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
<img width="543" height="407" alt="image"
src="https://github.com/user-attachments/assets/ab37d41e-2909-40ed-b537-191ebb54d979"
/>

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-19 14:41:54 -04:00 committed by GitHub
parent 5050b087e1
commit 790069d818
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 679 additions and 65 deletions

View file

@ -189,6 +189,10 @@ android {
isIncludeAndroidResources = true
}
}
lint {
disable.add("MissingTranslation")
}
}
protobuf {

View file

@ -30,6 +30,10 @@
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
</queries>
<application

View file

@ -30,6 +30,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import com.github.damontecres.wholphin.data.ServerRepository
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.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
@ -70,6 +71,7 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
@ -151,13 +153,16 @@ class MainActivity : AppCompatActivity() {
if (backStackStr != null) {
Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
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 {
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
@ -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

View file

@ -830,6 +830,17 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number },
)
val ExternalPlayerApp =
AppStringPreference<AppPreferences>(
title = R.string.external_player,
defaultValue = "",
getter = { it.playbackPreferences.externalPlayer },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { externalPlayer = value }
},
summary = null,
)
val ExoPlayerSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.exoplayer_options,
@ -1199,6 +1210,10 @@ val advancedPreferences =
AppPreference.MpvSettings,
),
),
ConditionalPreferences(
{ it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER },
listOf(AppPreference.ExternalPlayerApp),
),
),
),
)

View file

@ -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
}

View file

@ -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,

View file

@ -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,12 +79,20 @@ fun DestinationContent(
is Destination.PlaybackList,
is Destination.Playback,
-> {
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 -> {
PreferencesPage(

View file

@ -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<MediaStream> { 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<UUID?>(KEY_ID)
try {
val mediaSourceId = savedStateHandle.get<String?>(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<PlayExternalViewModel, PlayExternalViewModel.Factory>(
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 { }
}
}
}
}
}

View file

@ -185,7 +185,7 @@ class PlaybackViewModel
private val jobs = mutableListOf<Job>()
val nextUp = MutableLiveData<BaseItem?>()
private var isPlaylist = false
private val isPlaylist = destination is Destination.PlaybackList
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(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,

View file

@ -201,6 +201,10 @@ object TrackSelectionUtils {
}
}
}
PlayerBackend.EXTERNAL_PLAYER -> {
throw IllegalStateException("Cannot calculate tracks external playback")
}
}
}

View file

@ -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 <T> ChoicePreference(
title: String,
summary: String?,
possibleValues: List<T>,
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<DialogParams?>(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,
)
}
}
}

View file

@ -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 <T> 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 = {
possibleValues = values,
selectedIndex = selectedIndex,
onValueChange = { index ->
onValueChange(preference.indexToValue(index))
dialogParams = null
},
)
},
)
},
interactionSource = interactionSource,
modifier = modifier,
interactionSource = interactionSource,
subtitleDisplay = { index, _ ->
subtitles?.getOrNull(index)?.takeIf { it.isNotNullOrBlank() }?.let {
{
Text(text = it)
}
}
},
)
}

View file

@ -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(

View file

@ -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<Release>>(DataLoadingState.Pending)
val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(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<ExternalPlayerApp> {
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
}

View file

@ -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{

View file

@ -660,10 +660,12 @@
<string name="exoplayer" translatable="false">ExoPlayer</string>
<string name="mpv" translatable="false">MPV</string>
<string name="prefer_mpv">Prefer MPV</string>
<string name="external_player">External Player</string>
<string-array name="player_backend_options">
<item>@string/exoplayer</item>
<item>@string/mpv</item>
<item>@string/prefer_mpv</item>
<item>@string/external_player</item>
</string-array>
<string name="player_backend_options_subtitles_prefer_mpv">Use ExoPlayer for HDR playback</string>
@ -671,6 +673,7 @@
<item /><!-- Intentionally blank -->
<item /><!-- Intentionally blank -->
<item>@string/player_backend_options_subtitles_prefer_mpv</item>
<item /><!-- Intentionally blank -->
</string-array>
<string name="aspect_ratios_poster">Poster (2:3)</string>
@ -759,7 +762,7 @@
<string name="discover_movies">Discover Movies</string>
<string name="studios_in">Studios in %1$s</string>
<string name="last_played">Last played</string>
<string name="system_default">System default</string>
<string name="ass_subtitle_mode_libass">Direct play with libass</string>
<string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</string>
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string>