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

@ -2,7 +2,6 @@ package com.github.damontecres.wholphin.services
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject
@ -20,13 +19,14 @@ class PlaybackLifecycleObserver
private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) {
val lastDest = navigationManager.backStack.lastOrNull()
if (lastDest is Destination.Playback ||
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
navigationManager.goBack()
}
// TODO
// val lastDest = navigationManager.backStack.lastOrNull()
// if (lastDest is Destination.Playback ||
// lastDest is Destination.PlaybackList ||
// lastDest is Destination.Slideshow
// ) {
// navigationManager.goBack()
// }
wasPlaying = null
}

View file

@ -9,6 +9,7 @@ import androidx.annotation.OptIn
import androidx.datastore.core.DataStore
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
@ -126,6 +127,10 @@ class PlayerFactory
assHandler?.init(this)
}
}
PlayerBackend.EXTERNAL_PLAYER -> {
throw IllegalArgumentException("Cannot create a player for external playback")
}
}
currentPlayer = newPlayer
return PlayerCreation(newPlayer, assHandler)
@ -151,6 +156,7 @@ class WholphinRenderersFactory(
context: Context,
private val av1Enabled: Boolean,
) : DefaultRenderersFactory(context) {
@OptIn(ExperimentalApi::class)
override fun buildVideoRenderers(
context: Context,
extensionRendererMode: Int,