From 790069d818c5d701de234aba4fb4701a8e8d1ccc Mon Sep 17 00:00:00 2001
From: Ray <154766448+damontecres@users.noreply.github.com>
Date: Sun, 19 Apr 2026 14:41:54 -0400
Subject: [PATCH] Add support for external player playback (#1256)
## Description
Adds a new playback "backend" to play media in another app such as VLC.
By default, Wholphin will use the system default app. Typically if you
haven't chosen one, a dialog will show to pick which app. You can also
set a specific external app to use in Wholphin independent of the system
default.
Currently, this only support single item playback, ie playing next up
episodes is not supported yet.
Also since there is no standardized way to send resume position,
external subtitle info, etc, Wholphin makes a best effort. VLC,
mpv-android, & MX-Player are specifically tested/supported.
### Related issues
Closes #85
### Testing
Emulator & nvidia shield w/ VLC, mpv-android, & MX-player
## Screenshots
## AI or LLM usage
None
---
app/build.gradle.kts | 4 +
app/src/main/AndroidManifest.xml | 4 +
.../damontecres/wholphin/MainActivity.kt | 21 +-
.../wholphin/preferences/AppPreference.kt | 15 +
.../services/PlaybackLifecycleObserver.kt | 16 +-
.../wholphin/services/PlayerFactory.kt | 6 +
.../wholphin/ui/nav/DestinationContent.kt | 20 +-
.../wholphin/ui/playback/PlayExternalPage.kt | 417 ++++++++++++++++++
.../wholphin/ui/playback/PlaybackViewModel.kt | 5 +-
.../ui/playback/TrackSelectionUtils.kt | 4 +
.../ui/preferences/ChoicePreference.kt | 69 +++
.../ui/preferences/ComposablePreference.kt | 53 +--
.../ui/preferences/PreferencesContent.kt | 44 ++
.../ui/preferences/PreferencesViewModel.kt | 59 ++-
app/src/main/proto/WholphinDataStore.proto | 2 +
app/src/main/res/values/strings.xml | 5 +-
16 files changed, 679 insertions(+), 65 deletions(-)
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt
create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ChoicePreference.kt
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