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