Server changes/Sonarr

This commit is contained in:
ponzischeme89
2026-07-27 21:06:51 +12:00
parent 62f6345a40
commit 8d6cf2f5a1
42 changed files with 2388 additions and 284 deletions
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.SettingsStore
/**
@@ -14,10 +15,13 @@ object ServiceLocator {
private set
lateinit var repository: EmbyRepository
private set
lateinit var maintenance: MaintenanceMonitor
private set
fun init(context: Context) {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository)
}
}
@@ -3,10 +3,13 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayAuthError
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayRowEvent
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
@@ -15,12 +18,17 @@ import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import retrofit2.HttpException
@@ -47,6 +55,7 @@ data class Playable(
val title: String,
val url: String,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
)
class EmbyRepository(private val settings: SettingsStore) {
@@ -54,15 +63,20 @@ class EmbyRepository(private val settings: SettingsStore) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var snapshot: Settings = Settings.EMPTY
private var observedSettings: Settings = Settings.EMPTY
private val snapshot: Settings
get() = settings.current ?: observedSettings
init {
scope.launch { settings.settingsFlow.collect { snapshot = it } }
scope.launch { settings.settingsFlow.collect { observedSettings = it } }
}
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
val playbackStops = _playbackStops.asSharedFlow()
private val playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -119,20 +133,29 @@ class EmbyRepository(private val settings: SettingsStore) {
* Signs in. [serverUrl] is only consulted when the build does not hardwire one —
* with a hardwired address the setup screen never collects it.
*/
suspend fun authenticate(serverUrl: String, username: String, password: String) {
suspend fun authenticate(serverUrl: String, username: String, password: String, deviceName: String) {
clearPlayableCache()
settings.ensureDeviceId()
snapshot = settings.snapshot() // pick up the freshly-generated device id
observedSettings = settings.snapshot() // pick up the freshly-generated device id
ServerConfig.gatewayUrl?.let { gateway ->
// The gateway holds the Emby token; this device only ever stores the gateway
// token, so the same `token` slot in DataStore serves both modes.
val result = requireGateway().login(
GatewayLoginRequest(
username = username,
password = password,
deviceId = snapshot.deviceId.ifEmpty { "memby" },
),
)
val result = try {
requireGateway().login(
GatewayLoginRequest(
username = username,
password = password,
deviceId = snapshot.deviceId.ifEmpty { "memby" },
deviceName = deviceName.trim(),
),
)
} catch (error: HttpException) {
if (error.code() == 409) {
parseDeviceLimit(error.response()?.errorBody()?.string().orEmpty())?.let { throw it }
}
throw error
}
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
"Gateway did not return a session"
}
@@ -143,7 +166,8 @@ class EmbyRepository(private val settings: SettingsStore) {
result.username.ifBlank { username },
result.serverId.takeIf { it.isNotBlank() },
)
snapshot = settings.snapshot()
settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
return
}
@@ -157,9 +181,13 @@ class EmbyRepository(private val settings: SettingsStore) {
"Server did not return an access token"
}
settings.saveSession(base, token, userId, username, result.serverId)
snapshot = settings.snapshot()
settings.setDeviceName(deviceName)
observedSettings = settings.snapshot()
}
suspend fun authPolicy(): GatewayAuthPolicy? =
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
suspend fun signOut() {
// Retire the gateway token server-side too, so a lost TV can't keep reading the
// library. A failure here must not block the local sign-out.
@@ -167,16 +195,18 @@ class EmbyRepository(private val settings: SettingsStore) {
runCatching { requireGateway().logout() }
}
settings.clearSession()
snapshot = settings.snapshot()
observedSettings = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
}
suspend fun switchProfile(profile: EmbyProfile) {
settings.switchProfile(profile)
snapshot = settings.snapshot()
observedSettings = settings.snapshot()
cachedApi = null
cachedBaseUrl = null
clearPlayableCache()
}
// --- Content -------------------------------------------------------------
@@ -343,6 +373,9 @@ class EmbyRepository(private val settings: SettingsStore) {
?.takeIf { it.isActionable }
}
/** Live gateway state. Unlike content routes, this remains available in maintenance. */
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
/** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId)
@@ -445,14 +478,67 @@ class EmbyRepository(private val settings: SettingsStore) {
* for a series we play the next-up episode (falling back to the first one).
*/
suspend fun resolvePlayable(item: BaseItem): Playable {
require(item.membyPlayable) { "This item is informational and cannot be played" }
val now = System.currentTimeMillis()
val (cached, request) = playableMutex.withLock {
val ready = playableCache[item.id]?.takeIf { it.expiresAtMs > now }?.playable
if (ready != null) {
ready to null
} else {
playableCache.remove(item.id)
null to (playableInFlight[item.id] ?: newPlayableRequest(item))
}
}
return cached ?: requireNotNull(request).await()
}
/** Resolves the likely stream after focus settles, without opening or buffering it. */
suspend fun prefetchPlayable(item: BaseItem) {
if (!item.membyPlayable) return
resolvePlayable(item)
}
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
resolvePlayableUncached(item).also { playable ->
playableMutex.withLock {
playableCache[item.id] = CachedPlayable(
playable = playable,
expiresAtMs = System.currentTimeMillis() + PLAYABLE_CACHE_TTL_MS,
)
while (playableCache.size > PLAYABLE_CACHE_SIZE) {
playableCache.entries.iterator().run {
next()
remove()
}
}
}
}
} finally {
playableMutex.withLock { playableInFlight.remove(item.id) }
}
}
playableInFlight[item.id] = request
request.start()
return request
}
private suspend fun resolvePlayableUncached(item: BaseItem): Playable {
if (ServerConfig.isGateway) {
// Episode selection for a series is the gateway's job now.
val playback = requireGateway().playback(item.id)
val playback = requireGateway().playback(
itemId = item.id,
itemType = item.type,
title = item.name,
resumePositionMs = item.resumePositionMs,
)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { item.name },
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = logoUrl(item),
)
}
if (item.isSeries) {
@@ -463,9 +549,29 @@ class EmbyRepository(private val settings: SettingsStore) {
append(item.name)
episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") }
}
return Playable(episode.id, title, buildStreamUrl(episode.id), episode.resumePositionMs)
return Playable(
episode.id,
title,
buildStreamUrl(episode.id),
episode.resumePositionMs,
logoUrl(item),
)
}
return Playable(
item.id,
item.name,
buildStreamUrl(item.id),
item.resumePositionMs,
logoUrl(item),
)
}
private suspend fun clearPlayableCache() {
playableMutex.withLock {
playableCache.clear()
playableInFlight.values.forEach { it.cancel() }
playableInFlight.clear()
}
return Playable(item.id, item.name, buildStreamUrl(item.id), item.resumePositionMs)
}
suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) {
@@ -494,6 +600,7 @@ class EmbyRepository(private val settings: SettingsStore) {
requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true))
}
} finally {
clearPlayableCache()
_playbackStops.tryEmit(itemId)
}
}
@@ -628,12 +735,41 @@ class EmbyRepository(private val settings: SettingsStore) {
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
}
private data class CachedPlayable(
val playable: Playable,
val expiresAtMs: Long,
)
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal fun millisecondsToTicks(milliseconds: Long): Long =
milliseconds.coerceAtLeast(0L) * 10_000L
private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
class DeviceLimitException(
val activeClients: Int,
val maxClients: Int,
) : Exception("Memby device allowance reached")
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
if (body.isBlank()) return null
return runCatching {
val parsed = Json { ignoreUnknownKeys = true }
.decodeFromString<GatewayAuthError>(body)
parsed.takeIf {
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
}?.let {
DeviceLimitException(
activeClients = it.activeClients.coerceAtLeast(0),
maxClients = it.maxClientsPerUser,
)
}
}.getOrNull()
}
/**
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
* bodies or stack traces (which could contain tokens) to the screen.
@@ -10,8 +10,15 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@@ -28,6 +35,7 @@ data class Settings(
val serverId: String? = null,
val username: String? = null,
val deviceId: String = "",
val deviceName: String = "",
val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS,
// Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access
// token for the (private) repo's release API and asset downloads.
@@ -75,6 +83,12 @@ data class EmbyProfile(
)
class SettingsStore(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile
private var latestSettings: Settings? = null
val current: Settings?
get() = latestSettings
private object Keys {
val SERVER_URL = stringPreferencesKey("server_url")
@@ -83,6 +97,7 @@ class SettingsStore(private val context: Context) {
val SERVER_ID = stringPreferencesKey("server_id")
val USERNAME = stringPreferencesKey("username")
val DEVICE_ID = stringPreferencesKey("device_id")
val DEVICE_NAME = stringPreferencesKey("device_name")
val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds")
val UPDATE_BASE_URL = stringPreferencesKey("update_base_url")
val UPDATE_REPO = stringPreferencesKey("update_repo")
@@ -97,39 +112,26 @@ class SettingsStore(private val context: Context) {
val PROFILES = stringPreferencesKey("profiles")
}
val settingsFlow: Flow<Settings> = context.dataStore.data.map { p ->
val storedProfiles = decodeProfiles(p[Keys.PROFILES])
val profiles = if (storedProfiles.isEmpty()) {
legacyProfile(p)?.let(::listOf).orEmpty()
} else {
storedProfiles
}
Settings(
serverUrl = p[Keys.SERVER_URL],
token = p[Keys.TOKEN],
userId = p[Keys.USER_ID],
serverId = p[Keys.SERVER_ID],
username = p[Keys.USERNAME],
deviceId = p[Keys.DEVICE_ID].orEmpty(),
rotationIntervalSeconds = p[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
updateBaseUrl = p[Keys.UPDATE_BASE_URL],
updateRepo = p[Keys.UPDATE_REPO],
updateToken = p[Keys.UPDATE_TOKEN],
showTitleLogo = p[Keys.SHOW_TITLE_LOGO] ?: true,
ringColorHex = p[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = p[Keys.LAST_BACKDROP_URL],
homeSections = p[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = p[Keys.HOME_CACHE],
homeCardDensity = p[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
showHomeCardMetadata = p[Keys.SHOW_HOME_CARD_METADATA] ?: true,
profiles = profiles,
)
}
/**
* Start the single disk read as soon as the Application creates this store, then
* replay the decoded value to activities, repositories and services. Previously
* every collector repeated the cold mapping work and the home screen could be
* created before the repository had seen the saved cache.
*/
val settingsFlow: Flow<Settings> = context.dataStore.data
.map(::settingsFrom)
.distinctUntilChanged()
.onEach { latestSettings = it }
.shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
suspend fun setRotationIntervalSeconds(seconds: Int) {
context.dataStore.edit { it[Keys.ROTATION_SECONDS] = seconds }
}
suspend fun setDeviceName(name: String) {
context.dataStore.edit { it[Keys.DEVICE_NAME] = name.trim().take(80) }
}
/** Persists the Gitea update source. Blank values are cleared. */
suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) {
context.dataStore.edit {
@@ -196,8 +198,12 @@ class SettingsStore(private val context: Context) {
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
}
/** Reads a one-shot snapshot of the current settings. */
suspend fun snapshot(): Settings = settingsFlow.first()
/**
* Reads DataStore directly instead of taking the replayed flow value. This matters
* immediately after an edit, when the replay slot may still contain the prior value.
*/
suspend fun snapshot(): Settings = settingsFrom(context.dataStore.data.first())
.also { latestSettings = it }
/** Returns the stable device id, generating and persisting one on first use. */
suspend fun ensureDeviceId(): String {
@@ -284,6 +290,36 @@ class SettingsStore(private val context: Context) {
private fun decodeProfiles(value: String?): List<EmbyProfile> =
value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty()
private fun settingsFrom(preferences: Preferences): Settings {
val storedProfiles = decodeProfiles(preferences[Keys.PROFILES])
val profiles = if (storedProfiles.isEmpty()) {
legacyProfile(preferences)?.let(::listOf).orEmpty()
} else {
storedProfiles
}
return Settings(
serverUrl = preferences[Keys.SERVER_URL],
token = preferences[Keys.TOKEN],
userId = preferences[Keys.USER_ID],
serverId = preferences[Keys.SERVER_ID],
username = preferences[Keys.USERNAME],
deviceId = preferences[Keys.DEVICE_ID].orEmpty(),
deviceName = preferences[Keys.DEVICE_NAME].orEmpty(),
rotationIntervalSeconds = preferences[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS,
updateBaseUrl = preferences[Keys.UPDATE_BASE_URL],
updateRepo = preferences[Keys.UPDATE_REPO],
updateToken = preferences[Keys.UPDATE_TOKEN],
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
homeCacheJson = preferences[Keys.HOME_CACHE],
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
profiles = profiles,
)
}
private fun profilesFrom(preferences: Preferences): List<EmbyProfile> =
decodeProfiles(preferences[Keys.PROFILES]).ifEmpty {
legacyProfile(preferences)?.let(::listOf).orEmpty()
@@ -86,11 +86,22 @@ data class BaseItem(
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
@SerialName("UserData") val userData: UserItemData? = null,
// Server-authored schedule metadata. These fields are absent on normal Emby items.
@SerialName("MembySource") val membySource: String? = null,
@SerialName("MembyEpisodeTitle") val membyEpisodeTitle: String? = null,
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
val isFavorite: Boolean get() = userData?.isFavorite == true
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
/** Runtime in whole minutes, or null when unknown. */
val runtimeMinutes: Int?
@@ -15,6 +15,7 @@ data class GatewayLoginRequest(
val username: String,
val password: String,
val deviceId: String,
val deviceName: String,
)
@Serializable
@@ -23,6 +24,21 @@ data class GatewayLoginResponse(
val userId: String = "",
val username: String = "",
val serverId: String = "",
val activeClients: Int = 0,
val maxClientsPerUser: Int = 0,
)
@Serializable
data class GatewayAuthPolicy(
val maxClientsPerUser: Int = 0,
)
@Serializable
data class GatewayAuthError(
val error: String = "",
val message: String = "",
val activeClients: Int = 0,
val maxClientsPerUser: Int = 0,
)
/**
@@ -83,6 +99,13 @@ data class GatewayUpdate(
}
}
/** Lightweight live state returned even while normal gateway routes are in maintenance. */
@Serializable
data class GatewayServiceStatus(
val maintenance: Boolean = false,
val message: String = "",
)
/** Response of `GET /v1/recommendations`. */
@Serializable
data class GatewayRows(
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayItems
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
@@ -10,6 +11,7 @@ import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body
@@ -30,6 +32,9 @@ interface GatewayApi {
@POST("v1/auth/login")
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
@GET("v1/auth/policy")
suspend fun authPolicy(): GatewayAuthPolicy
@POST("v1/auth/logout")
suspend fun logout()
@@ -53,11 +58,20 @@ interface GatewayApi {
@GET("v1/update")
suspend fun updateStatus(): GatewayUpdate
/** Available during maintenance so an open app can be interrupted immediately. */
@GET("v1/status")
suspend fun serviceStatus(): GatewayServiceStatus
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
@GET("v1/items/{id}/playback")
suspend fun playback(@Path("id") itemId: String): GatewayPlayback
suspend fun playback(
@Path("id") itemId: String,
@Query("type") itemType: String,
@Query("title") title: String,
@Query("resumePositionMs") resumePositionMs: Long,
): GatewayPlayback
@GET("v1/items/{id}/trailer")
suspend fun trailer(@Path("id") itemId: String): BaseItem
@@ -69,16 +69,25 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.Recommend
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VideoLibrary
import androidx.tv.material3.Icon
import androidx.tv.material3.Button
import androidx.tv.material3.Text
@@ -120,6 +129,58 @@ data class HomeBrowseRow(
val showSecondaryMetadata: Boolean = true,
)
private data class HomeRowVisual(
val icon: ImageVector,
val colors: List<Color>,
)
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
listOf(Color(0xFF3A7BD5), Color(0xFF6C4DFF)),
)
row.id == "next-up" -> HomeRowVisual(
Icons.Default.SkipNext,
listOf(Color(0xFF00A896), Color(0xFF28C2A0)),
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
listOf(Color(0xFFFF5C8A), Color(0xFFB84DFF)),
)
row.id == "latest-movies" -> HomeRowVisual(
Icons.Default.Movie,
listOf(Color(0xFFFF8A3D), Color(0xFFE34B6F)),
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
Icons.Default.CalendarMonth,
listOf(Color(0xFF00A9CE), Color(0xFF4169E1)),
)
row.id == "curated:apple-tv" -> HomeRowVisual(
Icons.Default.LiveTv,
listOf(Color(0xFF252A31), Color(0xFF66717E)),
)
row.id == "curated:drama-shows" -> HomeRowVisual(
Icons.Default.TheaterComedy,
listOf(Color(0xFF6A3FB5), Color(0xFFB64272)),
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
Icons.Default.SentimentVerySatisfied,
listOf(Color(0xFFFFB52E), Color(0xFFFF7433)),
)
row.id.startsWith("similar:") -> HomeRowVisual(
Icons.Default.AutoAwesome,
listOf(Color(0xFF8A5CF6), Color(0xFFE458A3)),
)
row.id == "recommended" -> HomeRowVisual(
Icons.Default.Recommend,
listOf(Color(0xFF45B649), Color(0xFF00A896)),
)
else -> HomeRowVisual(
Icons.Default.VideoLibrary,
listOf(Color(0xFF536976), Color(0xFF738A96)),
)
}
@Composable
fun TvNavigationRail(
selected: BrowseDestination,
@@ -160,11 +221,27 @@ fun TvNavigationRail(
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Emby",
contentDescription = "Memby",
modifier = Modifier.width(32.dp).height(27.dp),
)
if (expanded) {
Text("Emby", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold)
Column(verticalArrangement = Arrangement.Center) {
Text(
"Memby",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Text(
"Matts Android TV client",
color = QuietText,
fontSize = 9.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Spacer(Modifier.height(16.dp))
@@ -528,6 +605,10 @@ fun MediaQuickActionsOverlay(
@Composable
private fun MetadataContent(item: BaseItem, sectionLabel: String) {
if (item.isSonarrSchedule) {
ScheduleMetadataContent(item, sectionLabel)
return
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
@@ -574,6 +655,62 @@ private fun MetadataContent(item: BaseItem, sectionLabel: String) {
}
}
@Composable
private fun ScheduleMetadataContent(item: BaseItem, sectionLabel: String) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
) {
Text(
sectionLabel.uppercase(),
color = EmbyGreen,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
)
Text(
item.name,
color = Color.White,
fontSize = 30.sp,
lineHeight = 34.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
listOfNotNull(
item.membyEpisodeCode,
item.membyEpisodeTitle?.takeIf(String::isNotBlank),
).joinToString(""),
color = MutedText,
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item.membyAirLabel?.takeIf(String::isNotBlank)?.let { MediaBadge(it.uppercase()) }
item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let {
MediaBadge(it.uppercase())
}
}
Text(
item.overview?.takeIf(String::isNotBlank)
?: "Episode information will appear when Sonarr receives it.",
color = Color(0xFFD0D4D7),
fontSize = 15.sp,
lineHeight = 20.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.74f),
)
Text(
"Schedule information from Sonarr",
color = QuietText,
fontSize = 12.sp,
)
}
}
@Composable
private fun MetadataStatus(item: BaseItem) {
val position = item.userData?.playbackPositionTicks ?: 0L
@@ -656,6 +793,23 @@ fun MediaRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val visual = homeRowVisual(row)
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(Brush.linearGradient(visual.colors))
.border(1.dp, Color.White.copy(alpha = 0.20f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = visual.icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(15.dp),
)
}
Spacer(Modifier.width(10.dp))
Text(
row.title,
color = Color(0xFFF1F3F4),
@@ -978,6 +1132,12 @@ private fun MediaCard(
}
}
}
if (item.isSonarrSchedule) {
ScheduleStatusBadge(
status = item.membyAvailability.orEmpty(),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
}
Text(
item.name,
@@ -1004,6 +1164,28 @@ private fun MediaCard(
}
}
@Composable
private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
val (label, color) = when (status) {
"available" -> "ADDED" to EmbyGreen
"downloading" -> "DOWNLOADING" to Color(0xFF5DA9FF)
"awaiting" -> "AWAITING" to Color(0xFFFFB454)
"unmonitored" -> "UNMONITORED" to QuietText
else -> "TODAY" to Color(0xFFE1E5E8)
}
Text(
text = label,
color = if (status == "available") Color(0xFF071008) else Color(0xFF090B0D),
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(color)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
@Composable
fun FocusScaleContainer(
onFocused: () -> Unit,
@@ -1127,6 +1309,7 @@ private const val BACKDROP_SETTLE_DELAY_MS = 240L
private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when {
showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}"
item.isSonarrSchedule -> item.membyAirLabel ?: "Airing today"
item.isEpisode -> item.seriesName ?: "Up next"
item.productionYear != null && item.runtimeMinutes != null ->
"${item.productionYear}${formatTvRuntime(requireNotNull(item.runtimeMinutes))}"
@@ -1141,6 +1324,10 @@ private fun cardDescription(item: BaseItem, progress: Float): String = buildStri
if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched")
if (item.userData?.played == true) append(", watched")
if (item.isFavorite) append(", favourite")
if (item.isSonarrSchedule) {
item.membyAirLabel?.let { append(", ").append(it) }
item.membyAvailabilityText?.let { append(", ").append(it) }
}
}
private fun formatTvRuntime(minutes: Int): String {
@@ -103,6 +103,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
init {
refreshAll()
checkForAppUpdate()
viewModelScope.launch {
// A TV can leave Memby open for days. Keep checking at a deliberately slow
// cadence so a newly published release appears without requiring a restart.
while (true) {
delay(UPDATE_CHECK_INTERVAL_MS)
checkForAppUpdate()
}
}
viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() }
}
@@ -224,13 +232,26 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val cached = synchronized(metadataCache) { metadataCache[item.id] }
_focusedItem.value = cached ?: item
metadataJob?.cancel()
if (cached != null) return
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
val details = runCatching { repository.getItemDetails(item.id) }.getOrNull() ?: return@launch
synchronized(metadataCache) { metadataCache[item.id] = details }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = details
coroutineScope {
// Resolution is tiny compared with video buffering and makes the later
// Play click a memory lookup. The repository single-flights requests,
// so focus and click can never duplicate the gateway call.
if (item.membyPlayable) {
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
}
if (cached == null && !item.isSonarrSchedule) {
launch {
val details = runCatching {
repository.getItemDetails(item.id)
}.getOrNull() ?: return@launch
synchronized(metadataCache) { metadataCache[item.id] = details }
if (_focusedItem.value?.id == item.id) {
_focusedItem.value = details
}
}
}
}
}
}
@@ -381,6 +402,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.watchingAndNextUp.firstOrNull()
@@ -1,8 +1,12 @@
package com.ponzischeme89.memby.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.provider.Settings as AndroidSettings
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
@@ -11,6 +15,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -45,6 +50,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.key
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateDpAsState
@@ -71,6 +77,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -89,7 +96,7 @@ import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.DeviceLimitException
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.performance.PerformanceMonitor
@@ -102,6 +109,7 @@ import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import kotlinx.coroutines.launch
import java.util.Date
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -126,6 +134,8 @@ class MainActivity : ComponentActivity() {
private fun AppRoot(onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository
var settings by remember { mutableStateOf<Settings?>(null) }
var addingProfile by rememberSaveable { mutableStateOf(false) }
var startingFirstRun by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(repo) {
repo.settingsFlow.collect { settings = it }
}
@@ -134,155 +144,473 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
val loaded = settings
when {
loaded == null -> MembyLoadingScreen()
addingProfile -> SetupScreen(
onCancel = { addingProfile = false },
onSignedIn = { addingProfile = false },
)
loaded.isSignedIn -> {
BackHandler(onBack = onCloseSettings)
key(loaded.userId, loaded.serverUrl) {
HomeScreen(loaded)
HomeScreen(
settings = loaded,
onAddProfile = { addingProfile = true },
)
}
}
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(loaded)
else -> SetupScreen()
loaded.profiles.isNotEmpty() -> ProfileEntryScreen(
settings = loaded,
onAddProfile = { addingProfile = true },
)
startingFirstRun -> SetupScreen(
onCancel = { startingFirstRun = false },
onSignedIn = { startingFirstRun = false },
)
else -> FirstRunScreen(onGetStarted = { startingFirstRun = true })
}
}
}
@Composable
private fun MembyLoadingScreen() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
val transition = rememberInfiniteTransition(label = "cold-start")
val pulse by transition.animateFloat(
initialValue = 0.96f,
targetValue = 1.04f,
animationSpec = infiniteRepeatable(
animation = tween(750),
repeatMode = RepeatMode.Reverse,
),
label = "cold-start-logo-pulse",
)
val glow by transition.animateFloat(
initialValue = 0.35f,
targetValue = 0.78f,
animationSpec = infiniteRepeatable(
animation = tween(750),
repeatMode = RepeatMode.Reverse,
),
label = "cold-start-glow",
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF16231E), Color(0xFF090C0F)),
radius = 1_100f,
),
),
contentAlignment = Alignment.Center,
) {
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo),
contentDescription = "Emby",
modifier = Modifier.width(92.dp).height(76.dp),
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier
.width(82.dp)
.height(68.dp)
.graphicsLayer {
scaleX = pulse
scaleY = pulse
alpha = 0.82f + (glow * 0.18f)
},
)
Text(
"Opening Memby…",
color = Color.White.copy(alpha = 0.88f),
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
)
Box(
Modifier
.width(92.dp)
.height(2.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.10f)),
) {
Box(
Modifier
.fillMaxWidth(glow)
.height(2.dp)
.clip(CircleShape)
.background(Color(0xFF52B54B)),
)
}
}
}
}
@Composable
private fun FirstRunScreen(onGetStarted: () -> Unit) {
val getStartedFocus = remember { FocusRequester() }
var entered by remember { mutableStateOf(false) }
val contentAlpha by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(420),
label = "first-run-content-alpha",
)
val contentOffset by animateDpAsState(
targetValue = if (entered) 0.dp else 18.dp,
animationSpec = tween(420),
label = "first-run-content-offset",
)
val ambient = rememberInfiniteTransition(label = "first-run-ambient")
val logoScale by ambient.animateFloat(
initialValue = 0.97f,
targetValue = 1.04f,
animationSpec = infiniteRepeatable(
animation = tween(1_800),
repeatMode = RepeatMode.Reverse,
),
label = "first-run-logo-scale",
)
val haloAlpha by ambient.animateFloat(
initialValue = 0.10f,
targetValue = 0.22f,
animationSpec = infiniteRepeatable(
animation = tween(1_800),
repeatMode = RepeatMode.Reverse,
),
label = "first-run-halo",
)
LaunchedEffect(Unit) {
entered = true
kotlinx.coroutines.delay(180L)
runCatching { getStartedFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(
Color(0xFF0A0E11),
Color(0xFF101A17),
Color(0xFF090B0D),
),
),
),
) {
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.offset(x = 70.dp)
.size(560.dp)
.graphicsLayer { alpha = haloAlpha }
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF52B54B), Color.Transparent),
),
CircleShape,
),
)
Spacer(Modifier.height(22.dp))
Text("Emby is loading", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(10.dp))
Text("Preparing your library…", color = Color(0xFF9EA6AD), fontSize = 16.sp)
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 88.dp, vertical = 64.dp)
.graphicsLayer { alpha = contentAlpha }
.offset(y = contentOffset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier.width(590.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"WELCOME TO MEMBY",
color = Color(0xFF69CD61),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.sp,
)
Text(
"Whos watching?",
color = Color.White,
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Sign in to bring your Emby library, progress and favourites together on this TV.",
color = Color(0xFFB8C0C6),
fontSize = 20.sp,
lineHeight = 29.sp,
)
Text(
"You can add the rest of the household afterwards.",
color = Color(0xFF818C94),
fontSize = 16.sp,
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onGetStarted,
modifier = Modifier.focusRequester(getStartedFocus),
) {
Text("Get started")
}
}
Box(
modifier = Modifier.size(330.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(250.dp)
.graphicsLayer {
scaleX = logoScale
scaleY = logoScale
alpha = haloAlpha
}
.background(Color(0xFF52B54B), CircleShape),
)
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
modifier = Modifier
.width(178.dp)
.height(148.dp)
.graphicsLayer {
scaleX = logoScale
scaleY = logoScale
},
)
}
}
}
}
@Composable
private fun SetupScreen(
onCancel: (() -> Unit)? = null,
onSignedIn: () -> Unit = {},
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
val scope = rememberCoroutineScope()
// A build that hardwires the server (memby.serverUrl) never asks for an address.
val hardwiredHost = ServerConfig.displayHost
var serverUrl by rememberSaveable { mutableStateOf(if (hardwiredHost != null) "" else "http://") }
val suggestedName = remember(context) { suggestedDeviceName(context) }
var deviceName by rememberSaveable {
mutableStateOf(
ServiceLocator.settings.current?.deviceName
?.takeIf { it.isNotBlank() }
?: suggestedName,
)
}
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var connecting by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var maxClientsPerUser by remember { mutableStateOf<Int?>(null) }
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
LaunchedEffect(hardwiredHost) {
runCatching { if (hardwiredHost != null) usernameFocus.requestFocus() else serverFocus.requestFocus() }
val deviceNameFocus = remember { FocusRequester() }
LaunchedEffect(repo) {
maxClientsPerUser = runCatching { repo.authPolicy()?.maxClientsPerUser }
.getOrNull()
?.takeIf { it > 0 }
}
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100L)
runCatching { deviceNameFocus.requestFocus() }
}
if (onCancel != null) BackHandler(onBack = onCancel)
Column(
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 96.dp, vertical = 56.dp)
.width(720.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text("Connect to Memby", color = Color.White, fontSize = 40.sp, fontWeight = FontWeight.Bold)
Text(
if (hardwiredHost != null) {
"Sign in with your Emby username and password."
} else {
"Enter your Emby server address and sign in."
},
color = Color(0xFFB9C0C7),
fontSize = 18.sp,
)
if (hardwiredHost != null) {
Text("Server: $hardwiredHost", color = Color(0xFF9AA3AC), fontSize = 15.sp)
} else {
TvTextField(
label = "Server address (e.g. http://192.168.1.10:8096)",
value = serverUrl,
onValueChange = { serverUrl = it; error = null },
modifier = Modifier.focusRequester(serverFocus),
keyboardType = KeyboardType.Uri,
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF172126), Color(0xFF090C0F)),
radius = 1_250f,
),
)
}
TvTextField(
label = "Username",
value = username,
onValueChange = { username = it; error = null },
modifier = Modifier.focusRequester(usernameFocus),
)
TvTextField(
label = "Password",
value = password,
onValueChange = { password = it; error = null },
isPassword = true,
)
error?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 16.sp) }
Button(
onClick = {
if (hardwiredHost == null && serverUrl.isBlank()) {
error = "Server address is required."
return@Button
}
if (username.isBlank()) {
error = "Username is required."
return@Button
}
connecting = true
error = null
scope.launch {
runCatching { repo.authenticate(serverUrl, username, password) }
.onFailure { error = "Sign-in failed: ${it.message}" }
connecting = false
}
},
.padding(horizontal = 72.dp, vertical = 44.dp),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(620.dp)
.clip(RoundedCornerShape(22.dp))
.background(Color(0xF2161C20))
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(22.dp))
.padding(horizontal = 44.dp, vertical = 38.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(if (connecting) "Connecting…" else "Connect")
}
if (onCancel != null) {
Button(onClick = onCancel) { Text("Cancel") }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = "Memby",
modifier = Modifier.size(48.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
if (onCancel != null) "Add another viewer" else "Welcome to Memby",
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Sign in with an Emby account",
color = Color(0xFF9EA9B1),
fontSize = 16.sp,
)
}
}
Text(
text = if (onCancel != null) {
"Their account will be saved as another profile on this TV."
} else {
"Enter your username and password to start watching."
},
color = Color(0xFFBAC2C8),
fontSize = 17.sp,
)
TvTextField(
label = "Name this TV",
value = deviceName,
onValueChange = { deviceName = it.take(80); error = null },
modifier = Modifier.focusRequester(deviceNameFocus),
)
Text(
"We suggested this from your TV. A room name such as “Dads TV” makes it easier to recognise later.",
color = Color(0xFF89959D),
fontSize = 14.sp,
)
TvTextField(
label = "Username",
value = username,
onValueChange = { username = it; error = null },
modifier = Modifier,
)
TvTextField(
label = "Password",
value = password,
onValueChange = { password = it; error = null },
isPassword = true,
)
maxClientsPerUser?.let { maximum ->
Text(
"Each Emby account can be signed in on up to $maximum Memby devices.",
color = Color(0xFF76C970),
fontSize = 14.sp,
)
}
error?.let { Text(it, color = Color(0xFFFF7777), fontSize = 16.sp) }
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Button(
onClick = {
if (deviceName.isBlank()) {
error = "Give this TV a name."
return@Button
}
if (username.isBlank()) {
error = "Username is required."
return@Button
}
connecting = true
error = null
scope.launch {
runCatching {
repo.authenticate(
serverUrl = "",
username = username.trim(),
password = password,
deviceName = deviceName.trim(),
)
}
.onSuccess { onSignedIn() }
.onFailure { failure ->
error = when (failure) {
is DeviceLimitException ->
"Device limit reached: this account is using " +
"${failure.activeClients} of ${failure.maxClients} devices. " +
"Sign out on another TV, then try again."
else ->
"Memby couldn't sign in. Check the username and password and try again."
}
}
connecting = false
}
},
enabled = !connecting,
) {
Text(if (connecting) "Signing in…" else "Sign in")
}
if (onCancel != null) {
Button(
onClick = onCancel,
enabled = !connecting,
) {
Text("Back")
}
}
}
}
}
}
internal fun suggestedDeviceName(context: android.content.Context): String {
val systemName = runCatching {
AndroidSettings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()?.trim().orEmpty()
if (systemName.isNotBlank()) return systemName.take(80)
val manufacturer = Build.MANUFACTURER
?.trim()
.orEmpty()
.takeUnless { it.equals("unknown", ignoreCase = true) }
.orEmpty()
val model = Build.MODEL
?.trim()
.orEmpty()
.takeUnless { it.equals("unknown", ignoreCase = true) }
.orEmpty()
val inferred = listOf(manufacturer, model)
.filter { it.isNotBlank() }
.distinctBy { it.lowercase() }
.joinToString(" ")
return inferred.ifBlank { "Living room TV" }.take(80)
}
@Composable
private fun ProfileEntryScreen(settings: Settings) {
private fun ProfileEntryScreen(
settings: Settings,
onAddProfile: () -> Unit,
) {
val repo = ServiceLocator.repository
val scope = rememberCoroutineScope()
var addingProfile by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) }
if (addingProfile) {
SetupScreen(onCancel = { addingProfile = false })
} else {
ProfileChooser(
profiles = settings.profiles,
currentProfileId = null,
switchingProfileId = switchingProfileId,
onSelect = { profile ->
switchingProfileId = profile.id
scope.launch {
runCatching { repo.switchProfile(profile) }
.onFailure { switchingProfileId = null }
}
},
onAddProfile = { addingProfile = true },
onClose = null,
)
}
ProfileChooser(
profiles = settings.profiles,
currentProfileId = null,
switchingProfileId = switchingProfileId,
onSelect = { profile ->
switchingProfileId = profile.id
scope.launch {
runCatching { repo.switchProfile(profile) }
.onFailure { switchingProfileId = null }
}
},
onAddProfile = onAddProfile,
onClose = null,
)
}
@Composable
@@ -311,11 +639,11 @@ private fun ProfileChooser(
) {
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo),
contentDescription = "Emby",
contentDescription = "Memby",
modifier = Modifier.width(72.dp).height(60.dp),
)
Spacer(Modifier.height(20.dp))
Text("Whos watching Emby?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold)
Text("Whos watching?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold)
Text(
if (switchingProfileId == null) "Choose a profile to continue" else "Switching profile…",
color = Color(0xFFAAB1B7),
@@ -336,7 +664,7 @@ private fun ProfileChooser(
)
}
ProfileTile(
name = "Add someone else",
name = "Add another user",
current = false,
enabled = switchingProfileId == null,
symbol = "+",
@@ -346,7 +674,7 @@ private fun ProfileChooser(
}
if (onClose != null) {
Spacer(Modifier.height(30.dp))
Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Emby") }
Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Memby") }
}
}
}
@@ -398,40 +726,71 @@ private fun ProfileTile(
}
@Composable
private fun HomeScreen(settings: Settings) {
private fun HomeScreen(
settings: Settings,
onAddProfile: () -> Unit,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
val scope = rememberCoroutineScope()
val factory = remember(repo) { HomeViewModelFactory(repo) }
val homeViewModel: HomeViewModel = viewModel(factory = factory)
val homeState by homeViewModel.state.collectAsStateWithLifecycle()
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
var showSettings by remember { mutableStateOf(false) }
var showProfiles by remember { mutableStateOf(false) }
var addProfile by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) }
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var navigationExpanded by remember { mutableStateOf(false) }
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
var launchingItem by remember { mutableStateOf<BaseItem?>(null) }
var returnRowId by rememberSaveable { mutableStateOf<String?>(null) }
var returnItemId by rememberSaveable { mutableStateOf<String?>(null) }
val navigationFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
val cardReturnFocusRequester = remember { FocusRequester() }
var initialFocusRequested by remember { mutableStateOf(false) }
val playItem: (BaseItem) -> Unit = { item ->
LaunchedEffect(liveMaintenance) {
if (liveMaintenance != null) {
// Maintenance is an app-wide interruption, not another overlay in the stack.
showSettings = false
showProfiles = false
detailsItem = null
quickMenuItem = null
} else if (homeState.maintenanceMessage != null) {
// A successful live status is authoritative; refresh content immediately
// instead of waiting for the maintenance screen's slower retry timer.
homeViewModel.refreshAll()
}
}
val playItem: (BaseItem) -> Unit = playItem@{ item ->
if (launchingItem != null || !item.membyPlayable) return@playItem
launchingItem = item
val playbackRequestedAtMs = SystemClock.elapsedRealtime()
scope.launch {
runCatching { repo.resolvePlayable(item) }.onSuccess { playable ->
context.startActivity(
PlayerActivity.intent(
context = context,
itemId = playable.itemId,
url = playable.url,
title = playable.title,
resumePositionMs = playable.resumePositionMs,
),
)
try {
runCatching { repo.resolvePlayable(item) }
.onSuccess { playable ->
context.startActivity(
PlayerActivity.intent(
context = context,
itemId = playable.itemId,
url = playable.url,
title = playable.title,
resumePositionMs = playable.resumePositionMs,
logoUrl = playable.logoUrl,
requestStartedAtMs = playbackRequestedAtMs,
),
)
}
.onFailure {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
}
} finally {
launchingItem = null
}
}
}
@@ -491,7 +850,7 @@ private fun HomeScreen(settings: Settings) {
.fillMaxSize()
.offset(x = contentShift),
) {
val maintenanceMessage = homeState.maintenanceMessage
val maintenanceMessage = liveMaintenance?.message ?: homeState.maintenanceMessage
if (maintenanceMessage != null) {
// The rows are gone but the rail is not: Settings and Switch user are
// local, so there is no reason to strand the viewer here.
@@ -575,13 +934,13 @@ private fun HomeScreen(settings: Settings) {
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackRowSelected(row.id, row.kind.name, item.id)
detailsItem = item
if (item.membyPlayable) detailsItem = item
},
onItemLongPressed = { item ->
returnRowId = row.id
returnItemId = item.id
homeViewModel.focusItem(item)
quickMenuItem = item
if (item.membyPlayable) quickMenuItem = item
},
)
}
@@ -589,6 +948,11 @@ private fun HomeScreen(settings: Settings) {
}
}
}
HomeClock(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 18.dp),
)
if (showSettings) {
BackHandler(onBack = { showSettings = false })
SettingsSheet(editableServer = true, onClose = { showSettings = false })
@@ -612,14 +976,11 @@ private fun HomeScreen(settings: Settings) {
},
onAddProfile = {
showProfiles = false
addProfile = true
onAddProfile()
},
onClose = { showProfiles = false },
)
}
if (addProfile) {
SetupScreen(onCancel = { addProfile = false })
}
detailsItem?.let { selected ->
BackHandler {
detailsItem = null
@@ -656,9 +1017,15 @@ private fun HomeScreen(settings: Settings) {
},
)
}
launchingItem?.let { item ->
PlaybackLaunchOverlay(
item = item,
modifier = Modifier.fillMaxSize().zIndex(9f),
)
}
// Last in the Box, so it draws over everything — including the rail and any
// overlay that happened to be open when the check came back.
homeState.update?.let { update ->
homeState.update?.takeIf { liveMaintenance == null }?.let { update ->
UpdateScreen(
update = update,
onDismiss = homeViewModel::dismissUpdatePrompt,
@@ -668,6 +1035,79 @@ private fun HomeScreen(settings: Settings) {
}
}
@Composable
private fun PlaybackLaunchOverlay(item: BaseItem, modifier: Modifier = Modifier) {
val rotation by rememberInfiniteTransition(label = "playback-launch").animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 850, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "playback-logo-rotation",
)
Box(
modifier = modifier.background(Color(0xEE050708)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.width(82.dp)
.height(70.dp)
.graphicsLayer { rotationZ = rotation },
)
Text(
"Starting ${item.name}",
color = Color.White,
fontSize = 21.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Preparing direct playback…",
color = Color(0xFF9FA7AD),
fontSize = 14.sp,
)
}
}
}
@Composable
private fun HomeClock(modifier: Modifier = Modifier) {
val context = LocalContext.current
val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) }
var currentTime by remember { mutableStateOf(Date()) }
LaunchedEffect(Unit) {
while (true) {
currentTime = Date()
// Wake on the next minute boundary instead of drifting by a few seconds
// every time the app is left open.
val untilNextMinute = 60_000L - (System.currentTimeMillis() % 60_000L)
kotlinx.coroutines.delay(untilNextMinute)
}
}
Text(
text = timeFormatter.format(currentTime),
color = Color.White.copy(alpha = 0.86f),
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10))
.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
@Composable
private fun HomeArtworkPreloader(
rows: List<HomeBrowseRow>,
@@ -804,13 +1244,18 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
.map { row ->
HomeBrowseRow(
id = row.id,
title = row.title,
title = if (row.kind == "favorites") {
personalizedFavoritesTitle(settings.username)
} else {
row.title
},
items = row.items,
kind = when (row.kind) {
"continue" -> MediaRowKind.CONTINUE
"nextup" -> MediaRowKind.NEXT_UP
"favorites" -> MediaRowKind.FAVORITES
"shows" -> MediaRowKind.SHOWS
"schedule" -> MediaRowKind.SHOWS
// Recommendation strips, "latest", and any kind a future server
// sends get poster cards, which suit mixed movie/series rows.
else -> MediaRowKind.MOVIES
@@ -821,6 +1266,7 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
"nextup" -> "You're all caught up"
"favorites" -> "Your favourites will appear here"
"latest" -> "No recent movies found"
"schedule" -> "No monitored shows are airing today"
else -> "Nothing to show here yet"
},
showSecondaryMetadata = settings.showHomeCardMetadata,
@@ -862,7 +1308,7 @@ private fun homeRowsFor(
)
val favorites = HomeBrowseRow(
id = "favorites",
title = "Favourites",
title = personalizedFavoritesTitle(settings.username),
items = state.favorites,
kind = MediaRowKind.FAVORITES,
loading = HomeSection.FAVORITES in state.loading,
@@ -914,6 +1360,33 @@ private fun homeRowsFor(
}
}
/**
* Turns Emby account-style usernames into a friendlier home-screen name.
*
* Household accounts commonly use a trailing capital as a disambiguating surname
* initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary
* usernames such as PJ, CHRIS, alice, or MattCohen are left alone.
*/
internal fun friendlyProfileName(username: String?): String? {
val name = username?.trim().orEmpty()
if (name.isEmpty()) return null
return if (
name.length >= 3 &&
name.last().isUpperCase() &&
name[name.lastIndex - 1].isLowerCase()
) {
name.dropLast(1)
} else {
name
}
}
internal fun personalizedFavoritesTitle(username: String?): String {
val name = friendlyProfileName(username) ?: return "Favorites"
val possessive = if (name.endsWith("s", ignoreCase = true)) "$name'" else "$name's"
return "$possessive Favorites"
}
@Composable
private fun HeaderAction(label: String, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
@@ -1,12 +1,20 @@
package com.ponzischeme89.memby.ui.player
import android.app.AlertDialog
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.SystemClock
import android.text.format.DateFormat
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.view.animation.LinearInterpolator
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.media3.common.C
import androidx.media3.common.MediaItem
@@ -15,14 +23,23 @@ import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.PlayerView
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import coil.load
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.ui.MainActivity
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.math.ceil
/**
* Fullscreen Media3 player with native stream-track selection. Press Menu while
@@ -37,6 +54,12 @@ class PlayerActivity : ComponentActivity() {
private var playbackStarted = false
private var stopReported = false
private var itemId: String? = null
private var remainingView: TextView? = null
private var finishTimeView: TextView? = null
private var loadingView: View? = null
private var loadingAnimator: ObjectAnimator? = null
private var renderedFirstFrame = false
private var requestStartedAtMs = 0L
@UnstableApi
override fun onCreate(savedInstanceState: Bundle?) {
@@ -44,6 +67,10 @@ class PlayerActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val url = intent.getStringExtra(EXTRA_URL)
requestStartedAtMs = intent.getLongExtra(
EXTRA_REQUEST_STARTED_AT_MS,
SystemClock.elapsedRealtime(),
)
itemId = intent.getStringExtra(EXTRA_ITEM_ID)
val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L)
if (url.isNullOrBlank()) {
@@ -51,25 +78,54 @@ class PlayerActivity : ComponentActivity() {
return
}
val view = PlayerView(this).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
setContentView(R.layout.activity_player)
val view = findViewById<PlayerView>(R.id.player_view).apply {
useController = true
setShowSubtitleButton(true)
controllerShowTimeoutMs = CONTROLLER_TIMEOUT_MS
}
setContentView(view)
playerView = view
loadingView = findViewById(R.id.playback_loading)
loadingAnimator = ObjectAnimator.ofFloat(
findViewById<ImageView>(R.id.playback_loading_logo),
View.ROTATION,
0f,
360f,
).apply {
duration = 850L
repeatCount = ValueAnimator.INFINITE
interpolator = LinearInterpolator()
start()
}
remainingView = findViewById(R.id.player_remaining)
finishTimeView = findViewById(R.id.player_finish_time)
bindTitleArtwork(
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
logoUrl = intent.getStringExtra(EXTRA_LOGO_URL),
)
val selector = DefaultTrackSelector(this)
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
MIN_BUFFER_MS,
MAX_BUFFER_MS,
BUFFER_FOR_PLAYBACK_MS,
BUFFER_AFTER_REBUFFER_MS,
)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
player = ExoPlayer.Builder(this)
.setTrackSelector(selector)
.setLoadControl(loadControl)
.build()
.also { playback ->
view.player = playback
playback.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_BUFFERING -> showPlaybackLoading()
Player.STATE_READY -> if (renderedFirstFrame) hidePlaybackLoading()
}
if (playbackState == Player.STATE_READY && !playbackStarted) {
playbackStarted = true
reportStarted(playback.currentPosition)
@@ -80,11 +136,95 @@ class PlayerActivity : ComponentActivity() {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (playbackStarted) reportProgress(playback.currentPosition, isPaused = !isPlaying)
}
override fun onEvents(player: Player, events: Player.Events) {
updatePlaybackTiming(player)
}
override fun onRenderedFirstFrame() {
renderedFirstFrame = true
hidePlaybackLoading()
Log.i(
PLAYBACK_LOG_TAG,
"First video frame rendered in " +
"${SystemClock.elapsedRealtime() - requestStartedAtMs} ms",
)
}
})
playback.setMediaItem(MediaItem.fromUri(url), resumePositionMs)
playback.playWhenReady = true
playback.prepare()
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
ServiceLocator.maintenance.notice.filterNotNull().collect {
interruptForMaintenance()
}
}
}
}
private fun showPlaybackLoading() {
loadingView?.visibility = View.VISIBLE
if (loadingAnimator?.isStarted != true) loadingAnimator?.start()
}
private fun hidePlaybackLoading() {
loadingView?.visibility = View.GONE
loadingAnimator?.cancel()
}
private fun bindTitleArtwork(title: String, logoUrl: String?) {
val logo = findViewById<ImageView>(R.id.player_title_logo)
val fallback = findViewById<TextView>(R.id.player_title).apply {
text = title.ifBlank { "Now playing" }
}
if (logoUrl.isNullOrBlank()) {
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
return
}
logo.load(logoUrl) {
crossfade(false)
listener(
onSuccess = { _, _ ->
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = View.VISIBLE
},
)
}
}
private fun updatePlaybackTiming(playback: Player) {
val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) {
remainingView?.text = if (playback.isCurrentMediaItemLive) "Live" else "Loading duration…"
finishTimeView?.text = ""
return
}
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
val speed = playback.playbackParameters.speed.coerceAtLeast(0.1f)
val wallClockRemainingMs = (remainingMs / speed).toLong()
remainingView?.text = formatRemaining(wallClockRemainingMs)
val finishAt = System.currentTimeMillis() + wallClockRemainingMs
finishTimeView?.text = "Ends at ${DateFormat.getTimeFormat(this).format(Date(finishAt))}"
}
private fun interruptForMaintenance() {
player?.pause()
progressJob?.cancel()
startActivity(
Intent(this, MainActivity::class.java).addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP,
),
)
finish()
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -159,6 +299,7 @@ class PlayerActivity : ComponentActivity() {
override fun onDestroy() {
progressJob?.cancel()
loadingAnimator?.cancel()
val playback = player
if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) {
stopReported = true
@@ -189,7 +330,10 @@ class PlayerActivity : ComponentActivity() {
progressJob = lifecycleScope.launch {
while (isActive) {
delay(PROGRESS_INTERVAL_MS)
player?.let { reportProgress(it.currentPosition, isPaused = !it.isPlaying) }
player?.let {
reportProgress(it.currentPosition, isPaused = !it.isPlaying)
updatePlaybackTiming(it)
}
}
}
}
@@ -201,6 +345,8 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_ITEM_ID = "extra_item_id"
private const val EXTRA_TITLE = "extra_title"
private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms"
private const val EXTRA_LOGO_URL = "extra_logo_url"
private const val EXTRA_REQUEST_STARTED_AT_MS = "extra_request_started_at_ms"
fun intent(
context: Context,
@@ -215,14 +361,34 @@ class PlayerActivity : ComponentActivity() {
url: String,
title: String?,
resumePositionMs: Long = 0L,
logoUrl: String? = null,
requestStartedAtMs: Long = SystemClock.elapsedRealtime(),
): Intent =
Intent(context, PlayerActivity::class.java).apply {
itemId?.let { putExtra(EXTRA_ITEM_ID, it) }
putExtra(EXTRA_URL, url)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs)
logoUrl?.let { putExtra(EXTRA_LOGO_URL, it) }
putExtra(EXTRA_REQUEST_STARTED_AT_MS, requestStartedAtMs)
}
private const val PROGRESS_INTERVAL_MS = 10_000L
private const val CONTROLLER_TIMEOUT_MS = 6_000
private const val MIN_BUFFER_MS = 10_000
private const val MAX_BUFFER_MS = 30_000
private const val BUFFER_FOR_PLAYBACK_MS = 750
private const val BUFFER_AFTER_REBUFFER_MS = 1_500
private const val PLAYBACK_LOG_TAG = "MembyPlayback"
internal fun formatRemaining(durationMs: Long): String {
val totalMinutes = ceil(durationMs.coerceAtLeast(0L) / 60_000.0).toLong()
return when {
totalMinutes <= 1L -> "Less than a minute remaining"
totalMinutes < 60L -> "$totalMinutes min remaining"
totalMinutes % 60L == 0L -> "${totalMinutes / 60L} hr remaining"
else -> "${totalMinutes / 60L} hr ${totalMinutes % 60L} min remaining"
}
}
}
}
+3
View File
@@ -1,4 +1,7 @@
<resources>
<string name="player_title_logo">Title logo</string>
<string name="playback_loading">Starting playback…</string>
<string name="playback_loading_hint">Connecting directly to Emby</string>
<string name="app_name">Memby</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="dream_description">Memby movie &amp; TV backdrops</string>
@@ -79,6 +79,44 @@ class GatewayPayloadTest {
assertEquals("Solaris", home.rows.last().items.single().name)
}
@Test
fun `decodes the informational Sonarr schedule row`() {
val payload = """
{
"rows": [{
"id":"sonarr-airing-today",
"title":"Shows airing today",
"kind":"schedule",
"items":[{
"Id":"sonarr:7:42",
"Name":"Northbound",
"Type":"MembySonarrEpisode",
"ImageTags":{"Primary":"sonarr"},
"MembySource":"sonarr",
"MembyEpisodeTitle":"The Crossing",
"MembyEpisodeCode":"S02E04",
"MembyAirLabel":"Airs today at 8:00 PM",
"MembyAvailability":"downloading",
"MembyAvailabilityText":"Downloading",
"MembyPlayable":false
}]
}],
"continueWatching":[],
"nextUp":[],
"favorites":[],
"latestMovies":[],
"partial":false
}
""".trimIndent()
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
assertTrue(item.isSonarrSchedule)
assertEquals("S02E04", item.membyEpisodeCode)
assertEquals("Downloading", item.membyAvailabilityText)
assertEquals(false, item.membyPlayable)
}
@Test
fun `a home payload without rows still decodes`() {
// The gateway omits recommendation rows while they are still building, and an
@@ -106,4 +144,14 @@ class GatewayPayloadTest {
assertEquals(42_000L, playback.resumePositionMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
}
@Test
fun `device allowance response becomes a specific sign-in error`() {
val error = parseDeviceLimit(
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
)
assertEquals(3, error?.activeClients)
assertEquals(3, error?.maxClients)
}
}
@@ -43,6 +43,32 @@ class ServerHomeRowsTest {
assertEquals("Recommended", rows.last().title)
}
@Test
fun `favorites row uses the friendly profile name`() {
val peter = serverHomeRows(
HomeUiState(rows = serverRows, loading = emptySet()),
Settings(username = "PeterC"),
)
val paul = serverHomeRows(
HomeUiState(rows = serverRows, loading = emptySet()),
Settings(username = "PaulR"),
)
assertEquals("Peter's Favorites", peter.first { it.id == "favorites" }.title)
assertEquals("Paul's Favorites", paul.first { it.id == "favorites" }.title)
}
@Test
fun `friendly profile name only removes a clear trailing initial`() {
assertEquals("Peter", friendlyProfileName("PeterC"))
assertEquals("Paul", friendlyProfileName(" PaulR "))
assertEquals("MattCohen", friendlyProfileName("MattCohen"))
assertEquals("CHRIS", friendlyProfileName("CHRIS"))
assertEquals("PJ", friendlyProfileName("PJ"))
assertEquals("Chris' Favorites", personalizedFavoritesTitle("Chris"))
assertEquals("Favorites", personalizedFavoritesTitle(null))
}
@Test
fun `disabling a section hides its rows but never the recommendations`() {
val settings = Settings(homeSections = "continue")
@@ -67,6 +93,20 @@ class ServerHomeRowsTest {
assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind)
}
@Test
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
val rows = serverHomeRows(
HomeUiState(rows = listOf(schedule), loading = emptySet()),
Settings(homeSections = ""),
)
assertEquals(1, rows.size)
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
assertEquals("No monitored shows are airing today", rows.single().emptyMessage)
}
@Test
fun `an unknown row kind from a newer server still renders`() {
val rows = serverHomeRows(