Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,30 @@ it), the worst case is the user gets a "Up to date" message and
needs to manually hit Download. `just apk-push` is that script; don't
upload the files by hand.

### Profiles and friends

A profile is a `users` row with `account_id` set, so every per-user table works
unchanged; never add a `profile_id`. Sign-in and the admin list see accounts
only (`account_id IS NULL`). Switching (`POST /api/auth/switch-profile`, under
`/api/auth` for the refresh cookie's path) opens a session on the other row. What
touches an account touches its profiles: `users::set_password`, `users::delete`,
`SessionCuts::forget` (clears everyone), the device list. Friendships hang on
profiles. What a profile adds is visible to friends; what it watches, live
included, only with `share_watching`, and `friends_hidden` beats both. A declined
request keeps its row: the asker still reads it as pending.

A person's mascot comes from their name alone: the Iris mark's grammar (no mouth),
its table and hash in `packages/iris-api/src/mascot.ts` and again in the TV's
`ui/components/Mascot.kt`. Changing one means changing both, and the test vectors
they share.

### The update sentence

A release that users should hear about gets one `> sentence` right under its
`## [x.y.z]` heading in `CHANGELOG.md`, in English. The server embeds it at
build time (`routes/release_note.rs`) and the web and TV homes say it once. No
quote line, nothing said: fine for a quiet patch.

### Versioning & client gate

**All three components** carry the same semver-aligned version
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **What changed, in a sentence**: after an update the home says it once, on
the web and the TV. The sentence is a `> quote` line right under the version
heading here; a release without one says nothing.
- **Profiles**: a shared account holds up to six profiles, each with its own
history, watchlist and preferences. An account with several asks who is
watching, on the web and the TV. The password and passkeys stay on the main
profile; a password change ends the sessions of every profile.
- **Friends**: found by name, never by email, and asked; declining tells the
other person nothing. Home says what friends play now and watched or added
lately. What a profile watches reaches friends only once it shares it, and a
title can be hidden from them.
- **Mascots**: everyone gets a small Iris TV picked from their name, the same on
the web and the TV: who's watching, the header, friends, profiles.

### Fixed

- **TV header**: the logo, the tabs and the account's name each pick their ink
from the luminance of what is behind them, the still's fade included, with
the same pass as the channel logos.

## [1.6.4] - 2026-10-31

### Added
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ COPY --from=planner /app/recipe.json ./recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
cargo chef cook --release --bin iris --recipe-path recipe.json
COPY Cargo.toml Cargo.lock* ./
COPY Cargo.toml Cargo.lock* CHANGELOG.md ./
COPY crates ./crates
COPY migrations ./migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
Expand Down
49 changes: 49 additions & 0 deletions android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package studio.kahn.iris.tv.data
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
Expand Down Expand Up @@ -36,6 +37,50 @@ interface IrisApi {
@POST("api/auth/logout")
suspend fun logout()

/** Under `/api/auth`, the only path the refresh cookie goes to: new session cookies on the other profile. */
@POST("api/auth/switch-profile")
suspend fun switchProfile(@Body body: SwitchProfileRequest): UserResponse

@GET("api/me/profiles")
suspend fun profiles(): List<ProfileView>

@POST("api/me/profiles")
suspend fun createProfile(@Body body: CreateProfileRequest): List<ProfileView>

@DELETE("api/me/profiles/{id}")
suspend fun removeProfile(@Path("id") id: String)

@GET("api/me/friends")
suspend fun friends(): FriendsOverview

@GET("api/me/friends/search")
suspend fun searchFriends(@Query("q") q: String): List<PersonView>

@GET("api/me/friends/activity")
suspend fun friendActivity(): FriendActivity

@POST("api/me/friends/{id}")
suspend fun askFriend(@Path("id") id: String)

@POST("api/me/friends/{id}/accept")
suspend fun acceptFriend(@Path("id") id: String)

@POST("api/me/friends/{id}/decline")
suspend fun declineFriend(@Path("id") id: String)

/** Ends a friendship, or takes back one's own request. */
@DELETE("api/me/friends/{id}")
suspend fun removeFriend(@Path("id") id: String)

@PUT("api/me/friends/sharing")
suspend fun setSharing(@Body body: SharingRequest)

@POST("api/me/friends/hidden")
suspend fun hideFromFriends(@Body body: HideRequest)

@HTTP(method = "DELETE", path = "api/me/friends/hidden", hasBody = true)
suspend fun showToFriends(@Body body: UnhideRequest)

@GET("api/me")
suspend fun me(): UserResponse

Expand Down Expand Up @@ -66,6 +111,10 @@ interface IrisApi {
@PUT("api/me/preferences")
suspend fun savePreferences(@Body body: UpdatePreferencesRequest): PreferencesResponse

/** The running server's version and its update sentence. */
@GET("api/release-note")
suspend fun releaseNote(): ReleaseNote

/** Merged movie + TV genre taxonomy for the onboarding picker. Anime
* is NOT in here — it's a distinct category driven by
* [UpdatePreferencesRequest.includeAnime]. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ private val Context.prefsDataStore by preferencesDataStore("iris_prefs")
private val KEY_SEARCH_VIEW_MODE = stringPreferencesKey("search_view_mode")
private val KEY_LIBRARY_VIEW = stringPreferencesKey("library_view")
private val KEY_INTERFACE_SIZE = stringPreferencesKey("interface_size")
private val KEY_RELEASE_SEEN = stringPreferencesKey("release_seen")

/**
* Small client-side UI preferences — NOT session / auth state (that's
Expand Down Expand Up @@ -65,6 +66,13 @@ class PrefsStore(private val context: Context) {
context.prefsDataStore.edit { it[KEY_LIBRARY_VIEW] = name }
}

/** The last version whose update sentence this TV has had (web `STORAGE.releaseSeen`). */
val releaseSeen: Flow<String?> = context.prefsDataStore.data.map { it[KEY_RELEASE_SEEN] }

suspend fun setReleaseSeen(version: String) {
context.prefsDataStore.edit { it[KEY_RELEASE_SEEN] = version }
}

val interfaceSize: Flow<InterfaceSize> = context.prefsDataStore.data.map { InterfaceSize.named(it[KEY_INTERFACE_SIZE]) }

suspend fun setInterfaceSize(size: InterfaceSize) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package studio.kahn.iris.tv.data

/**
* Whether this launch of the app asked who is watching, and how many times the profile changed:
* the home starts over when it did, wherever it was changed from.
*/
object ProfileChoice {
@Volatile var asked = false

@Volatile var changes = 0
private set

fun changed() {
asked = true
changes++
}

fun forget() {
asked = false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ fun IrisRoot(
},
onOpenLibrary = { navController.openTab(TopTab.Library) },
onOpenDiscover = { navController.openTab(TopTab.Discover) },
onAccountChanged = shell::refresh,
)
}
section<Routes.LiveTv>(TopTab.LiveTv, shellHost) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package studio.kahn.iris.tv.ui.components

import studio.kahn.iris.tv.ui.nav.HeaderBackdrop
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.findRootCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
Expand Down Expand Up @@ -59,6 +66,7 @@ enum class TopTab(val label: String) {
fun IrisWordmark(
modifier: Modifier = Modifier,
fontSize: TextUnit = IrisType.brand.fontSize,
ink: Color = IrisColor.ink,
) {
val markSize = with(LocalDensity.current) { (fontSize * MARK_TO_TEXT).toDp() }
val drop = with(LocalDensity.current) { (fontSize * BOREL_BASELINE_DROP).toDp() }
Expand All @@ -71,7 +79,7 @@ fun IrisWordmark(
Text(
"Iris",
style = IrisType.brand.copy(fontSize = fontSize, lineHeight = fontSize),
color = IrisColor.ink,
color = ink,
modifier = Modifier.offset(y = drop),
)
}
Expand All @@ -93,6 +101,7 @@ fun NavTab(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
ink: Color? = null,
) {
FocusSurface(
onClick = onClick,
Expand All @@ -105,7 +114,7 @@ fun NavTab(
shape = IrisShape.pill,
colors = FocusColors.Quiet.copy(
container = if (selected) IrisColor.accentWash else Color.Transparent,
content = if (selected) IrisColor.accent else IrisColor.inkMuted,
content = if (selected) IrisColor.accent else ink ?: IrisColor.inkMuted,
),
) {
Box(Modifier.padding(horizontal = 12.dp), contentAlignment = Alignment.Center) {
Expand Down Expand Up @@ -133,13 +142,15 @@ fun TvHeader(
modifier: Modifier = Modifier,
onAccount: (() -> Unit)? = null,
updateAvailable: Boolean = false,
/** What is drawn behind the header: each thing in it reads the part behind it. */
backdrop: HeaderBackdrop? = null,
) {
val currentTab = remember { FocusRequester() }
Row(
modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IrisWordmark()
OverBackdrop(backdrop) { ink, probe -> IrisWordmark(probe, ink = ink ?: IrisColor.ink) }
Spacer(Modifier.width(IrisSpace.s6))
Row(
Modifier
Expand All @@ -148,65 +159,72 @@ fun TvHeader(
horizontalArrangement = Arrangement.spacedBy(IrisSpace.s1),
) {
TopTab.entries.forEach { tab ->
NavTab(
text = tab.label,
selected = tab == current,
onClick = { onSelect(tab) },
modifier = if (tab == current) Modifier.focusRequester(currentTab) else Modifier,
)
OverBackdrop(backdrop) { ink, probe ->
NavTab(
text = tab.label,
selected = tab == current,
onClick = { onSelect(tab) },
modifier = probe.then(if (tab == current) Modifier.focusRequester(currentTab) else Modifier),
ink = ink,
)
}
}
}
Spacer(Modifier.weight(1f))
if (accountName != null) {
if (onAccount != null) {
FocusSurface(
onClick = onAccount,
modifier = Modifier.heightIn(min = IrisSize.controlSmall),
shape = IrisShape.pill,
colors = FocusColors.Quiet.copy(content = IrisColor.inkMuted),
) { focused ->
Account(
accountName,
focused,
updateAvailable,
Modifier
.padding(start = 3.dp, end = 12.dp)
.clearAndSetSemantics {
contentDescription = if (updateAvailable) {
"Settings, update available, $accountName"
} else {
"Settings, $accountName"
}
},
)
OverBackdrop(backdrop) { ink, probe ->
if (onAccount != null) {
FocusSurface(
onClick = onAccount,
modifier = probe.heightIn(min = IrisSize.controlSmall),
shape = IrisShape.pill,
colors = FocusColors.Quiet.copy(content = IrisColor.inkMuted),
) { focused ->
Account(
accountName,
focused,
updateAvailable,
ink,
Modifier
.padding(start = 3.dp, end = 12.dp)
.clearAndSetSemantics {
contentDescription = if (updateAvailable) {
"Settings, update available, $accountName"
} else {
"Settings, $accountName"
}
},
)
}
} else {
Account(accountName, focused = false, update = false, ink = ink, modifier = probe)
}
} else {
Account(accountName, focused = false, update = false)
}
}
}
}

/** [content] in the words that read over the part of [backdrop] behind it, told by the probe it wears. */
@Composable
private fun OverBackdrop(backdrop: HeaderBackdrop?, content: @Composable (ink: Color?, probe: Modifier) -> Unit) {
var span by remember { mutableStateOf(0f to 0f) }
val probe = Modifier.onGloballyPositioned { at ->
val width = at.findRootCoordinates().size.width.toFloat()
val bounds = at.boundsInRoot()
if (width > 0f) span = bounds.left / width to bounds.right / width
}
content(backdrop?.inkAt(span.first, span.second), probe)
}

@Composable
private fun Account(name: String, focused: Boolean, update: Boolean, modifier: Modifier = Modifier) {
private fun Account(name: String, focused: Boolean, update: Boolean, ink: Color? = null, modifier: Modifier = Modifier) {
Row(
modifier,
horizontalArrangement = Arrangement.spacedBy(IrisSpace.s2),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(IrisSize.avatar)) {
Box(
Modifier
.matchParentSize()
.background(IrisColor.accentWash, IrisShape.circle),
contentAlignment = Alignment.Center,
) {
Text(
name.trim().take(1).uppercase(),
style = IrisType.chip.copy(fontWeight = FontWeight.Bold),
color = IrisColor.accent,
)
}
Mascot(name, IrisSize.avatar)
if (update) {
Box(
Modifier
Expand All @@ -222,15 +240,15 @@ private fun Account(name: String, focused: Boolean, update: Boolean, modifier: M
Text(
name,
style = IrisType.meta,
color = if (focused) IrisColor.ground else IrisColor.inkMuted,
color = if (focused) IrisColor.ground else ink ?: IrisColor.inkMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (update) {
Text(
"Update",
style = IrisType.meta.copy(fontWeight = FontWeight.Bold),
color = if (focused) IrisColor.ground else IrisColor.accent,
color = if (focused) IrisColor.ground else ink ?: IrisColor.accent,
maxLines = 1,
)
}
Expand Down
Loading
Loading