Add support for external player playback (#1256)

## Description
Adds a new playback "backend" to play media in another app such as VLC.

By default, Wholphin will use the system default app. Typically if you
haven't chosen one, a dialog will show to pick which app. You can also
set a specific external app to use in Wholphin independent of the system
default.

Currently, this only support single item playback, ie playing next up
episodes is not supported yet.

Also since there is no standardized way to send resume position,
external subtitle info, etc, Wholphin makes a best effort. VLC,
mpv-android, & MX-Player are specifically tested/supported.

### Related issues
Closes #85

### Testing
Emulator & nvidia shield w/ VLC, mpv-android, & MX-player

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

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

View file

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