diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 02b20fb9..56c80b90 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -189,6 +189,10 @@ android {
isIncludeAndroidResources = true
}
}
+
+ lint {
+ disable.add("MissingTranslation")
+ }
}
protobuf {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8dd32ce9..e75ab500 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -30,6 +30,10 @@
+
+
+
+
>(backStackStr)
- val lastDest = backStack.lastOrNull()
- if (lastDest is Destination.Playback ||
- lastDest is Destination.PlaybackList ||
- lastDest is Destination.Slideshow
- ) {
- backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
+ if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
+ val lastDest = backStack.lastOrNull()
+ if (lastDest is Destination.Playback ||
+ lastDest is Destination.PlaybackList ||
+ lastDest is Destination.Slideshow
+ ) {
+ Timber.v("Restoring back stack with playback")
+ backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
+ }
}
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else {
@@ -314,6 +319,9 @@ class MainActivity : AppCompatActivity() {
Timber.d("onSaveInstanceState")
val str = json.encodeToString(navigationManager.backStack.toList())
outState.putString(KEY_BACK_STACK, str)
+ val playerBackend =
+ runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend
+ outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -390,6 +398,7 @@ class MainActivity : AppCompatActivity() {
const val INTENT_SEASON_ID = "seaId"
private const val KEY_BACK_STACK = "backStack"
+ private const val KEY_EXTERNAL_PLAYER = "extPlayer"
lateinit var instance: MainActivity
private set
diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
index 4463dd97..9ca6df06 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt
@@ -830,6 +830,17 @@ sealed interface AppPreference {
valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number },
)
+ val ExternalPlayerApp =
+ AppStringPreference(
+ title = R.string.external_player,
+ defaultValue = "",
+ getter = { it.playbackPreferences.externalPlayer },
+ setter = { prefs, value ->
+ prefs.updatePlaybackPreferences { externalPlayer = value }
+ },
+ summary = null,
+ )
+
val ExoPlayerSettings =
AppDestinationPreference(
title = R.string.exoplayer_options,
@@ -1199,6 +1210,10 @@ val advancedPreferences =
AppPreference.MpvSettings,
),
),
+ ConditionalPreferences(
+ { it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER },
+ listOf(AppPreference.ExternalPlayerApp),
+ ),
),
),
)
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
index 4be082a5..66fb851c 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt
@@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
-import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject
@@ -20,13 +19,14 @@ class PlaybackLifecycleObserver
private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) {
- val lastDest = navigationManager.backStack.lastOrNull()
- if (lastDest is Destination.Playback ||
- lastDest is Destination.PlaybackList ||
- lastDest is Destination.Slideshow
- ) {
- navigationManager.goBack()
- }
+ // TODO
+// val lastDest = navigationManager.backStack.lastOrNull()
+// if (lastDest is Destination.Playback ||
+// lastDest is Destination.PlaybackList ||
+// lastDest is Destination.Slideshow
+// ) {
+// navigationManager.goBack()
+// }
wasPlaying = null
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
index 335438e5..f8d5ad2b 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt
@@ -9,6 +9,7 @@ import androidx.annotation.OptIn
import androidx.datastore.core.DataStore
import androidx.media3.common.C
import androidx.media3.common.Player
+import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
@@ -126,6 +127,10 @@ class PlayerFactory
assHandler?.init(this)
}
}
+
+ PlayerBackend.EXTERNAL_PLAYER -> {
+ throw IllegalArgumentException("Cannot create a player for external playback")
+ }
}
currentPlayer = newPlayer
return PlayerCreation(newPlayer, assHandler)
@@ -151,6 +156,7 @@ class WholphinRenderersFactory(
context: Context,
private val av1Enabled: Boolean,
) : DefaultRenderersFactory(context) {
+ @OptIn(ExperimentalApi::class)
override fun buildVideoRenderers(
context: Context,
extensionRendererMode: Int,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
index 1aa92bb3..f6f3faa7 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt
@@ -8,6 +8,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions
import com.github.damontecres.wholphin.data.model.SeerrItemType
+import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid
import com.github.damontecres.wholphin.ui.components.LicenseInfo
@@ -40,6 +41,7 @@ import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid
import com.github.damontecres.wholphin.ui.main.HomePage
import com.github.damontecres.wholphin.ui.main.SearchPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
+import com.github.damontecres.wholphin.ui.playback.PlayExternalPage
import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
@@ -77,11 +79,19 @@ fun DestinationContent(
is Destination.PlaybackList,
is Destination.Playback,
-> {
- PlaybackPage(
- preferences = preferences,
- destination = destination,
- modifier = modifier,
- )
+ if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
+ PlayExternalPage(
+ preferences = preferences,
+ destination = destination,
+ modifier = modifier,
+ )
+ } else {
+ PlaybackPage(
+ preferences = preferences,
+ destination = destination,
+ modifier = modifier,
+ )
+ }
}
is Destination.Settings -> {
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
new file mode 100644
index 00000000..c14d184d
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
@@ -0,0 +1,417 @@
+package com.github.damontecres.wholphin.ui.playback
+
+import android.app.Activity
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.core.net.toUri
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.compose.LifecycleStartEffect
+import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.data.ItemPlaybackDao
+import com.github.damontecres.wholphin.data.ServerRepository
+import com.github.damontecres.wholphin.data.model.BaseItem
+import com.github.damontecres.wholphin.preferences.UserPreferences
+import com.github.damontecres.wholphin.services.NavigationManager
+import com.github.damontecres.wholphin.services.PlaylistCreationResult
+import com.github.damontecres.wholphin.services.PlaylistCreator
+import com.github.damontecres.wholphin.services.StreamChoiceService
+import com.github.damontecres.wholphin.services.UserPreferencesService
+import com.github.damontecres.wholphin.ui.components.ErrorMessage
+import com.github.damontecres.wholphin.ui.components.LoadingPage
+import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
+import com.github.damontecres.wholphin.ui.isNotNullOrBlank
+import com.github.damontecres.wholphin.ui.launchDefault
+import com.github.damontecres.wholphin.ui.nav.Destination
+import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
+import com.github.damontecres.wholphin.ui.showToast
+import com.github.damontecres.wholphin.util.LoadingState
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.playStateApi
+import org.jellyfin.sdk.api.client.extensions.subtitleApi
+import org.jellyfin.sdk.api.client.extensions.userLibraryApi
+import org.jellyfin.sdk.api.client.extensions.videosApi
+import org.jellyfin.sdk.model.api.MediaStream
+import org.jellyfin.sdk.model.api.PlaybackStopInfo
+import org.jellyfin.sdk.model.extensions.inWholeTicks
+import org.jellyfin.sdk.model.extensions.ticks
+import timber.log.Timber
+import java.io.File
+import java.util.UUID
+import kotlin.time.Duration.Companion.milliseconds
+
+@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class)
+class PlayExternalViewModel
+ @AssistedInject
+ constructor(
+ private val savedStateHandle: SavedStateHandle,
+ @param:ApplicationContext private val context: Context,
+ private val api: ApiClient,
+ private val serverRepository: ServerRepository,
+ private val itemPlaybackDao: ItemPlaybackDao,
+ private val playlistCreator: PlaylistCreator,
+ private val streamChoiceService: StreamChoiceService,
+ private val navigationManager: NavigationManager,
+ private val userPreferencesService: UserPreferencesService,
+ @Assisted val destination: Destination,
+ ) : ViewModel() {
+ @AssistedFactory
+ interface Factory {
+ fun create(destination: Destination): PlayExternalViewModel
+ }
+
+ val state = MutableStateFlow(PlayExternalState())
+
+ fun init() {
+ viewModelScope.launchDefault {
+ val prefs = userPreferencesService.getCurrent()
+ val positionMs: Long
+ val itemId =
+ when (val d = destination) {
+ is Destination.Playback -> {
+ positionMs = d.positionMs
+ d.itemId
+ }
+
+ is Destination.PlaybackList -> {
+ positionMs = 0
+ d.itemId
+ }
+
+ else -> {
+ throw IllegalArgumentException("Destination not supported: $destination")
+ }
+ }
+ try {
+ val queriedItem = api.userLibraryApi.getItem(itemId).content
+ val base =
+ if (queriedItem.type.playable) {
+ queriedItem
+ } else if (destination is Destination.PlaybackList) {
+ val playlistResult =
+ playlistCreator.createFrom(
+ item = queriedItem,
+ startIndex = destination.startIndex ?: 0,
+ sortAndDirection = destination.sortAndDirection,
+ shuffled = destination.shuffle,
+ recursive = destination.recursive,
+ filter = destination.filter,
+ )
+ when (val r = playlistResult) {
+ is PlaylistCreationResult.Error -> {
+ state.update {
+ it.copy(
+ loading = LoadingState.Error(r.message, r.ex),
+ )
+ }
+ return@launchDefault
+ }
+
+ is PlaylistCreationResult.Success -> {
+ if (r.playlist.items.isEmpty()) {
+ showToast(context, "Playlist is empty", Toast.LENGTH_SHORT)
+ navigationManager.goBack()
+ return@launchDefault
+ }
+ r.playlist.items
+ .first()
+ .data
+ }
+ }
+ } else {
+ throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}")
+ }
+ val item = BaseItem(base, false)
+ val playbackConfig =
+ serverRepository.currentUser.value?.let { user ->
+ itemPlaybackDao.getItem(user, base.id)?.let {
+ Timber.v("Fetched itemPlayback from DB: %s", it)
+ if (it.sourceId != null) {
+ it
+ } else {
+ null
+ }
+ }
+ }
+ val mediaSource = streamChoiceService.chooseSource(base, playbackConfig)
+ val plc = streamChoiceService.getPlaybackLanguageChoice(base)
+ if (mediaSource == null) {
+ Timber.w("Media source is null")
+ return@launchDefault
+ }
+ savedStateHandle[KEY_ID] = base.id
+ savedStateHandle[KEY_MEDIA_ID] = mediaSource.id
+ val subtitleIndex =
+ streamChoiceService
+ .chooseSubtitleStream(
+ source = mediaSource,
+ audioStream = null,
+ seriesId = base.seriesId,
+ itemPlayback = playbackConfig,
+ plc = plc,
+ prefs = prefs,
+ )?.index
+ val externalSubtitles =
+ mediaSource.mediaStreams
+ ?.filter { it.isExternal }
+ ?.sortedWith(compareBy { it.index == subtitleIndex }.thenBy { it.isDefault })
+ .orEmpty()
+ val subtitleUrls =
+ externalSubtitles.map {
+ val format = it.path?.let { File(it).extension } ?: "srt"
+ api.subtitleApi
+ .getSubtitleUrl(
+ routeItemId = itemId,
+ routeMediaSourceId = mediaSource.id!!,
+ routeIndex = it.index,
+ routeFormat = format,
+ ).toUri()
+ }
+
+ val uri =
+ api.videosApi
+ .getVideoStreamUrl(
+ itemId = item.id,
+ mediaSourceId = mediaSource.id,
+ static = true,
+ ).toUri()
+ val playerId = prefs.appPreferences.playbackPreferences.externalPlayer
+ // Make sure player is available, user could have uninstalled it
+ val foundPlayer =
+ getExternalPlayers(context).firstOrNull { it.identifier == playerId } != null
+ val component =
+ if (playerId.isNotNullOrBlank() && foundPlayer) {
+ ComponentName.unflattenFromString(playerId)
+ } else {
+ null
+ }
+ Timber.v("playerId=%s, component=%s", playerId, component)
+ val title = "${item.title} ${item.subtitleLong}"
+ val intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setComponent(component)
+ setDataAndTypeAndNormalize(uri, "video/*")
+ putExtra("title", title)
+ putExtra("position", positionMs.toInt())
+
+ // MX/mpv
+ putExtra("return_result", true)
+ putExtra("secure_uri", true)
+ putExtra("subs", subtitleUrls.toTypedArray())
+ putExtra(
+ "subs.name",
+ externalSubtitles
+ .map { it.displayTitle ?: it.index.toString() }
+ .toTypedArray(),
+ )
+ if (subtitleIndex != null) {
+ externalSubtitles
+ .indexOfFirstOrNull { it.index == subtitleIndex }
+ ?.let {
+ putExtra("subs.enable", arrayOf(subtitleUrls[it]))
+ }
+ }
+
+ // VLC
+ if (subtitleUrls.isNotEmpty()) {
+ putExtra("subtitles_location", subtitleUrls.first().toString())
+ }
+ mediaSource.runTimeTicks?.ticks?.inWholeMilliseconds?.let {
+ putExtra("extra_duration", it)
+ }
+
+ // Vimu - https://vimu.tv/player-api/
+ putExtra("startfrom", positionMs.toInt())
+ putExtra("forceresume", false)
+ putExtra("forcename", title)
+ externalSubtitles
+ .indexOfFirstOrNull { it.index == subtitleIndex && it.codec == "srt" }
+ ?.let {
+ putExtra("forcedsrt", subtitleUrls[it])
+ }
+ }
+
+ state.update {
+ PlayExternalState(
+ loading = LoadingState.Success,
+ intent = intent,
+ )
+ }
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error for destination %s", destination)
+ state.update {
+ it.copy(loading = LoadingState.Error(ex))
+ }
+ }
+ }
+ }
+
+ fun onResult(result: ActivityResult) {
+ viewModelScope.launchDefault {
+ val itemId = savedStateHandle.get(KEY_ID)
+ try {
+ val mediaSourceId = savedStateHandle.get(KEY_MEDIA_ID)
+ if (itemId == null) {
+ Timber.w("itemId is null")
+ return@launchDefault
+ }
+ Timber.v(
+ "Result: result=%s, itemId=%s action=%s",
+ result.resultCode,
+ itemId,
+ result.data?.action,
+ )
+ if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
+ // Vimu return 1 for video completion
+ (result.data?.action == "net.gtvbox.videoplayer.result" && result.resultCode == 1)
+ ) {
+ val position: Long?
+ val data = result.data
+ when (data?.action) {
+ // VLC: https://wiki.videolan.org/Android_Player_Intents/
+ "org.videolan.vlc.player.result" -> {
+ position =
+ data
+ .getLongExtra("extra_position", Long.MIN_VALUE)
+ .takeIf { it >= 0 }
+ }
+
+ // mpv-android: https://mpv-android.github.io/mpv-android/intent.html
+ "is.xyz.mpv.MPVActivity.result",
+ // MX player: https://mx.j2inter.com/api
+ "com.mxtech.intent.result.VIEW",
+ // VIMU: https://vimu.tv/player-api/
+ "net.gtvbox.videoplayer.result",
+ -> {
+ position =
+ data
+ .getIntExtra("position", Int.MIN_VALUE)
+ .toLong()
+ .takeIf { it >= 0 }
+ }
+
+ else -> {
+ // Unsupported app
+ val posInt =
+ data
+ ?.getIntExtra("position", Int.MIN_VALUE)
+ ?.takeIf { it >= 0 }
+ ?.toLong()
+ position =
+ posInt ?: data
+ ?.getLongExtra("position", -1L)
+ ?.takeIf { it >= 0 }
+ }
+ }
+ Timber.v("Result position: %s", position?.milliseconds)
+ api.playStateApi.reportPlaybackStopped(
+ PlaybackStopInfo(
+ itemId = itemId,
+ mediaSourceId = mediaSourceId,
+ positionTicks = position?.milliseconds?.inWholeTicks,
+ failed = false,
+ ),
+ )
+ } else {
+ Timber.w("Activity result: %s", result.resultCode)
+ showToast(context, "Unknown result from external player")
+ }
+ navigationManager.goBack()
+ } catch (_: CancellationException) {
+ } catch (ex: Exception) {
+ Timber.e(ex, "Error during external playback of %s", itemId)
+ state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
+ }
+ }
+
+ fun reportException(ex: Exception) {
+ Timber.e(ex, "Error launching activity")
+ state.update { it.copy(loading = LoadingState.Error(ex)) }
+ }
+
+ companion object {
+ private const val KEY_ID = "itemId"
+ private const val KEY_MEDIA_ID = "mediaId"
+ }
+ }
+
+data class PlayExternalState(
+ val loading: LoadingState = LoadingState.Loading,
+ val intent: Intent = Intent(),
+)
+
+@Composable
+fun PlayExternalPage(
+ preferences: UserPreferences,
+ destination: Destination,
+ modifier: Modifier = Modifier,
+ viewModel: PlayExternalViewModel =
+ hiltViewModel(
+ creationCallback = { it.create(destination) },
+ ),
+) {
+ val launcher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult(),
+ onResult = viewModel::onResult,
+ )
+
+ val state by viewModel.state.collectAsState()
+ var launched by rememberSaveable { mutableStateOf(false) }
+ if (!launched) {
+ LaunchedEffect(Unit) {
+ viewModel.init()
+ }
+ }
+
+ when (val l = state.loading) {
+ LoadingState.Pending,
+ LoadingState.Loading,
+ -> {
+ LoadingPage(modifier)
+ }
+
+ is LoadingState.Error -> {
+ ErrorMessage(l, modifier)
+ }
+
+ LoadingState.Success -> {
+ LoadingPage(modifier)
+ if (!launched) {
+ LifecycleStartEffect(Unit) {
+ Timber.i("Launching external playback")
+ launched = true
+ try {
+ launcher.launch(state.intent)
+ } catch (ex: Exception) {
+ viewModel.reportException(ex)
+ }
+ onStopOrDispose { }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
index f6bc9018..9bb0e999 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt
@@ -185,7 +185,7 @@ class PlaybackViewModel
private val jobs = mutableListOf()
val nextUp = MutableLiveData()
- private var isPlaylist = false
+ private val isPlaylist = destination is Destination.PlaybackList
val playlist = MutableLiveData(Playlist(listOf()))
val subtitleSearchStatus = MutableLiveData(null)
@@ -233,6 +233,8 @@ class PlaybackViewModel
PlayerBackend.MPV -> PlayerBackend.MPV
PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV
+
+ PlayerBackend.EXTERNAL_PLAYER -> throw IllegalStateException("Cannot use this for external playback")
}
Timber.d("Selected backend: %s", playerBackend)
@@ -315,7 +317,6 @@ class PlaybackViewModel
if (queriedItem.type.playable) {
queriedItem
} else if (destination is Destination.PlaybackList) {
- isPlaylist = true
val playlistResult =
playlistCreator.createFrom(
item = queriedItem,
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt
index d056e6f3..b7566482 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt
@@ -201,6 +201,10 @@ object TrackSelectionUtils {
}
}
}
+
+ PlayerBackend.EXTERNAL_PLAYER -> {
+ throw IllegalStateException("Cannot calculate tracks external playback")
+ }
}
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt
new file mode 100644
index 00000000..b21479c8
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt
@@ -0,0 +1,69 @@
+package com.github.damontecres.wholphin.ui.preferences
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.tv.material3.Text
+import com.github.damontecres.wholphin.ui.components.DialogItem
+import com.github.damontecres.wholphin.ui.components.DialogParams
+import com.github.damontecres.wholphin.ui.components.DialogPopup
+import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
+
+@Composable
+fun ChoicePreference(
+ title: String,
+ summary: String?,
+ possibleValues: List,
+ selectedIndex: Int,
+ onValueChange: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) },
+ subtitleDisplay: (index: Int, item: T) -> @Composable (() -> Unit)? = { _, _ -> null },
+) {
+ var dialogParams by remember { mutableStateOf(null) }
+ ClickPreference(
+ title = title,
+ summary = summary,
+ onClick = {
+ dialogParams =
+ DialogParams(
+ title = title,
+ fromLongClick = false,
+ items =
+ possibleValues.mapIndexed { index, item ->
+ DialogItem(
+ headlineContent = { valueDisplay.invoke(index, item) },
+ leadingContent = {
+ SelectedLeadingContent(index == selectedIndex)
+ },
+ supportingContent = subtitleDisplay.invoke(index, item),
+ onClick = {
+ onValueChange.invoke(index)
+ dialogParams = null
+ },
+ )
+ },
+ )
+ },
+ interactionSource = interactionSource,
+ modifier = modifier,
+ )
+ AnimatedVisibility(dialogParams != null) {
+ dialogParams?.let {
+ DialogPopup(
+ showDialog = true,
+ title = it.title,
+ dialogItems = it.items,
+ onDismissRequest = { dialogParams = null },
+ waitToLoad = false,
+ dismissOnClick = false,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
index 1762f916..6d96667a 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt
@@ -3,8 +3,6 @@ package com.github.damontecres.wholphin.ui.preferences
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Done
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
@@ -23,7 +21,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
-import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
@@ -35,7 +32,6 @@ import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppSliderPreference
import com.github.damontecres.wholphin.preferences.AppStringPreference
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
-import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@@ -181,46 +177,23 @@ fun ComposablePreference(
.valueToIndex(value as T)
.let { values[it] }
val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) }
- ClickPreference(
+ ChoicePreference(
title = title,
summary = summary,
- onClick = {
- dialogParams =
- DialogParams(
- title = title,
- fromLongClick = false,
- items =
- values.mapIndexed { index, it ->
- DialogItem(
- headlineContent = {
- Text(it)
- },
- leadingContent = {
- if (index == selectedIndex) {
- Icon(
- imageVector = Icons.Default.Done,
- contentDescription = "selected",
- )
- }
- },
- supportingContent = {
- subtitles?.let {
- val text = subtitles[index]
- if (text.isNotNullOrBlank()) {
- Text(text)
- }
- }
- },
- onClick = {
- onValueChange(preference.indexToValue(index))
- dialogParams = null
- },
- )
- },
- )
+ possibleValues = values,
+ selectedIndex = selectedIndex,
+ onValueChange = { index ->
+ onValueChange(preference.indexToValue(index))
},
- interactionSource = interactionSource,
modifier = modifier,
+ interactionSource = interactionSource,
+ subtitleDisplay = { index, _ ->
+ subtitles?.getOrNull(index)?.takeIf { it.isNotNullOrBlank() }?.let {
+ {
+ Text(text = it)
+ }
+ }
+ },
)
}
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
index 18b630f6..a4234f17 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt
@@ -6,6 +6,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
@@ -13,10 +14,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
@@ -62,6 +65,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.ifElse
+import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playOnClickSound
@@ -99,6 +103,7 @@ fun PreferencesContent(
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
var showPinFlow by remember { mutableStateOf(false) }
var showVersionDialog by remember { mutableStateOf(false) }
+ val players by viewModel.externalPlayers.collectAsState()
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrConnection by viewModel.seerrConnection.collectAsState()
@@ -471,6 +476,45 @@ fun PreferencesContent(
)
}
+ AppPreference.ExternalPlayerApp -> {
+ val value = pref.getter.invoke(preferences).toString()
+ val selectedIndex =
+ remember(value, players) {
+ players.indexOfFirstOrNull { it.identifier == value }
+ } ?: 0
+ ChoicePreference(
+ title = stringResource(pref.title),
+ summary = players[selectedIndex].name,
+ possibleValues = players,
+ selectedIndex = selectedIndex,
+ onValueChange = { index ->
+ scope.launch(ExceptionHandler()) {
+ val newValue =
+ players.getOrNull(index)?.identifier ?: ""
+ preferences =
+ viewModel.preferenceDataStore.updateData { prefs ->
+ pref.setter.invoke(prefs, newValue)
+ }
+ }
+ },
+ valueDisplay = { index, item ->
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (item.icon != null) {
+ Image(
+ bitmap = item.icon,
+ contentDescription = null,
+ modifier = Modifier.width(40.dp),
+ )
+ }
+ Text(item.name)
+ }
+ },
+ )
+ }
+
else -> {
val value = pref.getter.invoke(preferences)
ComposablePreference(
diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
index 80dd7934..32d085a5 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt
@@ -1,9 +1,17 @@
package com.github.damontecres.wholphin.ui.preferences
+import android.content.ComponentName
import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.graphics.drawable.toBitmap
+import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
@@ -59,11 +67,22 @@ class PreferencesViewModel
val releaseNotes = MutableStateFlow>(DataLoadingState.Pending)
+ val externalPlayers = MutableStateFlow>(emptyList())
+
init {
viewModelScope.launchIO {
- serverRepository.currentUser.value?.let { user ->
-// fetchNavDrawerPins(user)
- }
+ val fakeIntent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*")
+ }
+ val externalPlayers = getExternalPlayers(context)
+ val systemDefault =
+ ExternalPlayerApp(
+ name = context.getString(R.string.system_default),
+ icon = null,
+ identifier = "",
+ )
+ this@PreferencesViewModel.externalPlayers.update { listOf(systemDefault) + externalPlayers }
}
}
@@ -134,3 +153,37 @@ class PreferencesViewModel
}
}
}
+
+data class ExternalPlayerApp(
+ val name: String,
+ val icon: ImageBitmap?,
+ val identifier: String,
+)
+
+fun getExternalPlayers(context: Context): List {
+ val fakeIntent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType("https://damontecres.com/video.mp4".toUri(), "video/*")
+ }
+ val externalPlayers =
+ context.packageManager
+ .queryIntentActivities(fakeIntent, PackageManager.MATCH_ALL)
+ .filter { it.priority >= 0 }
+ .map {
+ val component =
+ ComponentName(
+ it.activityInfo.packageName,
+ it.activityInfo.name,
+ )
+ ExternalPlayerApp(
+ name = it.loadLabel(context.packageManager).toString(),
+ icon =
+ it
+ .loadIcon(context.packageManager)
+ .toBitmap()
+ .asImageBitmap(),
+ identifier = component.flattenToString(),
+ )
+ }
+ return externalPlayers
+}
diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto
index 60dd3943..7b7b543b 100644
--- a/app/src/main/proto/WholphinDataStore.proto
+++ b/app/src/main/proto/WholphinDataStore.proto
@@ -34,6 +34,7 @@ enum PlayerBackend{
EXO_PLAYER = 0;
MPV = 1;
PREFER_MPV = 2;
+ EXTERNAL_PLAYER = 3;
}
message MpvOptions{
@@ -84,6 +85,7 @@ message PlaybackPreferences {
MpvOptions mpv_options = 21;
bool refresh_rate_switching = 22;
bool resolution_switching = 23;
+ string external_player = 24;
}
message HomePagePreferences{
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d00eed2d..8857efff 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -660,10 +660,12 @@
ExoPlayer
MPV
Prefer MPV
+ External Player
- @string/exoplayer
- @string/mpv
- @string/prefer_mpv
+ - @string/external_player
Use ExoPlayer for HDR playback
@@ -671,6 +673,7 @@
- @string/player_backend_options_subtitles_prefer_mpv
+
Poster (2:3)
@@ -759,7 +762,7 @@
Discover Movies
Studios in %1$s
Last played
-
+ System default
Direct play with libass
Direct play with ExoPlayer built-in
Burn in/transcode on server