mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02: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
|
|
@ -189,6 +189,10 @@ android {
|
||||||
isIncludeAndroidResources = true
|
isIncludeAndroidResources = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable.add("MissingTranslation")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
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.AppUpgradeHandler
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
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.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -151,12 +153,15 @@ class MainActivity : AppCompatActivity() {
|
||||||
if (backStackStr != null) {
|
if (backStackStr != null) {
|
||||||
Timber.d("Restoring back stack")
|
Timber.d("Restoring back stack")
|
||||||
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
|
||||||
val lastDest = backStack.lastOrNull()
|
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
|
||||||
if (lastDest is Destination.Playback ||
|
val lastDest = backStack.lastOrNull()
|
||||||
lastDest is Destination.PlaybackList ||
|
if (lastDest is Destination.Playback ||
|
||||||
lastDest is Destination.Slideshow
|
lastDest is Destination.PlaybackList ||
|
||||||
) {
|
lastDest is Destination.Slideshow
|
||||||
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
) {
|
||||||
|
Timber.v("Restoring back stack with playback")
|
||||||
|
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
|
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -314,6 +319,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
Timber.d("onSaveInstanceState")
|
Timber.d("onSaveInstanceState")
|
||||||
val str = json.encodeToString(navigationManager.backStack.toList())
|
val str = json.encodeToString(navigationManager.backStack.toList())
|
||||||
outState.putString(KEY_BACK_STACK, str)
|
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) {
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
|
@ -390,6 +398,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
const val INTENT_SEASON_ID = "seaId"
|
const val INTENT_SEASON_ID = "seaId"
|
||||||
|
|
||||||
private const val KEY_BACK_STACK = "backStack"
|
private const val KEY_BACK_STACK = "backStack"
|
||||||
|
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
|
||||||
|
|
||||||
lateinit var instance: MainActivity
|
lateinit var instance: MainActivity
|
||||||
private set
|
private set
|
||||||
|
|
|
||||||
|
|
@ -830,6 +830,17 @@ sealed interface AppPreference<Pref, T> {
|
||||||
valueToIndex = { if (it != PlayerBackend.UNRECOGNIZED) it.number else PlayerBackend.EXO_PLAYER.number },
|
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 =
|
val ExoPlayerSettings =
|
||||||
AppDestinationPreference<AppPreferences>(
|
AppDestinationPreference<AppPreferences>(
|
||||||
title = R.string.exoplayer_options,
|
title = R.string.exoplayer_options,
|
||||||
|
|
@ -1199,6 +1210,10 @@ val advancedPreferences =
|
||||||
AppPreference.MpvSettings,
|
AppPreference.MpvSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ConditionalPreferences(
|
||||||
|
{ it.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER },
|
||||||
|
listOf(AppPreference.ExternalPlayerApp),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
|
||||||
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
import dagger.hilt.android.scopes.ActivityRetainedScoped
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -20,13 +19,14 @@ class PlaybackLifecycleObserver
|
||||||
private var wasPlaying: Boolean? = null
|
private var wasPlaying: Boolean? = null
|
||||||
|
|
||||||
override fun onStart(owner: LifecycleOwner) {
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
val lastDest = navigationManager.backStack.lastOrNull()
|
// TODO
|
||||||
if (lastDest is Destination.Playback ||
|
// val lastDest = navigationManager.backStack.lastOrNull()
|
||||||
lastDest is Destination.PlaybackList ||
|
// if (lastDest is Destination.Playback ||
|
||||||
lastDest is Destination.Slideshow
|
// lastDest is Destination.PlaybackList ||
|
||||||
) {
|
// lastDest is Destination.Slideshow
|
||||||
navigationManager.goBack()
|
// ) {
|
||||||
}
|
// navigationManager.goBack()
|
||||||
|
// }
|
||||||
wasPlaying = null
|
wasPlaying = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.annotation.OptIn
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
|
|
@ -126,6 +127,10 @@ class PlayerFactory
|
||||||
assHandler?.init(this)
|
assHandler?.init(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlayerBackend.EXTERNAL_PLAYER -> {
|
||||||
|
throw IllegalArgumentException("Cannot create a player for external playback")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
currentPlayer = newPlayer
|
currentPlayer = newPlayer
|
||||||
return PlayerCreation(newPlayer, assHandler)
|
return PlayerCreation(newPlayer, assHandler)
|
||||||
|
|
@ -151,6 +156,7 @@ class WholphinRenderersFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val av1Enabled: Boolean,
|
private val av1Enabled: Boolean,
|
||||||
) : DefaultRenderersFactory(context) {
|
) : DefaultRenderersFactory(context) {
|
||||||
|
@OptIn(ExperimentalApi::class)
|
||||||
override fun buildVideoRenderers(
|
override fun buildVideoRenderers(
|
||||||
context: Context,
|
context: Context,
|
||||||
extensionRendererMode: Int,
|
extensionRendererMode: Int,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions
|
||||||
import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions
|
import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
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.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.ItemGrid
|
import com.github.damontecres.wholphin.ui.components.ItemGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
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.HomePage
|
||||||
import com.github.damontecres.wholphin.ui.main.SearchPage
|
import com.github.damontecres.wholphin.ui.main.SearchPage
|
||||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
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.playback.PlaybackPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
|
||||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||||
|
|
@ -77,11 +79,19 @@ fun DestinationContent(
|
||||||
is Destination.PlaybackList,
|
is Destination.PlaybackList,
|
||||||
is Destination.Playback,
|
is Destination.Playback,
|
||||||
-> {
|
-> {
|
||||||
PlaybackPage(
|
if (preferences.appPreferences.playbackPreferences.playerBackend == PlayerBackend.EXTERNAL_PLAYER) {
|
||||||
preferences = preferences,
|
PlayExternalPage(
|
||||||
destination = destination,
|
preferences = preferences,
|
||||||
modifier = modifier,
|
destination = destination,
|
||||||
)
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PlaybackPage(
|
||||||
|
preferences = preferences,
|
||||||
|
destination = destination,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.Settings -> {
|
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>()
|
private val jobs = mutableListOf<Job>()
|
||||||
|
|
||||||
val nextUp = MutableLiveData<BaseItem?>()
|
val nextUp = MutableLiveData<BaseItem?>()
|
||||||
private var isPlaylist = false
|
private val isPlaylist = destination is Destination.PlaybackList
|
||||||
|
|
||||||
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
||||||
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null)
|
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null)
|
||||||
|
|
@ -233,6 +233,8 @@ class PlaybackViewModel
|
||||||
PlayerBackend.MPV -> PlayerBackend.MPV
|
PlayerBackend.MPV -> PlayerBackend.MPV
|
||||||
|
|
||||||
PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else 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)
|
Timber.d("Selected backend: %s", playerBackend)
|
||||||
|
|
@ -315,7 +317,6 @@ class PlaybackViewModel
|
||||||
if (queriedItem.type.playable) {
|
if (queriedItem.type.playable) {
|
||||||
queriedItem
|
queriedItem
|
||||||
} else if (destination is Destination.PlaybackList) {
|
} else if (destination is Destination.PlaybackList) {
|
||||||
isPlaylist = true
|
|
||||||
val playlistResult =
|
val playlistResult =
|
||||||
playlistCreator.createFrom(
|
playlistCreator.createFrom(
|
||||||
item = queriedItem,
|
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.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.runtime.getValue
|
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.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Icon
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.tv.material3.surfaceColorAtElevation
|
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.AppSliderPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppStringPreference
|
import com.github.damontecres.wholphin.preferences.AppStringPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
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.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -181,46 +177,23 @@ fun <T> ComposablePreference(
|
||||||
.valueToIndex(value as T)
|
.valueToIndex(value as T)
|
||||||
.let { values[it] }
|
.let { values[it] }
|
||||||
val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) }
|
val selectedIndex = remember(value) { preference.valueToIndex.invoke(value as T) }
|
||||||
ClickPreference(
|
ChoicePreference(
|
||||||
title = title,
|
title = title,
|
||||||
summary = summary,
|
summary = summary,
|
||||||
onClick = {
|
possibleValues = values,
|
||||||
dialogParams =
|
selectedIndex = selectedIndex,
|
||||||
DialogParams(
|
onValueChange = { index ->
|
||||||
title = title,
|
onValueChange(preference.indexToValue(index))
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
interactionSource = interactionSource,
|
|
||||||
modifier = modifier,
|
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.fadeOut
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.runtime.Composable
|
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.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||||
|
|
@ -99,6 +103,7 @@ fun PreferencesContent(
|
||||||
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
val currentServer by seerrVm.currentSeerrServer.collectAsState(null)
|
||||||
var showPinFlow by remember { mutableStateOf(false) }
|
var showPinFlow by remember { mutableStateOf(false) }
|
||||||
var showVersionDialog 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)) }
|
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||||
val seerrConnection by viewModel.seerrConnection.collectAsState()
|
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 -> {
|
else -> {
|
||||||
val value = pref.getter.invoke(preferences)
|
val value = pref.getter.invoke(preferences)
|
||||||
ComposablePreference(
|
ComposablePreference(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
package com.github.damontecres.wholphin.ui.preferences
|
package com.github.damontecres.wholphin.ui.preferences
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
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.datastore.core.DataStore
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
|
@ -59,11 +67,22 @@ class PreferencesViewModel
|
||||||
|
|
||||||
val releaseNotes = MutableStateFlow<DataLoadingState<Release>>(DataLoadingState.Pending)
|
val releaseNotes = MutableStateFlow<DataLoadingState<Release>>(DataLoadingState.Pending)
|
||||||
|
|
||||||
|
val externalPlayers = MutableStateFlow<List<ExternalPlayerApp>>(emptyList())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
serverRepository.currentUser.value?.let { user ->
|
val fakeIntent =
|
||||||
// fetchNavDrawerPins(user)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ enum PlayerBackend{
|
||||||
EXO_PLAYER = 0;
|
EXO_PLAYER = 0;
|
||||||
MPV = 1;
|
MPV = 1;
|
||||||
PREFER_MPV = 2;
|
PREFER_MPV = 2;
|
||||||
|
EXTERNAL_PLAYER = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MpvOptions{
|
message MpvOptions{
|
||||||
|
|
@ -84,6 +85,7 @@ message PlaybackPreferences {
|
||||||
MpvOptions mpv_options = 21;
|
MpvOptions mpv_options = 21;
|
||||||
bool refresh_rate_switching = 22;
|
bool refresh_rate_switching = 22;
|
||||||
bool resolution_switching = 23;
|
bool resolution_switching = 23;
|
||||||
|
string external_player = 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
message HomePagePreferences{
|
message HomePagePreferences{
|
||||||
|
|
|
||||||
|
|
@ -660,10 +660,12 @@
|
||||||
<string name="exoplayer" translatable="false">ExoPlayer</string>
|
<string name="exoplayer" translatable="false">ExoPlayer</string>
|
||||||
<string name="mpv" translatable="false">MPV</string>
|
<string name="mpv" translatable="false">MPV</string>
|
||||||
<string name="prefer_mpv">Prefer MPV</string>
|
<string name="prefer_mpv">Prefer MPV</string>
|
||||||
|
<string name="external_player">External Player</string>
|
||||||
<string-array name="player_backend_options">
|
<string-array name="player_backend_options">
|
||||||
<item>@string/exoplayer</item>
|
<item>@string/exoplayer</item>
|
||||||
<item>@string/mpv</item>
|
<item>@string/mpv</item>
|
||||||
<item>@string/prefer_mpv</item>
|
<item>@string/prefer_mpv</item>
|
||||||
|
<item>@string/external_player</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string name="player_backend_options_subtitles_prefer_mpv">Use ExoPlayer for HDR playback</string>
|
<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 /><!-- Intentionally blank -->
|
<item /><!-- Intentionally blank -->
|
||||||
<item>@string/player_backend_options_subtitles_prefer_mpv</item>
|
<item>@string/player_backend_options_subtitles_prefer_mpv</item>
|
||||||
|
<item /><!-- Intentionally blank -->
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string name="aspect_ratios_poster">Poster (2:3)</string>
|
<string name="aspect_ratios_poster">Poster (2:3)</string>
|
||||||
|
|
@ -759,7 +762,7 @@
|
||||||
<string name="discover_movies">Discover Movies</string>
|
<string name="discover_movies">Discover Movies</string>
|
||||||
<string name="studios_in">Studios in %1$s</string>
|
<string name="studios_in">Studios in %1$s</string>
|
||||||
<string name="last_played">Last played</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_libass">Direct play with libass</string>
|
||||||
<string name="ass_subtitle_mode_exoplayer">Direct play with ExoPlayer built-in</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>
|
<string name="ass_subtitle_mode_transcode">Burn in/transcode on server</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue