Server changes/Sonarr
This commit is contained in:
@@ -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(
|
||||
"Matt’s 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(
|
||||
"Who’s 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 “Dad’s 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("Who’s watching Emby?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text("Who’s 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, "Couldn’t 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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(
|
||||
|
||||
Reference in New Issue
Block a user