External player follow up fixes (#1294)

## Description
Fixes some issues when using an external player:
- Send playback started before launching so the last played date is
updated
- Only use VLC's position if >0 since it returns 0 when playback
completes

### Related issues
Follow up to #1256 

### Testing
Emulator & nvidia shield

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-04-23 15:08:25 -04:00 committed by GitHub
parent 0e37084a16
commit ab5c884072
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 76 additions and 50 deletions

View file

@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@ -82,6 +83,7 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val viewModel: MainActivityViewModel by viewModels() private val viewModel: MainActivityViewModel by viewModels()
private val playExternalViewModel: PlayExternalViewModel by viewModels()
@Inject @Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences> lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@ -152,12 +154,10 @@ 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)
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull() val lastDest = backStack.lastOrNull()
if (lastDest is Destination.Playback || if (lastDest.isPlayback) {
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
Timber.v("Restoring back stack with playback") Timber.v("Restoring back stack with playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) } backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
} }
@ -271,6 +271,14 @@ class MainActivity : AppCompatActivity() {
super.onRestart() super.onRestart()
Timber.d("onRestart") Timber.d("onRestart")
viewModel.appStart() viewModel.appStart()
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
if (lastDest.isPlayback) {
Timber.v("onRestart: go back from playback")
navigationManager.goBack()
}
}
} }
override fun onStop() { override fun onStop() {
@ -471,3 +479,9 @@ class MainActivityViewModel
} }
} }
} }
private val Destination?.isPlayback: Boolean
get() =
this is Destination.Playback ||
this is Destination.PlaybackList ||
this is Destination.Slideshow

View file

@ -19,14 +19,6 @@ class PlaybackLifecycleObserver
private var wasPlaying: Boolean? = null private var wasPlaying: Boolean? = null
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
// TODO
// val lastDest = navigationManager.backStack.lastOrNull()
// if (lastDest is Destination.Playback ||
// lastDest is Destination.PlaybackList ||
// lastDest is Destination.Slideshow
// ) {
// navigationManager.goBack()
// }
wasPlaying = null wasPlaying = null
} }

View file

@ -8,14 +8,13 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
@ -33,6 +32,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.findActivity
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull 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.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
@ -40,9 +40,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -54,17 +51,22 @@ import org.jellyfin.sdk.api.client.extensions.subtitleApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackOrder
import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class) @HiltViewModel
class PlayExternalViewModel class PlayExternalViewModel
@AssistedInject @Inject
constructor( constructor(
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
@ -75,16 +77,13 @@ class PlayExternalViewModel
private val streamChoiceService: StreamChoiceService, private val streamChoiceService: StreamChoiceService,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
@Assisted val destination: Destination,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory val launched = savedStateHandle.getMutableStateFlow("launched", false)
interface Factory {
fun create(destination: Destination): PlayExternalViewModel
}
val state = MutableStateFlow(PlayExternalState()) val state = MutableStateFlow(PlayExternalState())
fun init() { fun init(destination: Destination) {
Timber.v("init called: %s", destination)
state.update { it.copy(loading = LoadingState.Loading) }
viewModelScope.launchDefault { viewModelScope.launchDefault {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val positionMs: Long val positionMs: Long
@ -252,7 +251,17 @@ class PlayExternalViewModel
putExtra("forcedsrt", subtitleUrls[it]) putExtra("forcedsrt", subtitleUrls[it])
} }
} }
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
canSeek = false,
itemId = itemId,
isPaused = false,
playMethod = PlayMethod.DIRECT_PLAY,
repeatMode = RepeatMode.REPEAT_NONE,
playbackOrder = PlaybackOrder.DEFAULT,
isMuted = false,
),
)
state.update { state.update {
PlayExternalState( PlayExternalState(
loading = LoadingState.Success, loading = LoadingState.Success,
@ -278,10 +287,10 @@ class PlayExternalViewModel
return@launchDefault return@launchDefault
} }
Timber.v( Timber.v(
"Result: result=%s, itemId=%s action=%s", "Result: result=%s, action=%s, itemId=%s",
result.resultCode, result.resultCode,
itemId,
result.data?.action, result.data?.action,
itemId,
) )
if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED || if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
// Vimu return 1 for video completion // Vimu return 1 for video completion
@ -295,7 +304,7 @@ class PlayExternalViewModel
position = position =
data data
.getLongExtra("extra_position", Long.MIN_VALUE) .getLongExtra("extra_position", Long.MIN_VALUE)
.takeIf { it >= 0 } .takeIf { it > 0 }
} }
// mpv-android: https://mpv-android.github.io/mpv-android/intent.html // mpv-android: https://mpv-android.github.io/mpv-android/intent.html
@ -326,19 +335,27 @@ class PlayExternalViewModel
} }
} }
Timber.v("Result position: %s", position?.milliseconds) Timber.v("Result position: %s", position?.milliseconds)
api.playStateApi.reportPlaybackStopped( if (position != null || result.data?.action != null) {
PlaybackStopInfo( api.playStateApi.reportPlaybackStopped(
itemId = itemId, PlaybackStopInfo(
mediaSourceId = mediaSourceId, itemId = itemId,
positionTicks = position?.milliseconds?.inWholeTicks, mediaSourceId = mediaSourceId,
failed = false, positionTicks = position?.milliseconds?.inWholeTicks,
), failed = false,
) ),
)
}
} else { } else {
Timber.w("Activity result: %s", result.resultCode) Timber.w(
"Activity result: %s, action=%s",
result.resultCode,
result.data?.action,
)
showToast(context, "Unknown result from external player") showToast(context, "Unknown result from external player")
} }
navigationManager.goBack() navigationManager.goBack()
state.update { PlayExternalState() }
launched.update { false }
} catch (_: CancellationException) { } catch (_: CancellationException) {
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during external playback of %s", itemId) Timber.e(ex, "Error during external playback of %s", itemId)
@ -359,7 +376,7 @@ class PlayExternalViewModel
} }
data class PlayExternalState( data class PlayExternalState(
val loading: LoadingState = LoadingState.Loading, val loading: LoadingState = LoadingState.Pending,
val intent: Intent = Intent(), val intent: Intent = Intent(),
) )
@ -369,8 +386,8 @@ fun PlayExternalPage(
destination: Destination, destination: Destination,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: PlayExternalViewModel = viewModel: PlayExternalViewModel =
hiltViewModel<PlayExternalViewModel, PlayExternalViewModel.Factory>( hiltViewModel(
creationCallback = { it.create(destination) }, viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity,
), ),
) { ) {
val launcher = val launcher =
@ -380,15 +397,18 @@ fun PlayExternalPage(
) )
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var launched by rememberSaveable { mutableStateOf(false) } val launched by viewModel.launched.collectAsState()
if (!launched) { LaunchedEffect(Unit) {
LaunchedEffect(Unit) { if (!launched) {
viewModel.init() viewModel.init(destination)
} }
} }
when (val l = state.loading) { when (val l = state.loading) {
LoadingState.Pending, LoadingState.Pending -> {
LoadingPage(modifier, false)
}
LoadingState.Loading, LoadingState.Loading,
-> { -> {
LoadingPage(modifier) LoadingPage(modifier)
@ -403,7 +423,7 @@ fun PlayExternalPage(
if (!launched) { if (!launched) {
LifecycleStartEffect(Unit) { LifecycleStartEffect(Unit) {
Timber.i("Launching external playback") Timber.i("Launching external playback")
launched = true viewModel.launched.update { true }
try { try {
launcher.launch(state.intent) launcher.launch(state.intent)
} catch (ex: Exception) { } catch (ex: Exception) {