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.launchDefault
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.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@ -82,6 +83,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: MainActivityViewModel by viewModels()
private val playExternalViewModel: PlayExternalViewModel by viewModels()
@Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@ -152,12 +154,10 @@ class MainActivity : AppCompatActivity() {
if (backStackStr != null) {
Timber.d("Restoring back stack")
var backStack = json.decodeFromString<List<Destination>>(backStackStr)
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
if (!playExternalViewModel.launched.value) {
val lastDest = backStack.lastOrNull()
if (lastDest is Destination.Playback ||
lastDest is Destination.PlaybackList ||
lastDest is Destination.Slideshow
) {
if (lastDest.isPlayback) {
Timber.v("Restoring back stack with playback")
backStack = backStack.toMutableList().apply { removeAt(lastIndex) }
}
@ -271,6 +271,14 @@ class MainActivity : AppCompatActivity() {
super.onRestart()
Timber.d("onRestart")
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() {
@ -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
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
}

View file

@ -8,14 +8,13 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
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.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
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.ui.components.ErrorMessage
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.isNotNullOrBlank
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.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
@ -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.videosApi
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.RepeatMode
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 javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel(assistedFactory = PlayExternalViewModel.Factory::class)
@HiltViewModel
class PlayExternalViewModel
@AssistedInject
@Inject
constructor(
private val savedStateHandle: SavedStateHandle,
@param:ApplicationContext private val context: Context,
@ -75,16 +77,13 @@ class PlayExternalViewModel
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 launched = savedStateHandle.getMutableStateFlow("launched", false)
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 {
val prefs = userPreferencesService.getCurrent()
val positionMs: Long
@ -252,7 +251,17 @@ class PlayExternalViewModel
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 {
PlayExternalState(
loading = LoadingState.Success,
@ -278,10 +287,10 @@ class PlayExternalViewModel
return@launchDefault
}
Timber.v(
"Result: result=%s, itemId=%s action=%s",
"Result: result=%s, action=%s, itemId=%s",
result.resultCode,
itemId,
result.data?.action,
itemId,
)
if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED ||
// Vimu return 1 for video completion
@ -295,7 +304,7 @@ class PlayExternalViewModel
position =
data
.getLongExtra("extra_position", Long.MIN_VALUE)
.takeIf { it >= 0 }
.takeIf { it > 0 }
}
// mpv-android: https://mpv-android.github.io/mpv-android/intent.html
@ -326,6 +335,7 @@ class PlayExternalViewModel
}
}
Timber.v("Result position: %s", position?.milliseconds)
if (position != null || result.data?.action != null) {
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = itemId,
@ -334,11 +344,18 @@ class PlayExternalViewModel
failed = false,
),
)
}
} 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")
}
navigationManager.goBack()
state.update { PlayExternalState() }
launched.update { false }
} catch (_: CancellationException) {
} catch (ex: Exception) {
Timber.e(ex, "Error during external playback of %s", itemId)
@ -359,7 +376,7 @@ class PlayExternalViewModel
}
data class PlayExternalState(
val loading: LoadingState = LoadingState.Loading,
val loading: LoadingState = LoadingState.Pending,
val intent: Intent = Intent(),
)
@ -369,8 +386,8 @@ fun PlayExternalPage(
destination: Destination,
modifier: Modifier = Modifier,
viewModel: PlayExternalViewModel =
hiltViewModel<PlayExternalViewModel, PlayExternalViewModel.Factory>(
creationCallback = { it.create(destination) },
hiltViewModel(
viewModelStoreOwner = LocalContext.current.findActivity() as AppCompatActivity,
),
) {
val launcher =
@ -380,15 +397,18 @@ fun PlayExternalPage(
)
val state by viewModel.state.collectAsState()
var launched by rememberSaveable { mutableStateOf(false) }
if (!launched) {
val launched by viewModel.launched.collectAsState()
LaunchedEffect(Unit) {
viewModel.init()
if (!launched) {
viewModel.init(destination)
}
}
when (val l = state.loading) {
LoadingState.Pending,
LoadingState.Pending -> {
LoadingPage(modifier, false)
}
LoadingState.Loading,
-> {
LoadingPage(modifier)
@ -403,7 +423,7 @@ fun PlayExternalPage(
if (!launched) {
LifecycleStartEffect(Unit) {
Timber.i("Launching external playback")
launched = true
viewModel.launched.update { true }
try {
launcher.launch(state.intent)
} catch (ex: Exception) {