0.2.45 - Advanced analytics, logout old versions
This commit is contained in:
@@ -117,16 +117,6 @@
|
||||
android:theme="@style/Theme.Memby.Fullscreen"
|
||||
tools:ignore="DiscouragedApi" />
|
||||
|
||||
<!-- libmpv is a last-resort codec/container fallback. It receives an already
|
||||
resolved stream only after Media3 recovery has been exhausted. -->
|
||||
<activity
|
||||
android:name=".ui.player.MpvFallbackActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
|
||||
android:theme="@style/Theme.Memby.Fullscreen"
|
||||
tools:ignore="DiscouragedApi" />
|
||||
|
||||
<!-- The system screensaver (Daydream / Ambient mode source).
|
||||
Interactive: select to open the panel, play, or favourite. -->
|
||||
<service
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
|
||||
import com.ponzischeme89.memby.data.model.GatewayJourneyEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
|
||||
@@ -1389,6 +1391,36 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads authenticated per-user journey events without affecting the UI. */
|
||||
fun reportJourneyEvents(events: List<GatewayJourneyEvent>) {
|
||||
if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return
|
||||
// The server also verifies this guard. It prevents an old profile's buffered
|
||||
// events being attributed to the new profile during the handover frame.
|
||||
val currentUser = snapshot.userId ?: return
|
||||
val matching = events.filter { it.userId == currentUser }
|
||||
if (matching.isEmpty()) return
|
||||
scope.launch { runCatching { requireGateway().reportJourneyEvents(GatewayJourneyEvents(matching)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes both analytics streams before a profile token is replaced. Unlike the
|
||||
* periodic fire-and-forget path, this completes under the outgoing user's bearer token.
|
||||
*/
|
||||
suspend fun reportAnalyticsBeforeProfileSwitch(
|
||||
rowEvents: List<GatewayRowEvent>,
|
||||
journeyEvents: List<GatewayJourneyEvent>,
|
||||
) {
|
||||
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return
|
||||
val currentUser = snapshot.userId ?: return
|
||||
val matchingJourneys = journeyEvents.filter { it.userId == currentUser }
|
||||
runCatching {
|
||||
if (rowEvents.isNotEmpty()) requireGateway().reportRowEvents(GatewayRowEvents(rowEvents))
|
||||
if (matchingJourneys.isNotEmpty()) {
|
||||
requireGateway().reportJourneyEvents(GatewayJourneyEvents(matchingJourneys))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional decoration for the player pre-roll. Failure or direct-to-Emby mode returns
|
||||
* an empty schedule immediately; playback never depends on this request.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayJourneyEvent
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Builds one ordered, user-scoped foreground journey. Callers provide controlled labels;
|
||||
* this collector has no field for titles, queries, setting values or arbitrary metadata.
|
||||
*/
|
||||
class JourneyAnalytics(
|
||||
private val userId: String,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val journeyId: String = UUID.randomUUID().toString(),
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val buffer = ArrayList<GatewayJourneyEvent>()
|
||||
private var sequence = 0
|
||||
private var ended = false
|
||||
|
||||
init { track("session", "journey_start", screen = "home", feature = "app") }
|
||||
|
||||
fun track(
|
||||
category: String,
|
||||
action: String,
|
||||
screen: String = "",
|
||||
feature: String = "",
|
||||
source: String = "",
|
||||
target: String = "",
|
||||
itemId: String = "",
|
||||
itemType: String = "",
|
||||
outcome: String = "",
|
||||
) = synchronized(lock) {
|
||||
if (ended) return@synchronized
|
||||
buffer += GatewayJourneyEvent(
|
||||
userId = userId,
|
||||
journeyId = journeyId,
|
||||
sequence = sequence++,
|
||||
category = category,
|
||||
action = action,
|
||||
screen = clean(screen),
|
||||
feature = clean(feature),
|
||||
source = clean(source),
|
||||
target = clean(target),
|
||||
itemId = clean(itemId),
|
||||
itemType = clean(itemType),
|
||||
outcome = clean(outcome),
|
||||
occurredAt = timestamp(),
|
||||
)
|
||||
if (buffer.size > 200) buffer.removeAt(0)
|
||||
}
|
||||
|
||||
fun end(screen: String) = synchronized(lock) {
|
||||
if (!ended) {
|
||||
track("session", "journey_end", screen = screen, feature = "app")
|
||||
ended = true
|
||||
}
|
||||
}
|
||||
|
||||
fun drain(): List<GatewayJourneyEvent> = synchronized(lock) {
|
||||
val copy = buffer.toList(); buffer.clear(); copy
|
||||
}
|
||||
|
||||
private fun clean(value: String): String = value.take(100).filter {
|
||||
it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' || it == ':'
|
||||
}
|
||||
|
||||
private fun timestamp(): String = iso8601.get()!!.format(Date(now()))
|
||||
|
||||
companion object {
|
||||
private val iso8601 = object : ThreadLocal<SimpleDateFormat>() {
|
||||
override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -720,6 +720,27 @@ data class GatewayRowEvents(
|
||||
val events: List<GatewayRowEvent>,
|
||||
)
|
||||
|
||||
/** One privacy-bounded step in an authenticated user's app journey. */
|
||||
@Serializable
|
||||
data class GatewayJourneyEvent(
|
||||
val userId: String,
|
||||
val journeyId: String,
|
||||
val sequence: Int,
|
||||
val category: String,
|
||||
val action: String,
|
||||
val screen: String = "",
|
||||
val feature: String = "",
|
||||
val source: String = "",
|
||||
val target: String = "",
|
||||
val itemId: String = "",
|
||||
val itemType: String = "",
|
||||
val outcome: String = "",
|
||||
val occurredAt: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayJourneyEvents(val events: List<GatewayJourneyEvent>)
|
||||
|
||||
@Serializable
|
||||
data class GatewayPlaybackReport(
|
||||
val itemId: String,
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayJourneyEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayRows
|
||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
|
||||
@@ -297,4 +298,8 @@ interface GatewayApi {
|
||||
/** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */
|
||||
@POST("v1/analytics/rows")
|
||||
suspend fun reportRowEvents(@Body body: GatewayRowEvents)
|
||||
|
||||
/** Significant per-user journey steps. Fire-and-forget like row engagement. */
|
||||
@POST("v1/analytics/events")
|
||||
suspend fun reportJourneyEvents(@Body body: GatewayJourneyEvents)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.HomeCache
|
||||
import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||
import com.ponzischeme89.memby.data.analytics.JourneyAnalytics
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
@@ -161,6 +162,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
/** Row engagement, buffered here and uploaded in batches. */
|
||||
private val analytics = RowAnalytics()
|
||||
private val journey = JourneyAnalytics(repository.currentSettings.userId.orEmpty())
|
||||
|
||||
init {
|
||||
refreshAll()
|
||||
@@ -193,6 +195,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
fun flushAnalytics() {
|
||||
analytics.endFocus()
|
||||
repository.reportRowEvents(analytics.drain())
|
||||
repository.reportJourneyEvents(journey.drain())
|
||||
}
|
||||
|
||||
fun trackJourney(
|
||||
category: String, action: String, screen: String = "", feature: String = "",
|
||||
source: String = "", target: String = "", itemId: String = "", itemType: String = "",
|
||||
outcome: String = "",
|
||||
) = journey.track(category, action, screen, feature, source, target, itemId, itemType, outcome)
|
||||
|
||||
fun endJourney(screen: String) { journey.end(screen); flushAnalytics() }
|
||||
|
||||
suspend fun endJourneyBeforeProfileSwitch(screen: String) {
|
||||
analytics.endFocus()
|
||||
journey.track(
|
||||
category = "profile", action = "switch", screen = screen,
|
||||
feature = "profiles", source = screen, target = "profiles",
|
||||
)
|
||||
journey.end(screen)
|
||||
repository.reportAnalyticsBeforeProfileSwitch(analytics.drain(), journey.drain())
|
||||
}
|
||||
|
||||
fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) {
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -1829,6 +1830,10 @@ private fun HomeScreen(
|
||||
val playbackLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
) {
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "stop", screen = "player",
|
||||
feature = "playback", target = selectedDestination.name.lowercase(),
|
||||
)
|
||||
// PlayerActivity has finished and this activity owns the window again. Compose
|
||||
// needs one frame to reattach the saved card's focus node before it can receive
|
||||
// focus, especially when playback progress refreshed the row behind the player.
|
||||
@@ -1869,6 +1874,12 @@ private fun HomeScreen(
|
||||
}
|
||||
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
||||
if (launchingItem != null || !item.membyPlayable) return@playItem
|
||||
homeViewModel.trackJourney(
|
||||
category = "playback", action = "request", screen = selectedDestination.name.lowercase(),
|
||||
feature = "playback", source = returnRowId.orEmpty(), target = "player",
|
||||
itemId = item.id, itemType = item.type,
|
||||
)
|
||||
homeViewModel.flushAnalytics()
|
||||
launchingItem = item
|
||||
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
|
||||
// Resuming: open the player now and let it resolve the stream while it starts.
|
||||
@@ -1981,6 +1992,22 @@ private fun HomeScreen(
|
||||
repo.markForYouOpened()
|
||||
}
|
||||
}
|
||||
val journeyScreen = when {
|
||||
showSettings -> "settings"
|
||||
showNotifications -> "notifications"
|
||||
userSwitcherVisible || showProfiles -> "profiles"
|
||||
detailsItem != null -> "details"
|
||||
selectedMyShow != null -> "my_show_details"
|
||||
genreBrowseItemType != null -> "genre_browser"
|
||||
else -> selectedDestination.name.lowercase()
|
||||
}
|
||||
LaunchedEffect(journeyScreen) {
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "screen_view", screen = journeyScreen,
|
||||
feature = journeyScreen, target = journeyScreen,
|
||||
)
|
||||
}
|
||||
val latestJourneyScreen by rememberUpdatedState(journeyScreen)
|
||||
LaunchedEffect(showForYouNudge, selectedDestination, settings.hasOpenedForYou) {
|
||||
if (
|
||||
showForYouNudge &&
|
||||
@@ -2018,7 +2045,7 @@ private fun HomeScreen(
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
homeViewModel.flushAnalytics()
|
||||
homeViewModel.endJourney(latestJourneyScreen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2037,6 +2064,11 @@ private fun HomeScreen(
|
||||
navigationExpanded = it
|
||||
},
|
||||
onDestinationSelected = { destination ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "select",
|
||||
screen = journeyScreen, feature = destination.name.lowercase(),
|
||||
source = journeyScreen, target = destination.name.lowercase(),
|
||||
)
|
||||
genreBrowseItemType = null
|
||||
when (destination) {
|
||||
BrowseDestination.SETTINGS -> {
|
||||
@@ -2136,6 +2168,12 @@ private fun HomeScreen(
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
initialQuery = initialSearchQuery,
|
||||
onInitialQueryConsumed = { initialSearchQuery = null },
|
||||
onSearchStarted = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "search", action = "start", screen = "search",
|
||||
feature = "search", source = "search", target = "search_results",
|
||||
)
|
||||
},
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = SEARCH_ROW_ID
|
||||
@@ -2143,6 +2181,11 @@ private fun HomeScreen(
|
||||
destinationFocus[BrowseDestination.SEARCH] = SEARCH_ROW_ID to item.id
|
||||
homeViewModel.focusItem(item)
|
||||
if (item.membyPlayable) {
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "search",
|
||||
feature = "search", source = "search_results", target = "details",
|
||||
itemId = item.id, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
}
|
||||
@@ -2175,6 +2218,11 @@ private fun HomeScreen(
|
||||
returnRowId = GENRE_BROWSER_ROW_ID
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "genre_browser",
|
||||
feature = "genre_browse", source = "genre_results", target = "details",
|
||||
itemId = item.id, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
@@ -2335,6 +2383,11 @@ private fun HomeScreen(
|
||||
entryFocusRequester = contentFocusRequester,
|
||||
onFocused = { navigationExpanded = false },
|
||||
onOpenCategory = { categoryId ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "open",
|
||||
screen = selectedDestination.name.lowercase(), feature = "genre_browse",
|
||||
source = "genre_strip", target = "genre_browser",
|
||||
)
|
||||
genreBrowseInitialCategoryId = categoryId
|
||||
genreBrowseItemType = itemType
|
||||
},
|
||||
@@ -2355,7 +2408,14 @@ private fun HomeScreen(
|
||||
contentFocusRequester = contentFocusRequester.takeUnless {
|
||||
genreBrowserEnabled
|
||||
},
|
||||
onShowSelected = { selectedMyShow = it },
|
||||
onShowSelected = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "shows",
|
||||
feature = "my_shows", source = "my_shows", target = "my_show_details",
|
||||
itemId = it.itemId,
|
||||
)
|
||||
selectedMyShow = it
|
||||
},
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
)
|
||||
}
|
||||
@@ -2370,6 +2430,10 @@ private fun HomeScreen(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
onQuerySelected = { query ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "search", action = "select", screen = "favourites",
|
||||
feature = "recent_searches", source = "recent_searches", target = "search",
|
||||
)
|
||||
initialSearchQuery = query
|
||||
selectedDestination = BrowseDestination.SEARCH
|
||||
},
|
||||
@@ -2383,6 +2447,10 @@ private fun HomeScreen(
|
||||
loading = forYouState.loading,
|
||||
error = forYouState.error,
|
||||
onSelected = { minutes ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "recommendations", action = "change", screen = "for_you",
|
||||
feature = "for_you_time", outcome = "success",
|
||||
)
|
||||
homeViewModel.loadForYou(minutes)
|
||||
scope.launch { repo.setForYouMinutes(minutes) }
|
||||
},
|
||||
@@ -2499,6 +2567,12 @@ private fun HomeScreen(
|
||||
returnRowId = row.id
|
||||
returnItemId = item.id
|
||||
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open",
|
||||
screen = selectedDestination.name.lowercase(), feature = row.kind.name.lowercase(),
|
||||
source = row.id, target = if (item.membyPlayable) "details" else "content_action",
|
||||
itemId = item.id, itemType = item.type,
|
||||
)
|
||||
// A schedule card is an episode that has not aired, so
|
||||
// it is not playable and has no page of its own. What
|
||||
// the viewer asked for is the show — carrying the air
|
||||
@@ -2577,6 +2651,7 @@ private fun HomeScreen(
|
||||
if (profile.id != settings.activeProfileId) {
|
||||
switchingProfileId = profile.id
|
||||
scope.launch {
|
||||
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||
runCatching { repo.switchProfile(profile) }
|
||||
.onFailure { switchingProfileId = null }
|
||||
}
|
||||
@@ -2594,6 +2669,10 @@ private fun HomeScreen(
|
||||
},
|
||||
alertCount = notificationState.notifications.size,
|
||||
onOpenAlerts = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "notifications", action = "open", screen = journeyScreen,
|
||||
feature = "notifications", target = "notifications",
|
||||
)
|
||||
userSwitcherVisible = false
|
||||
navigationExpanded = false
|
||||
showNotifications = true
|
||||
@@ -2653,6 +2732,12 @@ private fun HomeScreen(
|
||||
onClose = closeSettings,
|
||||
overlay = false,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onAnalyticsEvent = { feature, action ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "settings", action = action, screen = "settings",
|
||||
feature = feature, outcome = "success",
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.offset { IntOffset(x = settingsShift.roundToPx(), y = 0) },
|
||||
@@ -2672,6 +2757,7 @@ private fun HomeScreen(
|
||||
} else {
|
||||
switchingProfileId = profile.id
|
||||
scope.launch {
|
||||
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||
runCatching { repo.switchProfile(profile) }
|
||||
.onFailure { switchingProfileId = null }
|
||||
}
|
||||
@@ -2684,7 +2770,12 @@ private fun HomeScreen(
|
||||
removingProfileId = null
|
||||
}
|
||||
},
|
||||
onAddProfile = { addingProfile = true },
|
||||
onAddProfile = {
|
||||
scope.launch {
|
||||
homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen)
|
||||
addingProfile = true
|
||||
}
|
||||
},
|
||||
onClose = { showProfiles = false },
|
||||
)
|
||||
}
|
||||
@@ -2714,6 +2805,11 @@ private fun HomeScreen(
|
||||
selected = selected,
|
||||
airingNotice = detailsAiringNotice,
|
||||
onOpenItem = { related ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "recommendations", action = "open", screen = "details",
|
||||
feature = "related", source = "related", target = "details",
|
||||
itemId = related.id, itemType = related.type,
|
||||
)
|
||||
detailsTrail = detailsTrail + selected
|
||||
// Ask for the full record the same way a focused card does. Some
|
||||
// routes here hand over a stub rather than a row item — the episode
|
||||
@@ -2733,9 +2829,21 @@ private fun HomeScreen(
|
||||
detailsAiringNotice = null
|
||||
playItem(it)
|
||||
},
|
||||
onToggleFavorite = homeViewModel::setFavorite,
|
||||
onToggleFavorite = { item, saved ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (saved) "favourite" else "unfavourite",
|
||||
screen = "details", feature = "favorites", itemId = item.id, itemType = item.type,
|
||||
outcome = "success",
|
||||
)
|
||||
homeViewModel.setFavorite(item, saved)
|
||||
},
|
||||
isMyShow = myShows.any { it.itemId == selected.id },
|
||||
onToggleMyShow = { item, saved ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (saved) "follow" else "unfollow",
|
||||
screen = "details", feature = "my_shows", itemId = item.id, itemType = item.type,
|
||||
outcome = "success",
|
||||
)
|
||||
// Optimistic, the way a favourite already is. Following a show is a
|
||||
// press with an obvious outcome, and the server's answer to it is the
|
||||
// *whole* list decorated with Sonarr's lifecycle for every show on it
|
||||
@@ -2766,7 +2874,14 @@ private fun HomeScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
onTogglePlayed = homeViewModel::setPlayed,
|
||||
onTogglePlayed = { item, played ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "library", action = if (played) "mark_played" else "mark_unplayed",
|
||||
screen = "details", feature = "played_status", itemId = item.id, itemType = item.type,
|
||||
outcome = "success",
|
||||
)
|
||||
homeViewModel.setPlayed(item, played)
|
||||
},
|
||||
onClose = {
|
||||
detailsItem = null
|
||||
detailsTrail = emptyList()
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.PlayableSubtitle
|
||||
import com.ponzischeme89.memby.data.PlaybackSession
|
||||
import `is`.xyz.mpv.BaseMPVView
|
||||
import `is`.xyz.mpv.MPV
|
||||
import `is`.xyz.mpv.MPVNode
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* A deliberately small libmpv safety net for a stream Media3 could not decode or demux.
|
||||
* Media3 remains Memby's normal Android TV player; this activity owns only the final
|
||||
* compatibility hand-off and the Emby check-ins needed to keep resume state accurate.
|
||||
*/
|
||||
class MpvFallbackActivity : ComponentActivity(), MPV.EventObserver {
|
||||
private lateinit var mpv: MPV
|
||||
private lateinit var mpvView: BaseMPVView
|
||||
private lateinit var controls: TextView
|
||||
private var progressJob: Job? = null
|
||||
private var positionMs = 0L
|
||||
private var durationMs = 0L
|
||||
private var loaded = false
|
||||
private var stopped = false
|
||||
private var controlsVisible = true
|
||||
|
||||
private val itemId by lazy { intent.getStringExtra(EXTRA_ITEM_ID).orEmpty() }
|
||||
private val session by lazy {
|
||||
PlaybackSession(
|
||||
itemId = itemId,
|
||||
mediaSourceId = intent.getStringExtra(EXTRA_MEDIA_SOURCE_ID).orEmpty().ifBlank { itemId },
|
||||
playSessionId = intent.getStringExtra(EXTRA_PLAY_SESSION_ID).orEmpty(),
|
||||
playMethod = intent.getStringExtra(EXTRA_PLAY_METHOD).orEmpty().ifBlank { "DirectPlay" },
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val url = intent.getStringExtra(EXTRA_URL).orEmpty()
|
||||
if (itemId.isBlank() || url.isBlank()) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility =
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
|
||||
val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
|
||||
mpvView = object : BaseMPVView(this@MpvFallbackActivity, null) {
|
||||
override fun initOptions() {
|
||||
// auto-safe uses hardware where mpv trusts it and falls back to software
|
||||
// when the same vendor decoder that failed Media3 is not viable.
|
||||
mpv.setOptionString("hwdec", "auto-safe")
|
||||
mpv.setOptionString("video-sync", "audio")
|
||||
}
|
||||
|
||||
override fun postInitOptions() {
|
||||
mpv.setPropertyBoolean("pause", true)
|
||||
}
|
||||
|
||||
override fun observeProperties() {
|
||||
mpv.observeProperty("time-pos", MPV.mpvFormat.MPV_FORMAT_DOUBLE)
|
||||
mpv.observeProperty("duration", MPV.mpvFormat.MPV_FORMAT_DOUBLE)
|
||||
mpv.observeProperty("pause", MPV.mpvFormat.MPV_FORMAT_FLAG)
|
||||
}
|
||||
}
|
||||
root.addView(
|
||||
mpvView,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
),
|
||||
)
|
||||
controls = TextView(this).apply {
|
||||
setTextColor(Color.WHITE)
|
||||
setBackgroundColor(Color.argb(185, 8, 10, 12))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(dp(24), dp(14), dp(24), dp(14))
|
||||
text = controlText(paused = true)
|
||||
}
|
||||
root.addView(
|
||||
controls,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
|
||||
).apply { bottomMargin = dp(32) },
|
||||
)
|
||||
setContentView(root)
|
||||
|
||||
runCatching {
|
||||
mpvView.initialize(
|
||||
filesDir.resolve("mpv").path,
|
||||
cacheDir.resolve("mpv").path,
|
||||
)
|
||||
mpv = mpvView.mpv
|
||||
mpv.addObserver(this)
|
||||
mpvView.setVo("gpu")
|
||||
mpvView.playFile(url)
|
||||
}.onFailure { error ->
|
||||
Log.e(TAG, "event=libmpv_init_failed item=$itemId", error)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean = when (keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER,
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
|
||||
-> {
|
||||
togglePause()
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND,
|
||||
-> {
|
||||
seekBy(-SEEK_SECONDS)
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD,
|
||||
-> {
|
||||
seekBy(SEEK_SECONDS)
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_MENU -> {
|
||||
controlsVisible = !controlsVisible
|
||||
controls.visibility = if (controlsVisible) View.VISIBLE else View.GONE
|
||||
true
|
||||
}
|
||||
else -> super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
private fun togglePause() {
|
||||
val paused = mpv.getPropertyBoolean("pause") != false
|
||||
mpv.setPropertyBoolean("pause", !paused)
|
||||
controls.visibility = View.VISIBLE
|
||||
controlsVisible = true
|
||||
controls.text = controlText(paused = !paused)
|
||||
reportProgress(if (!paused) "Pause" else "Unpause", isPaused = !paused)
|
||||
}
|
||||
|
||||
private fun seekBy(seconds: Int) {
|
||||
mpv.command("seek", seconds.toString(), "relative+exact")
|
||||
controls.visibility = View.VISIBLE
|
||||
controlsVisible = true
|
||||
controls.text = controlText(mpv.getPropertyBoolean("pause") != false)
|
||||
reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false)
|
||||
}
|
||||
|
||||
private fun controlText(paused: Boolean): String {
|
||||
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty().ifBlank { "Compatibility playback" }
|
||||
val state = if (paused) "Paused" else "Playing with libmpv"
|
||||
return "$title\n$state · Left/Right seek · OK pause"
|
||||
}
|
||||
|
||||
private fun onFileLoaded() {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
positionMs = intent.getLongExtra(EXTRA_POSITION_MS, 0L).coerceAtLeast(0L)
|
||||
if (positionMs > 0L) mpv.setPropertyDouble("time-pos", positionMs / 1_000.0)
|
||||
attachSelectedSubtitle()
|
||||
mpv.setPropertyBoolean("pause", false)
|
||||
controls.text = controlText(paused = false)
|
||||
lifecycleScope.launch {
|
||||
runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) }
|
||||
}
|
||||
progressJob = lifecycleScope.launch {
|
||||
while (isActive) {
|
||||
delay(PROGRESS_INTERVAL_MS)
|
||||
reportProgress("TimeUpdate", mpv.getPropertyBoolean("pause") != false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachSelectedSubtitle() {
|
||||
val selectedId = intent.getStringExtra(EXTRA_SELECTED_SUBTITLE_ID).orEmpty()
|
||||
if (selectedId.isBlank()) return
|
||||
val subtitle = decodeSubtitles(intent.getStringExtra(EXTRA_SUBTITLES))
|
||||
.firstOrNull { it.id == selectedId && it.deliveryMethod.equals("External", true) }
|
||||
?: return
|
||||
val title = subtitle.label?.takeIf(String::isNotBlank) ?: subtitle.language.orEmpty()
|
||||
mpv.command("sub-add", subtitle.url, "select", title, subtitle.language.orEmpty())
|
||||
}
|
||||
|
||||
private fun reportProgress(eventName: String, isPaused: Boolean) {
|
||||
if (!loaded) return
|
||||
lifecycleScope.launch {
|
||||
runCatching {
|
||||
ServiceLocator.repository.reportPlaybackProgress(
|
||||
session = session,
|
||||
positionMs = positionMs,
|
||||
isPaused = isPaused,
|
||||
eventName = eventName,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
if (loaded && !stopped) {
|
||||
stopped = true
|
||||
reportProgress("Pause", isPaused = true)
|
||||
PlaybackStopWorker.enqueue(this, session, positionMs)
|
||||
}
|
||||
if (::mpv.isInitialized) mpv.setPropertyBoolean("pause", true)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (loaded && stopped) {
|
||||
stopped = false
|
||||
mpv.setPropertyBoolean("pause", false)
|
||||
lifecycleScope.launch {
|
||||
runCatching { ServiceLocator.repository.reportPlaybackStarted(session, positionMs) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
progressJob?.cancel()
|
||||
if (::mpv.isInitialized) {
|
||||
runCatching { mpv.removeObserver(this) }
|
||||
runCatching { mpvView.destroy() }
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun eventProperty(property: String) = Unit
|
||||
override fun eventProperty(property: String, value: Long) = Unit
|
||||
override fun eventProperty(property: String, value: Boolean) {
|
||||
if (property == "pause") runOnUiThread { controls.text = controlText(value) }
|
||||
}
|
||||
override fun eventProperty(property: String, value: String) = Unit
|
||||
override fun eventProperty(property: String, value: Double) {
|
||||
when (property) {
|
||||
"time-pos" -> positionMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L)
|
||||
"duration" -> durationMs = (value * 1_000.0).roundToLong().coerceAtLeast(0L)
|
||||
}
|
||||
}
|
||||
override fun eventProperty(property: String, value: MPVNode) = Unit
|
||||
override fun event(eventId: Int, data: MPVNode) {
|
||||
when (eventId) {
|
||||
MPV.mpvEvent.MPV_EVENT_FILE_LOADED -> runOnUiThread(::onFileLoaded)
|
||||
MPV.mpvEvent.MPV_EVENT_END_FILE -> if (loaded) runOnUiThread(::finish)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp(value: Int): Int =
|
||||
TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
value.toFloat(),
|
||||
resources.displayMetrics,
|
||||
).roundToLong().toInt()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MembyPlayback"
|
||||
private const val PROGRESS_INTERVAL_MS = 10_000L
|
||||
private const val SEEK_SECONDS = 10
|
||||
private const val EXTRA_ITEM_ID = "mpv_item_id"
|
||||
private const val EXTRA_TITLE = "mpv_title"
|
||||
private const val EXTRA_URL = "mpv_url"
|
||||
private const val EXTRA_POSITION_MS = "mpv_position_ms"
|
||||
private const val EXTRA_SUBTITLES = "mpv_subtitles"
|
||||
private const val EXTRA_SELECTED_SUBTITLE_ID = "mpv_selected_subtitle_id"
|
||||
private const val EXTRA_MEDIA_SOURCE_ID = "mpv_media_source_id"
|
||||
private const val EXTRA_PLAY_SESSION_ID = "mpv_play_session_id"
|
||||
private const val EXTRA_PLAY_METHOD = "mpv_play_method"
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun intent(
|
||||
context: Context,
|
||||
itemId: String,
|
||||
title: String,
|
||||
url: String,
|
||||
positionMs: Long,
|
||||
subtitles: List<PlayableSubtitle>,
|
||||
selectedSubtitleId: String,
|
||||
mediaSourceId: String,
|
||||
playSessionId: String,
|
||||
playMethod: String,
|
||||
): Intent = Intent(context, MpvFallbackActivity::class.java).apply {
|
||||
putExtra(EXTRA_ITEM_ID, itemId)
|
||||
putExtra(EXTRA_TITLE, title)
|
||||
putExtra(EXTRA_URL, url)
|
||||
putExtra(EXTRA_POSITION_MS, positionMs.coerceAtLeast(0L))
|
||||
if (subtitles.isNotEmpty()) putExtra(EXTRA_SUBTITLES, json.encodeToString(subtitles))
|
||||
putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId)
|
||||
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
|
||||
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
|
||||
putExtra(EXTRA_PLAY_METHOD, playMethod)
|
||||
}
|
||||
|
||||
private fun decodeSubtitles(encoded: String?): List<PlayableSubtitle> =
|
||||
encoded?.let {
|
||||
runCatching { json.decodeFromString<List<PlayableSubtitle>>(it) }.getOrNull()
|
||||
}.orEmpty()
|
||||
}
|
||||
}
|
||||
@@ -88,14 +88,6 @@ internal fun automaticRetryDelayMs(attempt: Int): Long? =
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* libmpv is the codec/container safety net, not a second network retry. Media3 first gets
|
||||
* both automatic recovery attempts, including its lower-risk H.264 stream; only a local
|
||||
* format failure that survives those attempts is handed over.
|
||||
*/
|
||||
internal fun shouldUseLibmpvFallback(failure: PlaybackFailure, completedAttempts: Int): Boolean =
|
||||
failure.requiresTranscode && automaticRetryDelayMs(completedAttempts + 1) == null
|
||||
|
||||
/** One prolonged mid-programme rebuffer gets a lower-risk H.264 stream. */
|
||||
internal fun shouldRecoverProlongedRebuffer(
|
||||
renderedFirstFrame: Boolean,
|
||||
|
||||
@@ -143,7 +143,6 @@ class PlayerActivity : ComponentActivity() {
|
||||
private var prolongedRebufferRecoveryAttempted = false
|
||||
private var automaticRetryAttempt = 0
|
||||
private var renderedFirstFrame = false
|
||||
private var handingOffToLibmpv = false
|
||||
private var requestStartedAtMs = 0L
|
||||
private var trace = PlaybackTrace(SystemClock.elapsedRealtime(), SystemClock::elapsedRealtime)
|
||||
|
||||
@@ -1238,41 +1237,9 @@ class PlayerActivity : ComponentActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldUseLibmpvFallback(failure, automaticRetryAttempt) && handOffToLibmpv()) return
|
||||
showPlaybackError(failure)
|
||||
}
|
||||
|
||||
/** Continue the same Emby session in libmpv without a second pre-roll or stop report. */
|
||||
private fun handOffToLibmpv(): Boolean {
|
||||
val playback = player ?: return false
|
||||
val id = itemId?.takeIf(String::isNotBlank) ?: return false
|
||||
val url = playback.currentMediaItem?.localConfiguration?.uri?.toString()
|
||||
?.takeIf(String::isNotBlank) ?: return false
|
||||
val positionMs = playback.currentPosition.coerceAtLeast(0L)
|
||||
handingOffToLibmpv = true
|
||||
Log.w(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"event=libmpv_fallback item=$id positionMs=$positionMs playMethod=$playMethod",
|
||||
)
|
||||
startActivity(
|
||||
MpvFallbackActivity.intent(
|
||||
context = this,
|
||||
itemId = id,
|
||||
title = playbackTitle,
|
||||
url = url,
|
||||
positionMs = positionMs,
|
||||
subtitles = availableSubtitles,
|
||||
selectedSubtitleId = serverSubtitleId,
|
||||
mediaSourceId = mediaSourceId,
|
||||
playSessionId = playSessionId,
|
||||
playMethod = playMethod,
|
||||
),
|
||||
)
|
||||
playback.pause()
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun retryPlayback(refreshSource: Boolean, forceTranscode: Boolean = false) {
|
||||
retryJob?.cancel()
|
||||
prolongedRebufferJob?.cancel()
|
||||
@@ -3774,7 +3741,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
// this title resumes from — is the one the viewer had already skipped past.
|
||||
commitSeek()
|
||||
player?.let {
|
||||
if (!handingOffToLibmpv && playbackStarted && !stopReported) {
|
||||
if (playbackStarted && !stopReported) {
|
||||
reportProgress(it.currentPosition, isPaused = true, eventName = "Pause")
|
||||
stopReported = true
|
||||
stoppedInBackground = true
|
||||
@@ -3824,7 +3791,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
"bufferingMs=$totalBufferingMs positionMs=${player?.currentPosition ?: 0L}",
|
||||
)
|
||||
val playback = player
|
||||
if (!handingOffToLibmpv && !stopReported && playbackStarted && !itemId.isNullOrBlank()) {
|
||||
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
|
||||
stopReported = true
|
||||
PlaybackStopWorker.enqueue(
|
||||
this,
|
||||
|
||||
@@ -164,6 +164,7 @@ fun SearchScreen(
|
||||
returnFocusRequester: FocusRequester,
|
||||
initialQuery: String? = null,
|
||||
onInitialQueryConsumed: () -> Unit = {},
|
||||
onSearchStarted: () -> Unit = {},
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onContentFocused: () -> Unit,
|
||||
@@ -194,6 +195,15 @@ fun SearchScreen(
|
||||
var lastKeyIndex by remember { mutableIntStateOf(0) }
|
||||
var focusInResults by remember { mutableStateOf(false) }
|
||||
var restoreGenreChipFocus by remember { mutableStateOf(false) }
|
||||
var searchTracked by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(state.query) {
|
||||
if (state.query.isNotBlank() && !searchTracked) {
|
||||
searchTracked = true
|
||||
onSearchStarted()
|
||||
} else if (state.query.isBlank()) {
|
||||
searchTracked = false
|
||||
}
|
||||
}
|
||||
val hasResultsTarget = when {
|
||||
state.errorMessage != null && state.results.isEmpty() -> true
|
||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||
|
||||
@@ -319,6 +319,7 @@ fun SettingsSheet(
|
||||
modifier: Modifier = Modifier,
|
||||
overlay: Boolean = false,
|
||||
navigationFocusRequester: FocusRequester? = null,
|
||||
onAnalyticsEvent: (feature: String, action: String) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val store = ServiceLocator.settings
|
||||
@@ -496,30 +497,37 @@ fun SettingsSheet(
|
||||
val actions = SettingsPanelActions(
|
||||
onClose = onClose,
|
||||
onShowLogoChanged = {
|
||||
onAnalyticsEvent("title_logo", "toggle")
|
||||
showLogo = it
|
||||
scope.launch { store.setShowTitleLogo(it) }
|
||||
},
|
||||
onAutoPlayNextChanged = {
|
||||
onAnalyticsEvent("auto_play_next", "toggle")
|
||||
autoPlayNext = it
|
||||
scope.launch { store.setAutoPlayNextEpisode(it) }
|
||||
},
|
||||
onShowTenMinuteReminderChanged = {
|
||||
onAnalyticsEvent("playback_reminder", "toggle")
|
||||
showTenMinuteReminder = it
|
||||
scope.launch { store.setShowTenMinuteReminder(it) }
|
||||
},
|
||||
onSeekIntervalChanged = {
|
||||
onAnalyticsEvent("seek_interval", "change")
|
||||
seekInterval = it
|
||||
scope.launch { store.setSeekIntervalSeconds(it) }
|
||||
},
|
||||
onSkipIntroModeChanged = {
|
||||
onAnalyticsEvent("skip_intro", "change")
|
||||
skipIntroMode = it
|
||||
scope.launch { store.setSkipIntroMode(it) }
|
||||
},
|
||||
onSpeedUpCreditsChanged = {
|
||||
onAnalyticsEvent("speed_up_credits", "toggle")
|
||||
speedUpCredits = it
|
||||
scope.launch { store.setSpeedUpCredits(it) }
|
||||
},
|
||||
onAudioPassthroughModeChanged = { mode ->
|
||||
onAnalyticsEvent("audio_passthrough", "change")
|
||||
audioPassthroughMode = mode
|
||||
// Moonfin seeds a first manual visit from the live probe. That makes Manual
|
||||
// start as an editable copy of Auto instead of unexpectedly switching every
|
||||
@@ -530,6 +538,7 @@ fun SettingsSheet(
|
||||
scope.launch { store.setAudioPassthrough(mode, audioPassthroughCodecs) }
|
||||
},
|
||||
onAudioPassthroughCodecChanged = { codec, enabled ->
|
||||
onAnalyticsEvent("audio_passthrough", "toggle")
|
||||
audioPassthroughCodecs = if (enabled) {
|
||||
audioPassthroughCodecs + codec
|
||||
} else {
|
||||
@@ -540,18 +549,22 @@ fun SettingsSheet(
|
||||
}
|
||||
},
|
||||
onRingColorChanged = {
|
||||
onAnalyticsEvent("focus_colour", "change")
|
||||
ringColor = it
|
||||
scope.launch { store.setRingColor(it) }
|
||||
},
|
||||
onHomeSectionChanged = { key, enabled ->
|
||||
onAnalyticsEvent("home_sections", "toggle")
|
||||
homeSections = if (enabled) homeSections + key else homeSections - key
|
||||
scope.launch { store.setHomeSections(homeSections.toList()) }
|
||||
},
|
||||
onCardDensityChanged = {
|
||||
onAnalyticsEvent("card_density", "change")
|
||||
cardDensity = it
|
||||
scope.launch { store.setHomeCardDensity(it) }
|
||||
},
|
||||
onArtworkStyleChanged = {
|
||||
onAnalyticsEvent("artwork_style", "change")
|
||||
artworkStyle = it
|
||||
scope.launch {
|
||||
// Back closes this composable and cancels its scope. Once a selection has
|
||||
@@ -561,6 +574,7 @@ fun SettingsSheet(
|
||||
}
|
||||
},
|
||||
onRestoreHiddenRows = {
|
||||
onAnalyticsEvent("hidden_rows", "change")
|
||||
scope.launch {
|
||||
store.setHomeRowPreferences(
|
||||
settings.homeRowOrder.lineSequence().filter { it.isNotBlank() }.toList(),
|
||||
@@ -570,22 +584,27 @@ fun SettingsSheet(
|
||||
}
|
||||
},
|
||||
onShowCardMetadataChanged = {
|
||||
onAnalyticsEvent("card_metadata", "toggle")
|
||||
showCardMetadata = it
|
||||
scope.launch { store.setShowHomeCardMetadata(it) }
|
||||
},
|
||||
onShowRatingsStripChanged = {
|
||||
onAnalyticsEvent("ratings_strip", "toggle")
|
||||
showRatingsStrip = it
|
||||
scope.launch { store.setShowRatingsStrip(it) }
|
||||
},
|
||||
onHideWatchedMoviesChanged = {
|
||||
onAnalyticsEvent("hide_watched_movies", "toggle")
|
||||
hideWatchedMovies = it
|
||||
scope.launch { store.setHideWatchedMovies(it) }
|
||||
},
|
||||
onConfirmExitMembyChanged = {
|
||||
onAnalyticsEvent("confirm_exit", "toggle")
|
||||
confirmExitMemby = it
|
||||
scope.launch { store.setConfirmExitMemby(it) }
|
||||
},
|
||||
onThemeChanged = { chosen ->
|
||||
onAnalyticsEvent("theme", "change")
|
||||
// No local echo: what is on screen is the palette the gateway resolves, and it
|
||||
// arrives through ThemeSync a moment later. Painting optimistically here would
|
||||
// show a viewer a scheme that a season, or an allowlist they do not know about,
|
||||
@@ -593,10 +612,14 @@ fun SettingsSheet(
|
||||
scope.launch { store.setThemeId(chosen) }
|
||||
},
|
||||
onWelcomeQuoteStyleChanged = {
|
||||
onAnalyticsEvent("welcome_quote", "change")
|
||||
welcomeQuoteStyle = it
|
||||
scope.launch { store.setWelcomeQuoteStyle(it) }
|
||||
},
|
||||
onPageSelected = { selectedPage = it },
|
||||
onPageSelected = {
|
||||
onAnalyticsEvent("settings_page_${it.name.lowercase()}", "open")
|
||||
selectedPage = it
|
||||
},
|
||||
onRefreshDevices = {
|
||||
deviceJob?.cancel()
|
||||
deviceJob = scope.launch { refreshDevices() }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ponzischeme89.memby.data.analytics
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class JourneyAnalyticsTest {
|
||||
@Test
|
||||
fun `events stay ordered and contain no arbitrary characters`() {
|
||||
val analytics = JourneyAnalytics(userId = "user-1", now = { 1_700_000_000_000L }, journeyId = "journey-1")
|
||||
analytics.track(
|
||||
category = "content", action = "open", screen = "home screen",
|
||||
feature = "latest_movies", itemId = "item/unsafe", itemType = "Movie",
|
||||
)
|
||||
val events = analytics.drain()
|
||||
|
||||
assertEquals(listOf(0, 1), events.map { it.sequence })
|
||||
assertEquals("user-1", events.last().userId)
|
||||
assertEquals("homescreen", events.last().screen)
|
||||
assertEquals("itemunsafe", events.last().itemId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ending records one terminal event and rejects later actions`() {
|
||||
val analytics = JourneyAnalytics(userId = "user-1", journeyId = "journey-1")
|
||||
analytics.drain()
|
||||
analytics.end("details")
|
||||
analytics.end("player")
|
||||
analytics.track("navigation", "open", feature = "search")
|
||||
val events = analytics.drain()
|
||||
|
||||
assertEquals(1, events.size)
|
||||
assertEquals("journey_end", events.single().action)
|
||||
assertTrue(events.single().screen == "details")
|
||||
assertFalse(events.any { it.feature == "search" })
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,6 @@ import org.junit.Test
|
||||
|
||||
class PlaybackRecoveryTest {
|
||||
|
||||
@Test
|
||||
fun `libmpv takes codec failures only after Media3 recovery is exhausted`() {
|
||||
val codecFailure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
)
|
||||
|
||||
assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 0))
|
||||
assertFalse(shouldUseLibmpvFallback(codecFailure, completedAttempts = 1))
|
||||
assertTrue(shouldUseLibmpvFallback(codecFailure, completedAttempts = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `libmpv does not replace Media3 network recovery`() {
|
||||
val networkFailure = describePlaybackFailure(
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
)
|
||||
|
||||
assertFalse(shouldUseLibmpvFallback(networkFailure, completedAttempts = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkFailuresAreSafeToRetry() {
|
||||
val failure = describePlaybackFailure(
|
||||
|
||||
Reference in New Issue
Block a user