Rebrand as Reverie: logo, frames, typography, previous-slide history
- Name, mark, banner, adaptive icon and BRANDING.md generated with Codex (branding/), wired into the launcher icon and TV banner. - Overlay typography: Playfair Display for date and clock, Manrope for the location line, plus a soft bottom scrim. - Picture frames setting: gallery mat, polaroid (tilted), rounded card. - LEFT walks back through a 50-slide history; RIGHT moves forward again. - Any string/boolean setting can be preset from adb extras. - docs/reverie-ios-watchos-prompt.md: build prompt for the iOS + watchOS app. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BvT3RjNpBmMUsyPqHq7fm
@@ -1,4 +1,7 @@
|
||||
# Immich Screensaver for Android TV
|
||||
# Reverie — Immich screensaver for Android TV
|
||||
|
||||
<img src="branding/reverie-logo.png" alt="Reverie" width="480">
|
||||
|
||||
|
||||
Ambient photo screensaver for Android TV / Google TV that plays photos and videos from your
|
||||
[Immich](https://immich.app) server, in the spirit of the Google Photos ambient mode:
|
||||
@@ -10,6 +13,8 @@ Ambient photo screensaver for Android TV / Google TV that plays photos and video
|
||||
- Works as the **system screensaver** (Settings → Screen saver) and as a normal app (start the slideshow manually).
|
||||
- Filter by albums, favorites, photos / videos.
|
||||
- **Set up from your phone**: the TV shows a QR code, the phone opens a form served by the TV on the LAN and sends the credentials; no typing with the remote.
|
||||
- **Picture frames**: gallery mat, polaroid (with a slight tilt) or rounded card, over a blurred backdrop.
|
||||
- LEFT on the remote goes back to the previous slide (50-slide history), RIGHT skips ahead.
|
||||
- Sign in with email + password once: the app creates a scoped API key (`asset.read`, `asset.view`, `album.read`, `user.read`) and never stores the password.
|
||||
|
||||
Tested against Immich 3.1. Requires Android 8.0+ (API 26).
|
||||
@@ -30,7 +35,7 @@ adb shell am start -n cloud.alexandrevazquez.immichtv/.ui.MainActivity \
|
||||
--es server https://immich.example.com --es api_key "$(pass show services/immich-api-key)"
|
||||
```
|
||||
|
||||
Then on the TV: open **Immich Screensaver** → *Set as system screensaver* (or Settings → System →
|
||||
Then on the TV: open **Reverie** → *Set as system screensaver* (or Settings → System →
|
||||
Screen saver) and pick it. *Start slideshow now* previews it; RIGHT / OK skips, BACK exits.
|
||||
|
||||
**Chromecast with Google TV** has no menu to choose a third-party screensaver (only Google's ambient
|
||||
@@ -67,6 +72,10 @@ export ANDROID_HOME=~/android-sdk
|
||||
| `GET /api/assets/{id}/video/playback` | transcoded video stream |
|
||||
| `POST /api/auth/login` + `POST /api/api-keys` | one-time sign-in that mints the device key |
|
||||
|
||||
## Branding
|
||||
|
||||
Name, mark, banner and palette live in `branding/` (`BRANDING.md`). Fonts: Playfair Display (dates, clock) and Manrope (labels), both OFL, bundled in `app/src/main/res/font`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `data/ImmichApi.kt` – REST client (OkHttp + org.json).
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "cloud.alexandrevazquez.immichtv"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "1.1.1"
|
||||
versionCode = 4
|
||||
versionName = "1.2.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -22,6 +22,8 @@ class Settings(context: Context) {
|
||||
val transition: String get() = prefs.getString(KEY_TRANSITION, "fade") ?: "fade"
|
||||
val pairPortraits: Boolean get() = prefs.getBoolean(KEY_PAIR_PORTRAITS, true)
|
||||
val scaling: String get() = prefs.getString(KEY_SCALING, "fill") ?: "fill"
|
||||
/** none | gallery | polaroid | rounded */
|
||||
val frame: String get() = prefs.getString(KEY_FRAME, "none") ?: "none"
|
||||
val showInfo: Boolean get() = prefs.getBoolean(KEY_SHOW_INFO, true)
|
||||
val showClock: Boolean get() = prefs.getBoolean(KEY_SHOW_CLOCK, true)
|
||||
|
||||
@@ -48,6 +50,7 @@ class Settings(context: Context) {
|
||||
const val KEY_TRANSITION = "transition"
|
||||
const val KEY_PAIR_PORTRAITS = "pair_portraits"
|
||||
const val KEY_SCALING = "scaling"
|
||||
const val KEY_FRAME = "frame"
|
||||
const val KEY_SHOW_INFO = "show_info"
|
||||
const val KEY_SHOW_CLOCK = "show_clock"
|
||||
const val KEY_MUTE_VIDEOS = "mute_videos"
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.widget.Toast
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import cloud.alexandrevazquez.immichtv.App
|
||||
import cloud.alexandrevazquez.immichtv.R
|
||||
import cloud.alexandrevazquez.immichtv.data.Settings
|
||||
|
||||
/**
|
||||
* Settings screen. Also accepts `server` / `api_key` string extras so the app can be
|
||||
@@ -13,6 +14,17 @@ import cloud.alexandrevazquez.immichtv.R
|
||||
*/
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
private companion object {
|
||||
val ADB_STRING_PREFS = listOf(
|
||||
Settings.KEY_CONTENT_TYPE, Settings.KEY_QUALITY, Settings.KEY_INTERVAL, Settings.KEY_MOTION,
|
||||
Settings.KEY_TRANSITION, Settings.KEY_SCALING, Settings.KEY_FRAME, Settings.KEY_MAX_VIDEO,
|
||||
)
|
||||
val ADB_BOOL_PREFS = listOf(
|
||||
Settings.KEY_FAVORITES_ONLY, Settings.KEY_PAIR_PORTRAITS, Settings.KEY_SHOW_INFO,
|
||||
Settings.KEY_SHOW_CLOCK, Settings.KEY_MUTE_VIDEOS,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
@@ -23,6 +35,12 @@ class MainActivity : FragmentActivity() {
|
||||
App.from(this).settings.setServer(server, apiKey)
|
||||
Toast.makeText(this, R.string.toast_configured_from_intent, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
// Any other string setting can be set the same way, e.g. --es frame polaroid --es transition slide
|
||||
val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val editor = prefs.edit()
|
||||
for (key in ADB_STRING_PREFS) intent?.getStringExtra(key)?.let { editor.putString(key, it) }
|
||||
for (key in ADB_BOOL_PREFS) intent?.getStringExtra(key)?.let { editor.putBoolean(key, it == "true" || it == "1") }
|
||||
editor.apply()
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
|
||||
@@ -6,7 +6,7 @@ import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
/** Manual preview of the screensaver. RIGHT skips to the next slide, BACK exits. */
|
||||
/** Manual slideshow. RIGHT = next, LEFT = previous, BACK exits. */
|
||||
class SlideshowActivity : ComponentActivity() {
|
||||
|
||||
private lateinit var view: SlideshowView
|
||||
@@ -47,6 +47,9 @@ class SlideshowActivity : ComponentActivity() {
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
|
||||
view.next(); true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
view.previous(); true
|
||||
}
|
||||
else -> super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import android.graphics.Color
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
@@ -19,6 +21,7 @@ import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
@@ -78,14 +81,26 @@ class SlideshowView @JvmOverloads constructor(
|
||||
private var source: SlideSource? = null
|
||||
|
||||
private val stage = FrameLayout(context)
|
||||
private val infoText = TextView(context)
|
||||
private val scrim = View(context)
|
||||
private val infoBox = LinearLayout(context)
|
||||
private val dateText = TextView(context)
|
||||
private val placeText = TextView(context)
|
||||
private val clockText = TextView(context)
|
||||
private val statusText = TextView(context)
|
||||
|
||||
private val serif: Typeface? = ResourcesCompat.getFont(context, R.font.playfair_display)
|
||||
private val sans: Typeface? = ResourcesCompat.getFont(context, R.font.manrope)
|
||||
|
||||
private var scope: CoroutineScope? = null
|
||||
private var loopJob: Job? = null
|
||||
private var currentLayer: Layer? = null
|
||||
private val advance = Channel<Unit>(Channel.CONFLATED)
|
||||
private val commands = Channel<Command>(Channel.CONFLATED)
|
||||
|
||||
/** Slides already shown, so LEFT can walk back through exactly what was on screen. */
|
||||
private val history = ArrayList<Slide>()
|
||||
private var historyIndex = -1
|
||||
|
||||
private enum class Command { NEXT, PREV }
|
||||
|
||||
private val screenW: Int get() = if (width > 0) width else resources.displayMetrics.widthPixels
|
||||
private val screenH: Int get() = if (height > 0) height else resources.displayMetrics.heightPixels
|
||||
@@ -94,22 +109,49 @@ class SlideshowView @JvmOverloads constructor(
|
||||
setBackgroundColor(Color.BLACK)
|
||||
addView(stage, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
|
||||
val pad = dp(40)
|
||||
infoText.apply {
|
||||
val pad = dp(44)
|
||||
scrim.background = GradientDrawable(
|
||||
GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
intArrayOf(Color.TRANSPARENT, Color.argb(115, 0, 0, 0))
|
||||
)
|
||||
scrim.alpha = 0f
|
||||
addView(scrim, LayoutParams(LayoutParams.MATCH_PARENT, dp(220), Gravity.BOTTOM))
|
||||
|
||||
dateText.apply {
|
||||
setTextColor(Color.WHITE)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f)
|
||||
setShadowLayer(8f, 0f, 2f, Color.argb(200, 0, 0, 0))
|
||||
alpha = 0f
|
||||
setLineSpacing(0f, 1.15f)
|
||||
typeface = serif
|
||||
fontVariationSettings = "'wght' 500"
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 30f)
|
||||
setShadowLayer(12f, 0f, 2f, Color.argb(160, 0, 0, 0))
|
||||
includeFontPadding = false
|
||||
}
|
||||
addView(infoText, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
|
||||
placeText.apply {
|
||||
setTextColor(Color.argb(225, 255, 255, 255))
|
||||
typeface = sans
|
||||
fontVariationSettings = "'wght' 600"
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
|
||||
letterSpacing = 0.12f
|
||||
isAllCaps = true
|
||||
setShadowLayer(10f, 0f, 2f, Color.argb(160, 0, 0, 0))
|
||||
includeFontPadding = false
|
||||
setPadding(0, dp(8), 0, 0)
|
||||
}
|
||||
infoBox.apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
alpha = 0f
|
||||
addView(dateText)
|
||||
addView(placeText)
|
||||
}
|
||||
addView(infoBox, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
|
||||
setMargins(pad, pad, pad, pad)
|
||||
})
|
||||
clockText.apply {
|
||||
setTextColor(Color.WHITE)
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 44f)
|
||||
typeface = Typeface.create("sans-serif-light", Typeface.NORMAL)
|
||||
setShadowLayer(10f, 0f, 2f, Color.argb(200, 0, 0, 0))
|
||||
typeface = serif
|
||||
fontVariationSettings = "'wght' 400"
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 58f)
|
||||
setShadowLayer(14f, 0f, 2f, Color.argb(160, 0, 0, 0))
|
||||
includeFontPadding = false
|
||||
visibility = GONE
|
||||
}
|
||||
addView(clockText, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.END).apply {
|
||||
@@ -142,6 +184,9 @@ class SlideshowView @JvmOverloads constructor(
|
||||
source = SlideSource(api, settings) { msg -> post { if (msg == null) hideStatus() else showStatus(msg) } }
|
||||
|
||||
clockText.visibility = if (settings.showClock) VISIBLE else GONE
|
||||
scrim.alpha = if (settings.showInfo || settings.showClock) 1f else 0f
|
||||
history.clear()
|
||||
historyIndex = -1
|
||||
if (settings.showClock) scope.launch { clockLoop() }
|
||||
showStatus(context.getString(R.string.status_loading))
|
||||
loopJob = scope.launch { slideshowLoop() }
|
||||
@@ -161,21 +206,52 @@ class SlideshowView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun next() {
|
||||
advance.trySend(Unit)
|
||||
commands.trySend(Command.NEXT)
|
||||
}
|
||||
|
||||
fun previous() {
|
||||
commands.trySend(Command.PREV)
|
||||
}
|
||||
|
||||
// --- main loop -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun slideshowLoop() {
|
||||
val scope = scope ?: return
|
||||
var prepared = prepareNext()
|
||||
var upcoming = scope.async { prepareNext() }
|
||||
var command = Command.NEXT
|
||||
while (scope.isActive) {
|
||||
val layer = prepared
|
||||
var layer: Layer? = null
|
||||
var backwards = false
|
||||
if (command == Command.PREV && historyIndex > 0) {
|
||||
layer = prepareSlide(history[historyIndex - 1])
|
||||
if (layer != null) { historyIndex--; backwards = true }
|
||||
} else if (command == Command.NEXT && historyIndex < history.size - 1) {
|
||||
layer = prepareSlide(history[historyIndex + 1])
|
||||
if (layer != null) historyIndex++
|
||||
}
|
||||
if (layer == null) {
|
||||
layer = upcoming.await()
|
||||
upcoming = scope.async { prepareNext() }
|
||||
history.add(layer.slide)
|
||||
if (history.size > HISTORY_LIMIT) history.removeAt(0)
|
||||
historyIndex = history.size - 1
|
||||
}
|
||||
hideStatus()
|
||||
transitionTo(layer)
|
||||
val nextJob = scope.async { prepareNext() }
|
||||
waitForSlide(layer)
|
||||
prepared = nextJob.await()
|
||||
transitionTo(layer, backwards)
|
||||
command = waitForSlide(layer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-loads a slide from history (images come from Coil's memory cache). Null when it cannot be loaded. */
|
||||
private suspend fun prepareSlide(slide: Slide): Layer? {
|
||||
val layer = Layer(slide)
|
||||
return try {
|
||||
withTimeout(20_000) { layer.prepare() }
|
||||
layer
|
||||
} catch (e: CancellationException) {
|
||||
layer.release(); throw e
|
||||
} catch (e: Exception) {
|
||||
layer.release(); null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,22 +279,23 @@ class SlideshowView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForSlide(layer: Layer) {
|
||||
if (layer.slide is Slide.Single && layer.slide.asset.isVideo) {
|
||||
/** Waits until the slide is over (timer, video end or a remote key) and says where to go next. */
|
||||
private suspend fun waitForSlide(layer: Layer): Command {
|
||||
return if (layer.slide is Slide.Single && layer.slide.asset.isVideo) {
|
||||
val cap = settings.maxVideoMs
|
||||
val limit = if (cap > 0) cap else Long.MAX_VALUE
|
||||
withTimeoutOrNull(limit) {
|
||||
select<Unit> {
|
||||
advance.onReceive { }
|
||||
layer.videoEnded.onAwait { }
|
||||
select<Command> {
|
||||
commands.onReceive { it }
|
||||
layer.videoEnded.onAwait { Command.NEXT }
|
||||
}
|
||||
}
|
||||
} ?: Command.NEXT
|
||||
} else {
|
||||
withTimeoutOrNull(settings.intervalMs) { advance.receive() }
|
||||
withTimeoutOrNull(settings.intervalMs) { commands.receive() } ?: Command.NEXT
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun transitionTo(layer: Layer) {
|
||||
private suspend fun transitionTo(layer: Layer, backwards: Boolean = false) {
|
||||
val old = currentLayer
|
||||
currentLayer = layer
|
||||
val root = layer.root
|
||||
@@ -236,9 +313,10 @@ class SlideshowView @JvmOverloads constructor(
|
||||
root.animate().alpha(1f).setDuration(duration).setInterpolator(LinearInterpolator()).start()
|
||||
}
|
||||
"slide" -> {
|
||||
root.translationX = screenW.toFloat()
|
||||
val dir = if (backwards) -1f else 1f
|
||||
root.translationX = dir * screenW
|
||||
root.animate().translationX(0f).setDuration(duration).start()
|
||||
old?.root?.animate()?.translationX(-screenW.toFloat())?.setDuration(duration)?.start()
|
||||
old?.root?.animate()?.translationX(-dir * screenW)?.setDuration(duration)?.start()
|
||||
}
|
||||
"zoom" -> {
|
||||
root.alpha = 0f
|
||||
@@ -259,24 +337,27 @@ class SlideshowView @JvmOverloads constructor(
|
||||
|
||||
private fun updateInfo(slide: Slide) {
|
||||
if (!settings.showInfo) return
|
||||
val text = slide.assets.map { describe(it) }.filter { it.isNotBlank() }.distinct().joinToString("\n")
|
||||
infoText.animate().alpha(0f).setDuration(400).withEndAction {
|
||||
infoText.text = text
|
||||
if (text.isNotBlank()) infoText.animate().alpha(0.95f).setDuration(600).start()
|
||||
val dates = slide.assets.mapNotNull { formatDate(it) }.distinct().joinToString(" · ")
|
||||
val places = slide.assets.mapNotNull { formatPlace(it) }.distinct().joinToString(" · ")
|
||||
infoBox.animate().alpha(0f).setDuration(400).withEndAction {
|
||||
dateText.text = dates
|
||||
dateText.visibility = if (dates.isBlank()) GONE else VISIBLE
|
||||
placeText.text = places
|
||||
placeText.visibility = if (places.isBlank()) GONE else VISIBLE
|
||||
if (dates.isNotBlank() || places.isNotBlank()) infoBox.animate().alpha(1f).setDuration(700).start()
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun describe(asset: Asset): String {
|
||||
val date = asset.takenAt?.let { raw ->
|
||||
runCatching {
|
||||
LocalDateTime.parse(raw.removeSuffix("Z").substringBefore("+").take(23))
|
||||
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG))
|
||||
}.getOrNull()
|
||||
}
|
||||
val place = listOfNotNull(asset.city, asset.country).joinToString(", ")
|
||||
return listOfNotNull(date, place.ifBlank { null }).joinToString(" · ")
|
||||
private fun formatDate(asset: Asset): String? = asset.takenAt?.let { raw ->
|
||||
runCatching {
|
||||
LocalDateTime.parse(raw.removeSuffix("Z").substringBefore("+").take(23))
|
||||
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG))
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun formatPlace(asset: Asset): String? =
|
||||
listOfNotNull(asset.city, asset.country).joinToString(", ").ifBlank { null }
|
||||
|
||||
private suspend fun clockLoop() {
|
||||
val fmt = DateTimeFormatter.ofPattern("HH:mm")
|
||||
while (true) {
|
||||
@@ -315,13 +396,13 @@ class SlideshowView @JvmOverloads constructor(
|
||||
|
||||
private suspend fun prepareImage(asset: Asset) {
|
||||
// A lone portrait photo is always fitted over a blurred backdrop; cropping it would lose most of it.
|
||||
val fit = settings.scaling == "fit" || asset.isPortrait
|
||||
val fit = settings.scaling == "fit" || asset.isPortrait || settings.frame != "none"
|
||||
val cell = buildCell(asset, fit, screenW, screenH)
|
||||
root.addView(cell, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
|
||||
private suspend fun preparePair(left: Asset, right: Asset) {
|
||||
val fit = settings.scaling == "fit"
|
||||
val fit = settings.scaling == "fit" || settings.frame != "none"
|
||||
val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL }
|
||||
val gap = dp(3)
|
||||
val cellW = (screenW - gap) / 2
|
||||
@@ -354,6 +435,11 @@ class SlideshowView @JvmOverloads constructor(
|
||||
}
|
||||
container.addView(bg, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
val frame = settings.frame
|
||||
if (frame != "none" && asset.width > 0 && asset.height > 0) {
|
||||
container.addView(buildFrame(asset, frame, fgJob.await(), cellW, cellH))
|
||||
return container
|
||||
}
|
||||
val fg = ImageView(context).apply {
|
||||
scaleType = if (fit) ImageView.ScaleType.FIT_CENTER else ImageView.ScaleType.CENTER_CROP
|
||||
setImageDrawable(fgJob.await())
|
||||
@@ -363,6 +449,47 @@ class SlideshowView @JvmOverloads constructor(
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the photo in a decorative frame sized to the photo's aspect ratio:
|
||||
* gallery = thin off-white mat, polaroid = thick bottom border and a slight tilt, rounded = floating card.
|
||||
*/
|
||||
private fun buildFrame(asset: Asset, style: String, drawable: android.graphics.drawable.Drawable, cellW: Int, cellH: Int): View {
|
||||
val unit = minOf(cellW, cellH)
|
||||
val margin = (unit * 0.08f).toInt()
|
||||
val border = when (style) {
|
||||
"gallery" -> (unit * 0.022f).toInt()
|
||||
"polaroid" -> (unit * 0.035f).toInt()
|
||||
else -> 0
|
||||
}
|
||||
val bottomExtra = if (style == "polaroid") (border * 3.4f).toInt() else 0
|
||||
val availW = cellW - 2 * margin - 2 * border
|
||||
val availH = cellH - 2 * margin - 2 * border - bottomExtra
|
||||
val scale = minOf(availW.toFloat() / asset.width, availH.toFloat() / asset.height)
|
||||
val imgW = (asset.width * scale).toInt()
|
||||
val imgH = (asset.height * scale).toInt()
|
||||
|
||||
val paper = Color.parseColor("#FAF7F2")
|
||||
val frameView = FrameLayout(context).apply {
|
||||
clipChildren = true
|
||||
elevation = dp(18).toFloat()
|
||||
background = if (style == "rounded") {
|
||||
GradientDrawable().apply { cornerRadius = dp(22).toFloat(); setColor(Color.BLACK) }
|
||||
} else ColorDrawable(paper)
|
||||
if (style == "rounded") clipToOutline = true
|
||||
if (style == "polaroid") rotation = Random.nextFloat() * 7f - 3.5f
|
||||
}
|
||||
val fg = ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
setImageDrawable(drawable)
|
||||
}
|
||||
frameView.addView(fg, LayoutParams(imgW, imgH).apply {
|
||||
setMargins(border, border, border, border + bottomExtra)
|
||||
})
|
||||
frameView.layoutParams = LayoutParams(imgW + 2 * border, imgH + 2 * border + bottomExtra, Gravity.CENTER)
|
||||
motionViews += fg
|
||||
return frameView
|
||||
}
|
||||
|
||||
private suspend fun load(url: String, w: Int, h: Int): android.graphics.drawable.Drawable {
|
||||
val loader = imageLoader ?: throw IllegalStateException("stopped")
|
||||
val req = ImageRequest.Builder(context)
|
||||
@@ -493,6 +620,7 @@ class SlideshowView @JvmOverloads constructor(
|
||||
|
||||
companion object {
|
||||
private const val TRANSITION_MS = 1_600L
|
||||
private const val HISTORY_LIMIT = 50
|
||||
private val TRANSITIONS = listOf("fade", "slide", "zoom")
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="96"
|
||||
android:endY="104"
|
||||
android:startX="13"
|
||||
android:startY="10"
|
||||
android:type="linear">
|
||||
<item android:color="#FF151A3D" android:offset="0"/>
|
||||
<item android:color="#FF3D254F" android:offset="0.52"/>
|
||||
<item android:color="#FFF0A66F" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -4,22 +4,101 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- TV frame -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M26,34 h56 a4,4 0 0 1 4,4 v30 a4,4 0 0 1 -4,4 h-56 a4,4 0 0 1 -4,-4 v-30 a4,4 0 0 1 4,-4 z" />
|
||||
<!-- Landscape inside -->
|
||||
android:fillAlpha="0.52"
|
||||
android:fillColor="#F6B779"
|
||||
android:pathData="M69,33m-15,0a15,15 0,1 0,30 0a15,15 0,1 0,-30 0"/>
|
||||
<group
|
||||
android:pivotX="58"
|
||||
android:pivotY="54"
|
||||
android:rotation="8">
|
||||
<path
|
||||
android:fillAlpha="0.30"
|
||||
android:fillColor="#181D3C"
|
||||
android:pathData="M38,27h34a6,6 0,0 1,6 6v43a6,6 0,0 1,-6 6h-34a6,6 0,0 1,-6 -6v-43a6,6 0,0 1,6 -6z"/>
|
||||
<path android:pathData="M36,25h34a6,6 0,0 1,6 6v43a6,6 0,0 1,-6 6h-34a6,6 0,0 1,-6 -6v-43a6,6 0,0 1,6 -6z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="76"
|
||||
android:endY="80"
|
||||
android:startX="30"
|
||||
android:startY="25"
|
||||
android:type="linear">
|
||||
<item android:color="#FFFFF2DA" android:offset="0"/>
|
||||
<item android:color="#FFF4C08A" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M40,30h27a4,4 0,0 1,4 4v36a4,4 0,0 1,-4 4h-27a4,4 0,0 1,-4 -4v-36a4,4 0,0 1,4 -4z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="71"
|
||||
android:endY="74"
|
||||
android:startX="36"
|
||||
android:startY="30"
|
||||
android:type="linear">
|
||||
<item android:color="#FF28345F" android:offset="0"/>
|
||||
<item android:color="#FF6F3D61" android:offset="0.58"/>
|
||||
<item android:color="#FFE98968" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillAlpha="0.28"
|
||||
android:fillColor="#151A3D"
|
||||
android:pathData="M36,66C43,59 49,58 55,64C60,69 64,70 71,65V74H36V66Z"/>
|
||||
<path
|
||||
android:fillColor="#FFE2A0"
|
||||
android:pathData="M64,38m-5,0a5,5 0,1 0,10 0a5,5 0,1 0,-10 0"/>
|
||||
</group>
|
||||
<group
|
||||
android:pivotX="47"
|
||||
android:pivotY="61"
|
||||
android:rotation="-8">
|
||||
<path
|
||||
android:fillAlpha="0.28"
|
||||
android:fillColor="#111735"
|
||||
android:pathData="M27,36h31a6,6 0,0 1,6 6v39a6,6 0,0 1,-6 6h-31a6,6 0,0 1,-6 -6v-39a6,6 0,0 1,6 -6z"/>
|
||||
<path android:pathData="M25,34h31a6,6 0,0 1,6 6v39a6,6 0,0 1,-6 6h-31a6,6 0,0 1,-6 -6v-39a6,6 0,0 1,6 -6z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="62"
|
||||
android:endY="85"
|
||||
android:startX="19"
|
||||
android:startY="34"
|
||||
android:type="linear">
|
||||
<item android:color="#FFF9D6A5" android:offset="0"/>
|
||||
<item android:color="#FFFFF7E8" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M29,39h23a4,4 0,0 1,4 4v32a4,4 0,0 1,-4 4h-23a4,4 0,0 1,-4 -4v-32a4,4 0,0 1,4 -4z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="56"
|
||||
android:endY="79"
|
||||
android:startX="25"
|
||||
android:startY="39"
|
||||
android:type="linear">
|
||||
<item android:color="#FF20294F" android:offset="0"/>
|
||||
<item android:color="#FF594064" android:offset="0.62"/>
|
||||
<item android:color="#FFD7896B" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillAlpha="0.30"
|
||||
android:fillColor="#151A3D"
|
||||
android:pathData="M25,71C32,63 38,64 43,69C48,74 52,74 56,70V79H25V71Z"/>
|
||||
<path
|
||||
android:fillColor="#FFD99A"
|
||||
android:pathData="M49,47m-4,0a4,4 0,1 0,8 0a4,4 0,1 0,-8 0"/>
|
||||
</group>
|
||||
<path
|
||||
android:fillColor="#FF4250AF"
|
||||
android:pathData="M28,36 h52 v30 h-52 z" />
|
||||
<path
|
||||
android:fillColor="#FFF4B942"
|
||||
android:pathData="M64,44 m-5,0 a5,5 0 1 0 10,0 a5,5 0 1 0 -10,0" />
|
||||
<path
|
||||
android:fillColor="#FF2E8B57"
|
||||
android:pathData="M28,66 L44,50 L54,60 L62,54 L80,66 z" />
|
||||
<!-- stand -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M48,74 h12 v4 h-12 z M40,78 h28 v3 h-28 z" />
|
||||
android:fillAlpha="0"
|
||||
android:pathData="M32,89C44,94 65,94 76,88"
|
||||
android:strokeAlpha="0.20"
|
||||
android:strokeColor="#FFF2DA"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3"/>
|
||||
</vector>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Immich Screensaver</string>
|
||||
<string name="app_name">Reverie</string>
|
||||
|
||||
<string name="cat_server">Servidor</string>
|
||||
<string name="pref_server_url">URL del servidor Immich</string>
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<string name="cat_screensaver">Salvapantallas</string>
|
||||
<string name="pref_preview">Iniciar presentación ahora</string>
|
||||
<string name="pref_preview_summary">Derecha = siguiente foto, Atrás = salir</string>
|
||||
<string name="pref_preview_summary">Derecha = siguiente, Izquierda = anterior, Atrás = salir</string>
|
||||
<string name="pref_dream_settings">Usar como salvapantallas del sistema</string>
|
||||
<string name="pref_dream_settings_summary">Abre Ajustes de Android → Salvapantallas</string>
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
<string name="toast_connection_failed">Error de conexión: %1$s</string>
|
||||
<string name="toast_enter_server_first">Introduce primero la URL del servidor</string>
|
||||
<string name="toast_login_ok">Sesión iniciada, API key creada</string>
|
||||
<string name="toast_dream_settings_manual">Abre Ajustes → Sistema → Salvapantallas y elige Immich Screensaver</string>
|
||||
<string name="toast_dream_settings_manual">Abre Ajustes → Sistema → Salvapantallas y elige Reverie</string>
|
||||
<string name="toast_configured_from_intent">Servidor configurado</string>
|
||||
|
||||
<string name="status_not_configured">Immich no está configurado.\nAbre la app Immich Screensaver para configurar el servidor.</string>
|
||||
<string name="status_not_configured">Immich no está configurado.\nAbre la app Reverie para configurar el servidor.</string>
|
||||
<string name="status_loading">Cargando fotos…</string>
|
||||
<string name="pref_pair">Configurar desde el móvil (código QR)</string>
|
||||
<string name="pref_pair_summary">Escanea un código y escribe las credenciales en el móvil</string>
|
||||
@@ -83,4 +83,9 @@
|
||||
<string name="pair_web_success">¡Listo! Conectado como %1$s.</string>
|
||||
<string name="pair_web_done_hint">Puedes cerrar esta página. La TV ya está configurada.</string>
|
||||
<string name="pair_web_privacy">Las credenciales van directamente a tu TV por la red local. La TV inicia sesión en Immich y solo guarda una API key.</string>
|
||||
<string name="pref_frame">Marco de las fotos</string>
|
||||
<string name="opt_frame_none">Ninguno</string>
|
||||
<string name="opt_frame_gallery">Paspartú de galería</string>
|
||||
<string name="opt_frame_polaroid">Polaroid</string>
|
||||
<string name="opt_frame_rounded">Tarjeta redondeada</string>
|
||||
</resources>
|
||||
|
||||
@@ -54,6 +54,16 @@
|
||||
<item>fill</item><item>fit</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="frame_entries">
|
||||
<item>@string/opt_frame_none</item>
|
||||
<item>@string/opt_frame_gallery</item>
|
||||
<item>@string/opt_frame_polaroid</item>
|
||||
<item>@string/opt_frame_rounded</item>
|
||||
</string-array>
|
||||
<string-array name="frame_values" translatable="false">
|
||||
<item>none</item><item>gallery</item><item>polaroid</item><item>rounded</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="max_video_entries">
|
||||
<item>15 s</item><item>30 s</item><item>1 min</item><item>2 min</item><item>@string/opt_whole_video</item>
|
||||
</string-array>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Immich Screensaver</string>
|
||||
<string name="app_name">Reverie</string>
|
||||
|
||||
<string name="cat_server">Server</string>
|
||||
<string name="pref_server_url">Immich server URL</string>
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<string name="cat_screensaver">Screensaver</string>
|
||||
<string name="pref_preview">Start slideshow now</string>
|
||||
<string name="pref_preview_summary">Right = next photo, Back = exit</string>
|
||||
<string name="pref_preview_summary">Right = next photo, Left = previous, Back = exit</string>
|
||||
<string name="pref_dream_settings">Set as system screensaver</string>
|
||||
<string name="pref_dream_settings_summary">Opens Android settings → Screen saver</string>
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
<string name="toast_connection_failed">Connection failed: %1$s</string>
|
||||
<string name="toast_enter_server_first">Enter the server URL first</string>
|
||||
<string name="toast_login_ok">Signed in, API key created</string>
|
||||
<string name="toast_dream_settings_manual">Open Settings → System → Screen saver and choose Immich Screensaver</string>
|
||||
<string name="toast_dream_settings_manual">Open Settings → System → Screen saver and choose Reverie</string>
|
||||
<string name="toast_configured_from_intent">Server configured</string>
|
||||
|
||||
<string name="status_not_configured">Immich is not configured.\nOpen the Immich Screensaver app to set up your server.</string>
|
||||
<string name="status_not_configured">Immich is not configured.\nOpen the Reverie app to set up your server.</string>
|
||||
<string name="status_loading">Loading photos…</string>
|
||||
<string name="pref_pair">Set up from your phone (QR code)</string>
|
||||
<string name="pref_pair_summary">Scan a code and type the credentials on the phone</string>
|
||||
@@ -83,4 +83,9 @@
|
||||
<string name="pair_web_success">Done! Connected as %1$s.</string>
|
||||
<string name="pair_web_done_hint">You can close this page. The TV is now configured.</string>
|
||||
<string name="pair_web_privacy">The credentials go straight to your TV over the local network. The TV signs in to Immich and keeps only an API key.</string>
|
||||
<string name="pref_frame">Picture frame</string>
|
||||
<string name="opt_frame_none">None</string>
|
||||
<string name="opt_frame_gallery">Gallery mat</string>
|
||||
<string name="opt_frame_polaroid">Polaroid</string>
|
||||
<string name="opt_frame_rounded">Rounded card</string>
|
||||
</resources>
|
||||
|
||||
@@ -86,6 +86,13 @@
|
||||
android:entryValues="@array/scaling_values"
|
||||
android:defaultValue="fill"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<ListPreference
|
||||
android:key="frame"
|
||||
android:title="@string/pref_frame"
|
||||
android:entries="@array/frame_entries"
|
||||
android:entryValues="@array/frame_values"
|
||||
android:defaultValue="none"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
<SwitchPreference
|
||||
android:key="show_info"
|
||||
android:title="@string/pref_show_info"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Reverie Brand Identity
|
||||
|
||||
Reverie is a quiet daydream for the largest screen in the home: family memories drifting through dusk light, moving slowly enough to feel ambient but warmly enough to feel alive. The identity avoids literal camera shutters, mountains, and generic gallery symbols; instead, the mark uses two overlapping picture frames, soft rotation, and a small glowing sun or moon to suggest paired portrait photos, crossfades, Ken Burns motion, and the calm ritual of seeing old moments return.
|
||||
|
||||
The mood is premium, nostalgic, and domestic without becoming sentimental. Deep indigo gives the app the presence of a TV screensaver at rest, aubergine adds evening warmth, and apricot light carries the human feeling of photographs on a wall. The icon should feel like a tiny framed memory rather than a utility app.
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Name | Hex | Usage |
|
||||
| --- | --- | --- |
|
||||
| Midnight Indigo | `#151A3D` | Primary app background, icon depth, Android adaptive background start. |
|
||||
| Dusk Aubergine | `#3D254F` | Gradient midpoint, dark UI accents, atmospheric warmth. |
|
||||
| Apricot Glow | `#F0A66F` | Warm gradient end, launcher emphasis, memory-light accent. |
|
||||
| Golden Haze | `#F4C08A` | Frame edges, secondary typography, gentle highlight states. |
|
||||
| Moonlit Ivory | `#FFF2DA` | Wordmark, primary text on dark backgrounds, frame highlights. |
|
||||
| Ember Rose | `#E98968` | Photo-interior warmth and subtle sunset color. |
|
||||
|
||||
## Typography
|
||||
|
||||
Use **Playfair Display** for the Reverie wordmark, display moments, dates, and elegant location overlays. Use **Manrope** for UI labels, settings, helper text, and short metadata. Playfair should feel cinematic and memory-led; Manrope should keep the product clear, modern, and easy to scan from the couch.
|
||||
|
||||
## Tagline
|
||||
|
||||
English: **A daydream for family memories.**
|
||||
|
||||
Spanish: **Un ensueño para los recuerdos familiares.**
|
||||
|
||||
## Logo Usage
|
||||
|
||||
Use the mark alone when space is square or compact: app icon, Android settings, TV app grid, favicon-like surfaces, and loading states. Use the horizontal lockup when there is enough width for the wordmark to breathe, especially in documentation, splash artwork, app listings, or wide TV surfaces. Keep the mark on dark, quiet backgrounds or its own filled icon tile. Do not place it over busy photographs, add camera-shutter shapes, recolor the frames into bright primaries, stretch the icon, or crop into the central overlapping frames.
|
||||
|
||||
## TV Surfaces
|
||||
|
||||
On the TV settings screen, the mark should appear as a filled rounded-square icon with the overlapping frames centered inside the adaptive safe zone, reading clearly at small sizes beside the app name. On the launcher, use the `reverie-banner.svg` composition: dark dusk field, mark on the left, and the Playfair wordmark on the right. The banner should feel legible from across the room, with the warm apricot accent acting as a soft memory glow rather than a loud call to action.
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="96"
|
||||
android:endY="104"
|
||||
android:startX="13"
|
||||
android:startY="10"
|
||||
android:type="linear">
|
||||
<item android:color="#FF151A3D" android:offset="0"/>
|
||||
<item android:color="#FF3D254F" android:offset="0.52"/>
|
||||
<item android:color="#FFF0A66F" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillAlpha="0.52"
|
||||
android:fillColor="#F6B779"
|
||||
android:pathData="M69,33m-15,0a15,15 0,1 0,30 0a15,15 0,1 0,-30 0"/>
|
||||
<group
|
||||
android:pivotX="58"
|
||||
android:pivotY="54"
|
||||
android:rotation="8">
|
||||
<path
|
||||
android:fillAlpha="0.30"
|
||||
android:fillColor="#181D3C"
|
||||
android:pathData="M38,27h34a6,6 0,0 1,6 6v43a6,6 0,0 1,-6 6h-34a6,6 0,0 1,-6 -6v-43a6,6 0,0 1,6 -6z"/>
|
||||
<path android:pathData="M36,25h34a6,6 0,0 1,6 6v43a6,6 0,0 1,-6 6h-34a6,6 0,0 1,-6 -6v-43a6,6 0,0 1,6 -6z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="76"
|
||||
android:endY="80"
|
||||
android:startX="30"
|
||||
android:startY="25"
|
||||
android:type="linear">
|
||||
<item android:color="#FFFFF2DA" android:offset="0"/>
|
||||
<item android:color="#FFF4C08A" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M40,30h27a4,4 0,0 1,4 4v36a4,4 0,0 1,-4 4h-27a4,4 0,0 1,-4 -4v-36a4,4 0,0 1,4 -4z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="71"
|
||||
android:endY="74"
|
||||
android:startX="36"
|
||||
android:startY="30"
|
||||
android:type="linear">
|
||||
<item android:color="#FF28345F" android:offset="0"/>
|
||||
<item android:color="#FF6F3D61" android:offset="0.58"/>
|
||||
<item android:color="#FFE98968" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillAlpha="0.28"
|
||||
android:fillColor="#151A3D"
|
||||
android:pathData="M36,66C43,59 49,58 55,64C60,69 64,70 71,65V74H36V66Z"/>
|
||||
<path
|
||||
android:fillColor="#FFE2A0"
|
||||
android:pathData="M64,38m-5,0a5,5 0,1 0,10 0a5,5 0,1 0,-10 0"/>
|
||||
</group>
|
||||
<group
|
||||
android:pivotX="47"
|
||||
android:pivotY="61"
|
||||
android:rotation="-8">
|
||||
<path
|
||||
android:fillAlpha="0.28"
|
||||
android:fillColor="#111735"
|
||||
android:pathData="M27,36h31a6,6 0,0 1,6 6v39a6,6 0,0 1,-6 6h-31a6,6 0,0 1,-6 -6v-39a6,6 0,0 1,6 -6z"/>
|
||||
<path android:pathData="M25,34h31a6,6 0,0 1,6 6v39a6,6 0,0 1,-6 6h-31a6,6 0,0 1,-6 -6v-39a6,6 0,0 1,6 -6z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="62"
|
||||
android:endY="85"
|
||||
android:startX="19"
|
||||
android:startY="34"
|
||||
android:type="linear">
|
||||
<item android:color="#FFF9D6A5" android:offset="0"/>
|
||||
<item android:color="#FFFFF7E8" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M29,39h23a4,4 0,0 1,4 4v32a4,4 0,0 1,-4 4h-23a4,4 0,0 1,-4 -4v-32a4,4 0,0 1,4 -4z">
|
||||
<aapt:attr xmlns:aapt="http://schemas.android.com/aapt" name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="56"
|
||||
android:endY="79"
|
||||
android:startX="25"
|
||||
android:startY="39"
|
||||
android:type="linear">
|
||||
<item android:color="#FF20294F" android:offset="0"/>
|
||||
<item android:color="#FF594064" android:offset="0.62"/>
|
||||
<item android:color="#FFD7896B" android:offset="1"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillAlpha="0.30"
|
||||
android:fillColor="#151A3D"
|
||||
android:pathData="M25,71C32,63 38,64 43,69C48,74 52,74 56,70V79H25V71Z"/>
|
||||
<path
|
||||
android:fillColor="#FFD99A"
|
||||
android:pathData="M49,47m-4,0a4,4 0,1 0,8 0a4,4 0,1 0,-8 0"/>
|
||||
</group>
|
||||
<path
|
||||
android:fillAlpha="0"
|
||||
android:pathData="M32,89C44,94 65,94 76,88"
|
||||
android:strokeAlpha="0.20"
|
||||
android:strokeColor="#FFF2DA"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,65 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" width="320" height="180" role="img" aria-label="Reverie Android TV banner">
|
||||
<defs>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Playfair Display";
|
||||
src: url("../app/src/main/res/font/playfair_display.ttf") format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Manrope";
|
||||
src: url("../app/src/main/res/font/manrope.ttf") format("truetype");
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="bannerBg" x1="0" y1="0" x2="320" y2="180" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#10142F"/>
|
||||
<stop offset="0.58" stop-color="#241B3F"/>
|
||||
<stop offset="1" stop-color="#5A324E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="markBg" x1="26" y1="28" x2="128" y2="142" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#151A3D"/>
|
||||
<stop offset="0.52" stop-color="#3D254F"/>
|
||||
<stop offset="1" stop-color="#F0A66F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameA" x1="50" y1="52" x2="112" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFF2DA"/>
|
||||
<stop offset="1" stop-color="#F4C08A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameB" x1="42" y1="64" x2="101" y2="128" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#F9D6A5"/>
|
||||
<stop offset="1" stop-color="#FFF7E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="photoA" x1="58" y1="55" x2="105" y2="111" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#28345F"/>
|
||||
<stop offset="0.58" stop-color="#6F3D61"/>
|
||||
<stop offset="1" stop-color="#E98968"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="photoB" x1="52" y1="71" x2="96" y2="118" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20294F"/>
|
||||
<stop offset="0.62" stop-color="#594064"/>
|
||||
<stop offset="1" stop-color="#D7896B"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(101 66) rotate(90) scale(34)">
|
||||
<stop offset="0" stop-color="#FFE6A8"/>
|
||||
<stop offset="0.42" stop-color="#F6B779" stop-opacity="0.78"/>
|
||||
<stop offset="1" stop-color="#F6B779" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="320" height="180" fill="url(#bannerBg)"/>
|
||||
<circle cx="281" cy="28" r="78" fill="#F0A66F" opacity="0.08"/>
|
||||
<rect x="18" y="34" width="112" height="112" rx="25" fill="url(#markBg)"/>
|
||||
<circle cx="101" cy="66" r="34" fill="url(#glow)"/>
|
||||
<g transform="rotate(8 83 90)">
|
||||
<rect x="57" y="50" width="54" height="67" rx="8" fill="url(#frameA)"/>
|
||||
<rect x="62" y="56" width="44" height="55" rx="5" fill="url(#photoA)"/>
|
||||
<path d="M62 101C72 91 79 89 88 98C94 104 99 105 106 99V111H62V101Z" fill="#151A3D" opacity="0.28"/>
|
||||
<circle cx="97" cy="66" r="7" fill="#FFE2A0"/>
|
||||
</g>
|
||||
<g transform="rotate(-8 67 101)">
|
||||
<rect x="42" y="61" width="50" height="62" rx="7" fill="url(#frameB)"/>
|
||||
<rect x="47" y="67" width="40" height="51" rx="5" fill="url(#photoB)"/>
|
||||
<path d="M47 107C56 97 64 98 71 105C77 111 82 111 87 105V118H47V107Z" fill="#151A3D" opacity="0.30"/>
|
||||
<circle cx="76" cy="76" r="5" fill="#FFD99A"/>
|
||||
</g>
|
||||
<text x="154" y="92" fill="#FFF2DA" font-family="'Playfair Display', Georgia, serif" font-size="48" font-weight="500" letter-spacing="0">Reverie</text>
|
||||
<text x="157" y="119" fill="#F4C08A" font-family="Manrope, Arial, sans-serif" font-size="10" font-weight="600" letter-spacing="2.2">IMMICH DAYDREAM</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,63 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" width="1200" height="360" role="img" aria-label="Reverie logo">
|
||||
<defs>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Playfair Display";
|
||||
src: url("../app/src/main/res/font/playfair_display.ttf") format("truetype");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Manrope";
|
||||
src: url("../app/src/main/res/font/manrope.ttf") format("truetype");
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="markBg" x1="60" y1="42" x2="300" y2="318" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#151A3D"/>
|
||||
<stop offset="0.52" stop-color="#3D254F"/>
|
||||
<stop offset="1" stop-color="#F0A66F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameA" x1="114" y1="99" x2="260" y2="261" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFF2DA"/>
|
||||
<stop offset="1" stop-color="#F4C08A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameB" x1="94" y1="125" x2="235" y2="275" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#F9D6A5"/>
|
||||
<stop offset="1" stop-color="#FFF7E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="photoA" x1="132" y1="107" x2="246" y2="239" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#28345F"/>
|
||||
<stop offset="0.58" stop-color="#6F3D61"/>
|
||||
<stop offset="1" stop-color="#E98968"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="photoB" x1="119" y1="144" x2="225" y2="255" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20294F"/>
|
||||
<stop offset="0.62" stop-color="#594064"/>
|
||||
<stop offset="1" stop-color="#D7896B"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(232 132) rotate(90) scale(64)">
|
||||
<stop offset="0" stop-color="#FFE6A8"/>
|
||||
<stop offset="0.42" stop-color="#F6B779" stop-opacity="0.78"/>
|
||||
<stop offset="1" stop-color="#F6B779" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<rect x="36" y="36" width="288" height="288" rx="64" fill="url(#markBg)"/>
|
||||
<circle cx="232" cy="132" r="64" fill="url(#glow)"/>
|
||||
<g transform="rotate(8 198 178)">
|
||||
<rect x="127" y="89" width="138" height="172" rx="20" fill="#181D3C" opacity="0.30"/>
|
||||
<rect x="119" y="83" width="138" height="172" rx="20" fill="url(#frameA)"/>
|
||||
<rect x="132" y="98" width="111" height="142" rx="13" fill="url(#photoA)"/>
|
||||
<path d="M132 214C156 190 176 186 199 208C214 223 227 226 243 212V240H132V214Z" fill="#151A3D" opacity="0.28"/>
|
||||
<circle cx="220" cy="124" r="17" fill="#FFE2A0"/>
|
||||
</g>
|
||||
<g transform="rotate(-8 160 202)">
|
||||
<rect x="98" y="115" width="127" height="158" rx="18" fill="#111735" opacity="0.28"/>
|
||||
<rect x="90" y="109" width="127" height="158" rx="18" fill="url(#frameB)"/>
|
||||
<rect x="103" y="123" width="101" height="130" rx="12" fill="url(#photoB)"/>
|
||||
<path d="M103 227C127 201 146 203 164 221C179 236 190 235 204 222V253H103V227Z" fill="#151A3D" opacity="0.30"/>
|
||||
<circle cx="176" cy="146" r="13" fill="#FFD99A"/>
|
||||
</g>
|
||||
<path d="M108 292C147 308 217 309 258 289" fill="none" stroke="#FFF2DA" stroke-opacity="0.20" stroke-width="11" stroke-linecap="round"/>
|
||||
</g>
|
||||
<text x="388" y="228" fill="#FFF2DA" font-family="'Playfair Display', Georgia, serif" font-size="128" font-weight="500" letter-spacing="0">Reverie</text>
|
||||
<text x="394" y="276" fill="#F4C08A" font-family="Manrope, Arial, sans-serif" font-size="24" font-weight="500" letter-spacing="6">DAYDREAMS FOR FAMILY MEMORIES</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Reverie app mark">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="82" y1="55" x2="430" y2="466" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#151A3D"/>
|
||||
<stop offset="0.52" stop-color="#3D254F"/>
|
||||
<stop offset="1" stop-color="#F0A66F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameA" x1="151" y1="137" x2="371" y2="377" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFF2DA"/>
|
||||
<stop offset="1" stop-color="#F4C08A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameB" x1="127" y1="172" x2="336" y2="394" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#F9D6A5"/>
|
||||
<stop offset="1" stop-color="#FFF7E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="photoA" x1="177" y1="149" x2="346" y2="343" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#28345F"/>
|
||||
<stop offset="0.58" stop-color="#6F3D61"/>
|
||||
<stop offset="1" stop-color="#E98968"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="photoB" x1="159" y1="200" x2="315" y2="363" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20294F"/>
|
||||
<stop offset="0.62" stop-color="#594064"/>
|
||||
<stop offset="1" stop-color="#D7896B"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(327 189) rotate(90) scale(91)">
|
||||
<stop offset="0" stop-color="#FFE6A8"/>
|
||||
<stop offset="0.42" stop-color="#F6B779" stop-opacity="0.78"/>
|
||||
<stop offset="1" stop-color="#F6B779" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||
<circle cx="327" cy="189" r="91" fill="url(#glow)"/>
|
||||
<g transform="rotate(8 278 252)">
|
||||
<rect x="176" y="130" width="196" height="244" rx="28" fill="#181D3C" opacity="0.30"/>
|
||||
<rect x="166" y="120" width="196" height="244" rx="28" fill="url(#frameA)"/>
|
||||
<rect x="185" y="141" width="158" height="202" rx="18" fill="url(#photoA)"/>
|
||||
<path d="M185 304C219 270 247 265 279 296C301 317 319 321 343 302V343H185V304Z" fill="#151A3D" opacity="0.28"/>
|
||||
<circle cx="310" cy="178" r="24" fill="#FFE2A0"/>
|
||||
</g>
|
||||
<g transform="rotate(-8 225 287)">
|
||||
<rect x="136" y="166" width="180" height="224" rx="26" fill="#111735" opacity="0.28"/>
|
||||
<rect x="126" y="156" width="180" height="224" rx="26" fill="url(#frameB)"/>
|
||||
<rect x="144" y="176" width="144" height="184" rx="17" fill="url(#photoB)"/>
|
||||
<path d="M144 323C178 286 205 289 230 314C251 335 268 334 288 315V360H144V323Z" fill="#151A3D" opacity="0.30"/>
|
||||
<circle cx="247" cy="209" r="19" fill="#FFD99A"/>
|
||||
</g>
|
||||
<path d="M151 414C206 437 306 438 364 410" fill="none" stroke="#FFF2DA" stroke-opacity="0.20" stroke-width="16" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,72 @@
|
||||
# Reverie para iOS y Apple Watch — prompt de construcción
|
||||
|
||||
Este fichero es un prompt listo para pegar en un agente de código (Codex, Claude Code, Cursor…) para
|
||||
construir la versión iOS + watchOS de **Reverie**, la app de presentación ambiental de fotos de Immich
|
||||
que ya existe para Android TV. En iOS no hay salvapantallas de terceros, así que la app es un visor
|
||||
ambiental a pantalla completa (ideal en un iPad apoyado o en el modo StandBy del iPhone) y el reloj
|
||||
muestra recuerdos aleatorios en la esfera y en su propia app.
|
||||
|
||||
Todo lo que sigue, a partir de la línea, es el prompt. Sustituye `<SERVER>` si quieres precargar tu
|
||||
servidor de pruebas.
|
||||
|
||||
---
|
||||
|
||||
You are a senior Apple platforms engineer. Build **Reverie**, a native iOS 17+ / watchOS 10+ app in Swift and SwiftUI that turns a self-hosted [Immich](https://immich.app) photo library into a calm, ambient slideshow. It is the companion of an existing Android TV screensaver with the same name; match its behaviour and aesthetics described below. There is no screensaver API on iOS, so the iPhone/iPad app is a full-screen "ambient viewer" that keeps the screen awake, and the Apple Watch gets its own app plus complications.
|
||||
|
||||
## 1. Product scope
|
||||
|
||||
### iOS / iPadOS app
|
||||
- **Onboarding**: server URL, then either email + password (preferred) or paste an API key. With email + password the app calls `POST /api/auth/login`, then `POST /api/api-keys` to create a key named `"Reverie (<device name>)"` with the scoped permissions `["asset.read", "asset.view", "album.read", "user.read"]`, stores only the key in the Keychain and discards the password. Show "Connected as <name>" using `GET /api/users/me`.
|
||||
- **Ambient mode** (the core): full-screen slideshow, no chrome, status bar hidden, `UIApplication.shared.isIdleTimerDisabled = true` while showing. Landscape and portrait supported; on iPad and on iPhone in landscape, **two portrait photos are shown side by side**; portrait orientation shows one photo at a time.
|
||||
- **Motion**: Ken Burns (slow pan + zoom, random direction, linear easing, lasting the whole slide), slow zoom only, or none. **Transitions**: crossfade (1.6 s), slide, zoom, random, cut.
|
||||
- **Frames** (setting): none, gallery mat (thin off-white border + soft shadow), polaroid (thick bottom border, ±3° random tilt), rounded card (16 pt corners + shadow). Framed styles always fit the photo over a blurred backdrop made from the asset thumbnail; unframed style fills the screen (crop) except for lone portrait photos, which are fitted over the blurred backdrop.
|
||||
- **Overlay**: bottom-left date in a serif display face (Playfair Display, weight 500, ~28 pt), location below in Manrope 600 uppercase with 0.12 em tracking (~15 pt), clock top-right in Playfair Display (~56 pt). A subtle bottom gradient scrim (transparent → 45 % black) keeps text legible. Each toggleable.
|
||||
- **Videos**: play inline with AVPlayer (muted by default, max length setting 15 s / 30 s / 1 min / 2 min / whole), then advance. Portrait videos are fitted over the blurred backdrop.
|
||||
- **Navigation**: tap right half or swipe left = next; tap left half or swipe right = **previous** (keep a history of the last 50 slides so "previous" shows exactly what was on screen); swipe down or a small close button after a tap = exit ambient mode. Hardware keyboard arrows on iPad do the same.
|
||||
- **Content filters**: albums (multi-select loaded from `GET /api/albums`, sorted by name, showing asset counts), photos / videos / both, favourites only, image quality (preview or full size).
|
||||
- **Settings**: interval per photo (5 s … 5 min, default 20 s), motion, transition, frame, scaling (fill / fit), show date & location, show clock, mute videos, max video length. Persist in `UserDefaults`; the API key in Keychain.
|
||||
- **StandBy**: provide a WidgetKit widget (systemLarge + StandBy-friendly) that shows a recent random photo with the date, refreshed on a timeline every 15–30 min from a cached pool of photos.
|
||||
- **Handoff to Watch**: the phone pushes a small pool (≈30) of square-cropped 400 px thumbnails plus their date/location to the watch via `WatchConnectivity` (`transferFile` / application context), so the watch works even when the phone is away.
|
||||
|
||||
### watchOS app
|
||||
- Standalone-capable watch app (with the companion providing credentials via WatchConnectivity; also allow entering the server + API key on the watch as a fallback).
|
||||
- Main view: one random photo at a time, filling the screen with a gentle Ken Burns drift; Digital Crown or swipe advances; long press shows date + location; tapping toggles the blurred backdrop for portrait photos.
|
||||
- Complications via WidgetKit (`accessoryRectangular`, `accessoryCircular` with a tiny cropped photo, `accessoryInline` with "Reverie · <date>") drawn from the cached pool, refreshed on a timeline.
|
||||
- Background refresh (`WKApplicationRefreshBackgroundTask`) tops up the pool from the server when reachable; never block the UI on the network.
|
||||
|
||||
## 2. Immich API facts (verified against Immich 3.1)
|
||||
|
||||
- Auth header: `x-api-key: <key>`. Base URL is the server root; all paths below are under `/api`.
|
||||
- `POST /api/search/random` body `{ "size": 60, "withExif": true, "visibility": "timeline", "type": "IMAGE" | "VIDEO" (omit for both), "isFavorite": true (optional), "albumIds": ["…"] (optional, any of) }` → array of assets. Fields to use: `id`, `type` (`IMAGE`/`VIDEO`), top-level `width`/`height` (already orientation-corrected in 3.x; on older servers fall back to `exifInfo.exifImageWidth/Height` and swap them when `exifInfo.orientation` is 5–8), `originalFileName`, `localDateTime` (naive ISO date, no timezone conversion needed), `exifInfo.city`, `exifInfo.country` (may be JSON `null`), `duration` (milliseconds as a number in 3.x; older servers return `"H:MM:SS.mmm"`).
|
||||
- `GET /api/albums` → `[ { id, albumName, assetCount } ]`.
|
||||
- `GET /api/assets/{id}/thumbnail?size=thumbnail` (small WebP, use for blurred backdrops and the watch pool), `?size=preview` (JPEG ≤ 1440 px, the default for display), `?size=fullsize` (may 302 to preview for HEIC originals; follow redirects keeping the header).
|
||||
- `GET /api/assets/{id}/video/playback` → transcoded MP4, supports HTTP range requests; feed it to `AVURLAsset` with `AVURLAssetHTTPHeaderFieldsKey` for the API key.
|
||||
- `POST /api/auth/login` `{ email, password }` → `{ accessToken }`; `POST /api/api-keys` with `Authorization: Bearer <accessToken>` and `{ "name": "…", "permissions": [ "asset.read", "asset.view", "album.read", "user.read" ] }` → `{ "secret": "<key>" }`. There is **no** `search.read` permission; the four above are enough for everything the app does.
|
||||
- Treat any JSON `null` as absent (never display the string "null").
|
||||
|
||||
## 3. Architecture and quality bar
|
||||
|
||||
- Swift 5.10, SwiftUI, Swift Concurrency (`async/await`, actors), `Observation` framework. No third-party dependencies unless indispensable.
|
||||
- Modules / targets: `ReverieCore` (Swift package: API client, models, slide source with portrait pairing and a "recently shown" dedupe set of ~600 ids, settings, keychain), `Reverie` (iOS app), `ReverieWidgets` (iOS widget extension), `Reverie Watch App`, `ReverieWatchWidgets` (complications).
|
||||
- Slide source: fetch batches of 60 random assets, drop ids seen recently (reset when everything was seen), split portraits (`height > width`) from the rest, pair portraits two by two (an odd leftover becomes a single), shuffle, queue. Preload the next slide's images while the current one is on screen so transitions never wait on the network.
|
||||
- Image loading through `URLSession` with an in-memory cache keyed by asset id + size; decode off the main thread; downsample to the screen size with `CGImageSourceCreateThumbnailAtIndex`.
|
||||
- Animations with SwiftUI `withAnimation(.linear(duration:))` for Ken Burns and explicit `transition` modifiers for crossfade/slide/zoom; keep 60 fps on iPhone 12-class hardware.
|
||||
- Accessibility: VoiceOver labels on controls, Dynamic Type in settings, Reduce Motion disables Ken Burns and uses crossfade only.
|
||||
- Localisation: English and Spanish string catalogs.
|
||||
- Tests: unit tests for the API decoding (both the 3.x and legacy shapes above), the portrait pairing, the history/previous logic and the duration parser. A UI test that walks onboarding with a mocked server.
|
||||
- Deliver a working Xcode project (`Reverie.xcodeproj` with an `xcconfig` for bundle ids and team), a `README.md` with setup and screenshots placeholders, and a short `ARCHITECTURE.md`.
|
||||
|
||||
## 4. Visual identity
|
||||
|
||||
Name **Reverie**, tagline "Your memories, drifting on screen" / "Tus recuerdos, flotando en pantalla". Palette: deep indigo `#1E1E2A` (background), dusk violet `#4250AF` (accent), warm apricot `#F4B942` (highlight), off-white paper `#FAF7F2` (frames), soft green `#2E8B57` (success). Typography: Playfair Display for dates, the clock and headings; Manrope for UI. The app icon is two overlapping rounded photo frames on a dusk gradient with a small glowing sun; reproduce it as an SF-Symbol-free asset catalog icon (1024 px) and keep the same mark for the watch icon and complication placeholders.
|
||||
|
||||
## 5. Acceptance checklist
|
||||
|
||||
1. Fresh install → enter `https://<SERVER>` + email + password → "Connected as …" within 5 s; the password is never persisted.
|
||||
2. Ambient mode on an iPad in landscape shows two portrait photos side by side with Ken Burns, crossfades every 20 s, and shows date + city/country without ever displaying "null".
|
||||
3. Swiping right returns to the exact previous slide; swiping left afterwards returns to the one that was on screen.
|
||||
4. A video plays muted for at most the configured length and then advances; portrait videos sit on a blurred backdrop.
|
||||
5. Frames setting switches between none / gallery / polaroid / rounded live, without restarting the slideshow.
|
||||
6. The watch shows a photo within 2 s of opening with the phone nearby, and still shows cached photos with the phone off; complications render a photo and a date.
|
||||
7. Screen never dims during ambient mode; leaving it restores the idle timer.
|
||||
8. All tests pass with `xcodebuild test` on the iOS simulator and the watchOS simulator.
|
||||