Parse intents to search, view, or play media (#1688)

## Description
Adds parsing for intents to search, view, or play media based on the
action and parameters. This allows for remotely opening Wholphin and
starting playback.

This is considered experimental, so the syntax or behaviors may change!

See the `Intents.md` file in this PR for syntax and examples.

### Related issues
Replaces https://github.com/damontecres/Wholphin/pull/738
Closes https://github.com/damontecres/Wholphin/discussions/1095

### Testing
Emulator with the test commands in the docs

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-07-20 10:04:34 -04:00 • committed by GitHub
parent 4f5cd3e7b1
commit b1f9c32a19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 415 additions and 78 deletions

View file

@ -36,6 +36,8 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentResult
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
@ -54,7 +56,6 @@ import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
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
@ -80,7 +81,6 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
@ -145,6 +145,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var screensaverService: ScreensaverService
@Inject
lateinit var intentService: IntentService
private var signInAuto = true
private var playerBackend: PlayerBackend? = null
@ -187,8 +190,7 @@ class MainActivity : AppCompatActivity() {
}
navigationManager.backStack = NavBackStack(*backStack.toTypedArray())
} else {
val startDestination = intent?.let(::extractDestination) ?: Destination.Home()
navigationManager.backStack = NavBackStack(startDestination)
navigationManager.backStack = NavBackStack(Destination.Home())
}
viewModel.serverRepository.currentUserFlow
@ -223,7 +225,7 @@ class MainActivity : AppCompatActivity() {
playerBackend = prefs.playbackPreferences.playerBackend
}
viewModel.appStart()
viewModel.appStart(intent)
setContent {
Surface(
Modifier
@ -307,7 +309,7 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() {
super.onRestart()
Timber.d("onRestart")
viewModel.appStart()
viewModel.appStart(null)
if (!playExternalViewModel.launched.value) {
// If restarting during playback that is not external, go back a page
val lastDest = navigationManager.backStack.lastOrNull()
@ -384,42 +386,9 @@ class MainActivity : AppCompatActivity() {
super.onNewIntent(intent)
Timber.v("onNewIntent")
setIntent(intent)
extractDestination(intent)?.let {
navigationManager.replace(it)
}
viewModel.appStart(intent)
}
private fun extractDestination(intent: Intent): Destination? =
intent.let {
val itemId =
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
val type =
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
if (itemId != null && type != null) {
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
Destination.SeriesOverview(
itemId = seriesId,
type = BaseItemKind.SERIES,
seasonEpisode =
SeasonEpisodeIds(
seasonId = seasonId,
seasonNumber = seasonNumber,
episodeId = itemId,
episodeNumber = episodeNumber,
),
)
} else {
Destination.MediaItem(itemId, type)
}
} else {
null
}
}
fun changeDisplayMode(modeId: Int) {
lifecycleScope.launch(WholphinDispatchers.Main + ExceptionHandler(autoToast = true)) {
val attrs = window.attributes
@ -431,13 +400,6 @@ class MainActivity : AppCompatActivity() {
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
const val INTENT_SERIES_ID = "seriesId"
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
private const val KEY_BACK_STACK = "backStack"
private const val KEY_EXTERNAL_PLAYER = "extPlayer"
@ -453,16 +415,55 @@ class MainActivityViewModel
@param:ApplicationContext private val context: Context,
private val preferences: DataStore<AppPreferences>,
val serverRepository: ServerRepository,
private val navigationManager: SetupNavigationManager,
private val setupNavigationManager: SetupNavigationManager,
private val navigationManager: NavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
private val intentService: IntentService,
) : ViewModel() {
private val mutex = Mutex()
fun appStart() {
fun appStart(intent: Intent?) {
viewModelScope.launchDefault {
mutex.withLock {
try {
val result = intent?.let { intentService.parseIntent(intent) }
when (result) {
is IntentResult.Error -> {
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent: %s", result.message)
showToast(context, "Invalid intent: ${result.message}")
return@withLock
}
is IntentResult.Target -> {
val current = serverRepository.current.value
if (current != null) {
Timber.i("Received valid intent, switching to AppContent")
navigationManager.replace(result.destination)
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
} else {
// This should never happen, but just reset app state if it does
setupNavigationManager.navigateTo(SetupDestination.ServerList)
Timber.e("Error parsing intent, no user is active")
showToast(context, "An error occurred parsing the intent")
}
return@withLock
}
IntentResult.NoOp,
null,
-> {
// No-op, proceed below
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error parsing intent")
}
try {
val needUpgrade = appUpgradeHandler.needUpgrade()
if (needUpgrade) {
@ -479,9 +480,9 @@ class MainActivityViewModel
val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val profileProtected =
serverRepository.current.value?.user?.let {
it.hasPin || it.requireLogin
} == true
serverRepository.current.value
?.user
?.isProtected == true
if (prefs.signInAutomatically && !profileProtected) {
val current =
serverRepository.restoreSession(
@ -490,38 +491,46 @@ class MainActivityViewModel
)
if (current != null) {
if (current.user.hasPin || current.user.requireLogin) {
navigationManager.navigateTo(SetupDestination.UserList(current.server))
setupNavigationManager.navigateTo(
SetupDestination.UserList(
current.server,
),
)
} else {
// Restored
navigationManager.navigateTo(SetupDestination.AppContent(current))
setupNavigationManager.navigateTo(
SetupDestination.AppContent(
current,
),
)
}
} else {
// Did not restore
navigationManager.navigateTo(SetupDestination.ServerList)
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
navigationManager.navigateTo(SetupDestination.Loading)
setupNavigationManager.navigateTo(SetupDestination.Loading)
backdropService.clearBackdrop()
val currentServerId = prefs.currentServerId?.toUUIDOrNull()
if (currentServerId != null) {
val currentServer =
serverRepository.serverDao.getServer(currentServerId)?.server
if (currentServer != null) {
navigationManager.navigateTo(
setupNavigationManager.navigateTo(
SetupDestination.UserList(
currentServer,
),
)
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error during appStart")
navigationManager.navigateTo(SetupDestination.ServerList)
setupNavigationManager.navigateTo(SetupDestination.ServerList)
}
}
}

View file

@ -71,6 +71,8 @@ data class JellyfinUser(
) {
val hasPin: Boolean get() = pin.isNotNullOrBlank()
val isProtected: Boolean get() = hasPin || requireLogin
override fun toString(): String =
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " +
"accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " +

View file

@ -0,0 +1,164 @@
package com.github.damontecres.wholphin.services
import android.app.SearchManager
import android.content.Intent
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.nav.Destination
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class IntentService
@Inject
constructor(
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
) {
suspend fun parseIntent(intent: Intent): IntentResult {
Timber.v("Parsing intent %s", intent)
Timber.v("Intent extras: %s", intent.extras)
Timber.v("Intent data: %s", intent.data)
if (intent.getStringParam("type") != null) {
return getDestinationFromChannel(intent)?.let { IntentResult.Target(it) }
?: IntentResult.Error("Invalid parameters")
}
val action = intent.action ?: intent.data?.host
if (action == Intent.ACTION_MAIN || action.isNullOrBlank()) {
return IntentResult.NoOp
}
val userId = intent.getStringParam("userId")?.toUUIDOrNull()
val serverId = intent.getStringParam("serverId")?.toUUIDOrNull()
if (userId != null && serverId != null) {
Timber.v("Intent switches user")
val user =
serverRepository.serverDao.getUser(serverId, userId)
?: return IntentResult.Error("User not found")
if (!user.isProtected) {
serverRepository.restoreSession(serverId, userId)
} else {
return IntentResult.Error("Cannot switch to specified user")
}
} else if (serverRepository.currentUser == null) {
Timber.v("No current user, so restoring last")
val appPrefs = userPreferencesService.flow.first().appPreferences
if (appPrefs.signInAutomatically) {
val userId = appPrefs.currentUserId.toUUIDOrNull()
val serverId = appPrefs.currentServerId.toUUIDOrNull()
if (userId != null && serverId != null) {
serverRepository.restoreSession(serverId, userId)
?: return IntentResult.Error("Error restoring user")
} else {
return IntentResult.Error("Could not auto sign-in, specify a server & user")
}
} else {
return IntentResult.Error("Auto sign-in not enabled, specify a server & user")
}
}
if (action == Intent.ACTION_SEARCH || action == "search") {
val query = intent.getStringParam(SearchManager.QUERY)
return IntentResult.Target(Destination.Search(query ?: ""))
}
val itemId =
intent.getStringParam("itemId")?.toUUIDOrNull()
?: return IntentResult.Error("No item id provided")
val item =
try {
api.userLibraryApi
.getItem(itemId)
.content
.let { BaseItem(it) }
} catch (ex: Exception) {
Timber.w(ex, "Error fetching item %s", itemId)
return IntentResult.Error("Could not fetch item $itemId")
}
val destination =
when (action) {
Intent.ACTION_VIEW, "view" -> {
item.destination()
}
"com.github.damontecres.wholphin.PLAYBACK", "play" -> {
val position = intent.getLongParam("position")?.coerceAtLeast(0)
Destination.Playback(
itemId = itemId,
positionMs = position ?: 0L,
)
}
else -> {
return IntentResult.Error("Invalid action: ${intent.action}")
}
}
return IntentResult.Target(destination)
}
private fun Intent.getStringParam(key: String) = getStringExtra(key) ?: data?.getQueryParameter(key)
private fun Intent.getLongParam(key: String) =
getLongExtra(key, -1).takeIf { it >= 0 } ?: data?.getQueryParameter(key)?.toLongOrNull()
private fun getDestinationFromChannel(intent: Intent): Destination? =
intent.let {
val itemId =
it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull()
val type =
it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull)
if (itemId != null && type != null) {
val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull()
val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull()
val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1)
val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1)
if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) {
Destination.SeriesOverview(
itemId = seriesId,
type = BaseItemKind.SERIES,
seasonEpisode =
SeasonEpisodeIds(
seasonId = seasonId,
seasonNumber = seasonNumber,
episodeId = itemId,
episodeNumber = episodeNumber,
),
)
} else {
Destination.MediaItem(itemId, type)
}
} else {
null
}
}
companion object {
const val INTENT_ITEM_ID = "itemId"
const val INTENT_ITEM_TYPE = "itemType"
const val INTENT_SERIES_ID = "seriesId"
const val INTENT_EPISODE_NUMBER = "epNum"
const val INTENT_SEASON_NUMBER = "seaNum"
const val INTENT_SEASON_ID = "seaId"
}
}
sealed interface IntentResult {
data class Error(
val message: String,
) : IntentResult
data object NoOp : IntentResult
data class Target(
val destination: Destination,
) : IntentResult
}

View file

@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.IntentService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.ui.SlimItemFields
import dagger.assisted.Assisted
@ -226,27 +227,27 @@ class TvProviderWorker
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
MainActivity.INTENT_SERIES_ID,
IntentService.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
MainActivity.INTENT_SEASON_ID,
IntentService.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
MainActivity.INTENT_SEASON_NUMBER,
IntentService.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
MainActivity.INTENT_EPISODE_NUMBER,
IntentService.INTENT_EPISODE_NUMBER,
it,
)
}
@ -363,27 +364,27 @@ class TvProviderWorker
setIntent(
Intent(context, MainActivity::class.java)
.putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString())
.putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName)
.putExtra(IntentService.INTENT_ITEM_ID, item.id.toString())
.putExtra(IntentService.INTENT_ITEM_TYPE, item.type.serialName)
.apply {
if (item.type == BaseItemKind.EPISODE) {
putExtra(
MainActivity.INTENT_SERIES_ID,
IntentService.INTENT_SERIES_ID,
dto.seriesId?.toString(),
)
putExtra(
MainActivity.INTENT_SEASON_ID,
IntentService.INTENT_SEASON_ID,
dto.seasonId?.toString(),
)
dto.parentIndexNumber?.let {
putExtra(
MainActivity.INTENT_SEASON_NUMBER,
IntentService.INTENT_SEASON_NUMBER,
it,
)
}
dto.indexNumber?.let {
putExtra(
MainActivity.INTENT_EPISODE_NUMBER,
IntentService.INTENT_EPISODE_NUMBER,
it,
)
}

View file

@ -496,6 +496,7 @@ private const val COMBINED_ROW = TAB_ROW + 1
@Composable
fun SearchPage(
initialQuery: String,
userPreferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = hiltViewModel(),
@ -513,7 +514,7 @@ fun SearchPage(
val voiceSearchButtonVisible = prefs.showVoiceSearchButton
// val query = rememberTextFieldState()
var query by rememberSaveable { mutableStateOf("") }
var query by rememberSaveable { mutableStateOf(initialQuery) }
val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } }
val seerrActive by viewModel.seerrActive.collectAsState(initial = false)

View file

@ -54,10 +54,11 @@ sealed class Destination(
) : Destination(true)
@Serializable
data object UserAppPreferences : Destination(true)
data class Search(
val query: String = "",
) : Destination()
@Serializable
data object Search : Destination()
data object UserAppPreferences : Destination(true)
@Serializable
data class SeriesOverview(

View file

@ -351,9 +351,10 @@ fun DestinationContent(
LicenseInfo(modifier)
}
Destination.Search -> {
is Destination.Search -> {
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
SearchPage(
initialQuery = destination.query,
userPreferences = preferences,
modifier = modifier,
)

View file

@ -409,7 +409,7 @@ fun NavDrawer(
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(SEARCH_INDEX)
viewModel.navigationManager.navigateToFromDrawer(Destination.Search)
viewModel.navigationManager.navigateToFromDrawer(Destination.Search())
},
modifier =
Modifier