mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-06 02:40:58 +01:00
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:
parent
5050b087e1
commit
790069d818
16 changed files with 679 additions and 65 deletions
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -201,6 +201,10 @@ object TrackSelectionUtils {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayerBackend.EXTERNAL_PLAYER -> {
|
||||
throw IllegalStateException("Cannot calculate tracks external playback")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue