0.2.45 - Advanced analytics, logout old versions

This commit is contained in:
ponzischeme89
2026-08-10 20:24:22 +12:00
parent 63f0768507
commit 56c1167382
32 changed files with 1125 additions and 452 deletions
-10
View File
@@ -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(