Immich screensaver for Android TV

Full-screen ambient slideshow (DreamService + manual activity) fed by the
Immich API: random photos/videos with album, favorites and type filters,
portrait photos paired side by side, Ken Burns motion, crossfade/slide/zoom
transitions, inline muted video via ExoPlayer, date/location and clock
overlays, Leanback settings with email+password sign-in that mints a scoped
API key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BvT3RjNpBmMUsyPqHq7fm
This commit is contained in:
2026-09-11 16:26:31 +00:00
commit d15475e5eb
34 changed files with 2130 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.gradle/
build/
local.properties
.idea/
*.iml
.DS_Store
*.jks
*.keystore
signing.properties
app/release/
+63
View File
@@ -0,0 +1,63 @@
# Immich Screensaver for Android TV
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:
- Full-screen slideshow with slow pan & zoom (Ken Burns), crossfade / slide / zoom transitions.
- Portrait photos are shown two at a time, side by side.
- Videos play inline (muted by default, capped length), lone portrait items sit on a blurred backdrop.
- Date and location overlay, clock.
- Works as the **system screensaver** (Settings → Screen saver) and as a normal app (start the slideshow manually).
- Filter by albums, favorites, photos / videos.
- 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).
## Install
Grab `app-release.apk` from the releases page (or build it) and sideload it:
```bash
adb connect <tv-ip>
adb install -r app-release.apk
```
Optional: pre-configure the server without typing on the TV remote:
```bash
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 →
Screen saver) and pick it. *Start slideshow now* previews it; RIGHT / OK skips, BACK exits.
## Build
```bash
export ANDROID_HOME=~/android-sdk
./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk
./scripts/build-release.sh # signed release, keystore password taken from pass
```
`scripts/build-release.sh` expects the keystore at `~/.android/immichtv-release.jks` and its password in
`pass show android/immichtv-keystore-password`.
## Immich API used
| Call | Purpose |
|------|---------|
| `POST /api/search/random` | random batch of assets (albums / type / favorites filters, `visibility: timeline`) |
| `GET /api/albums` | album picker |
| `GET /api/assets/{id}/thumbnail?size=preview|fullsize|thumbnail` | photos and blurred backdrops |
| `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 |
## Layout
- `data/ImmichApi.kt` REST client (OkHttp + org.json).
- `data/SlideSource.kt` endless shuffled stream of slides, portrait pairing, dedupe of recent assets.
- `ui/SlideshowView.kt` rendering engine: layers, transitions, Ken Burns, ExoPlayer for video, overlays.
- `dream/ImmichDreamService.kt` system screensaver entry point.
- `ui/SlideshowActivity.kt` manual full-screen mode.
- `ui/MainActivity.kt` + `ui/SettingsFragment.kt` Leanback settings UI.
+63
View File
@@ -0,0 +1,63 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
val keystoreFile = System.getenv("IMMICHTV_KEYSTORE")?.let { file(it) }
val keystorePassword = System.getenv("IMMICHTV_KEYSTORE_PASSWORD")
android {
namespace = "cloud.alexandrevazquez.immichtv"
compileSdk = 35
defaultConfig {
applicationId = "cloud.alexandrevazquez.immichtv"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
signingConfigs {
if (keystoreFile != null && keystoreFile.exists() && keystorePassword != null) {
create("release") {
storeFile = keystoreFile
storePassword = keystorePassword
keyAlias = "immichtv"
keyPassword = keystorePassword
}
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.fragment:fragment-ktx:1.8.2")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.leanback:leanback:1.2.0")
implementation("androidx.leanback:leanback-preference:1.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("io.coil-kt:coil:2.7.0")
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
}
View File
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.software.leanback" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:name=".App"
android:allowBackup="true"
android:banner="@drawable/banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity
android:name=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.SlideshowActivity"
android:exported="true"
android:theme="@style/SlideshowTheme" />
<service
android:name=".dream.ImmichDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/dream_info" />
</service>
</application>
</manifest>
@@ -0,0 +1,28 @@
package cloud.alexandrevazquez.immichtv
import android.app.Application
import cloud.alexandrevazquez.immichtv.data.ImmichApi
import cloud.alexandrevazquez.immichtv.data.Settings
class App : Application() {
lateinit var settings: Settings
private set
override fun onCreate() {
super.onCreate()
settings = Settings(this)
}
/** Builds an API client for the currently configured server, or null when not configured. */
fun api(): ImmichApi? {
val url = settings.serverUrl
val key = settings.apiKey
if (url.isBlank() || key.isBlank()) return null
return ImmichApi(url, key)
}
companion object {
fun from(context: android.content.Context): App = context.applicationContext as App
}
}
@@ -0,0 +1,195 @@
package cloud.alexandrevazquez.immichtv.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
data class Album(val id: String, val name: String, val assetCount: Int)
data class Asset(
val id: String,
val type: String,
val width: Int,
val height: Int,
val fileName: String,
val takenAt: String?,
val city: String?,
val country: String?,
val durationMs: Long,
) {
val isVideo: Boolean get() = type == "VIDEO"
val isPortrait: Boolean get() = width > 0 && height > 0 && height > width
}
class ImmichException(message: String, val code: Int = 0) : IOException(message)
/** Minimal Immich REST client (tested against Immich 3.x, compatible with 1.1xx+). */
class ImmichApi(baseUrl: String, private val apiKey: String) {
val baseUrl: String = normalizeUrl(baseUrl)
val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("x-api-key", apiKey)
.header("Accept", "application/json, image/*, video/*, */*")
.build()
)
}
.build()
fun imageUrl(id: String, size: String): String = "$baseUrl/api/assets/$id/thumbnail?size=$size"
fun videoUrl(id: String): String = "$baseUrl/api/assets/$id/video/playback"
suspend fun me(): String = withContext(Dispatchers.IO) {
val json = JSONObject(get("$baseUrl/api/users/me"))
json.optString("name").ifBlank { json.optString("email") }
}
suspend fun albums(): List<Album> = withContext(Dispatchers.IO) {
val arr = JSONArray(get("$baseUrl/api/albums"))
(0 until arr.length()).map { i ->
val a = arr.getJSONObject(i)
Album(a.getString("id"), a.optString("albumName", "?"), a.optInt("assetCount", 0))
}.sortedBy { it.name.lowercase() }
}
/**
* Random assets. [type] is "IMAGE", "VIDEO" or null for both.
* Archived / locked assets are excluded by requesting the timeline visibility.
*/
suspend fun random(
size: Int,
albumIds: Collection<String>,
type: String?,
favoritesOnly: Boolean,
): List<Asset> = withContext(Dispatchers.IO) {
val body = JSONObject().apply {
put("size", size)
put("withExif", true)
put("visibility", "timeline")
if (type != null) put("type", type)
if (favoritesOnly) put("isFavorite", true)
if (albumIds.isNotEmpty()) put("albumIds", JSONArray(albumIds.toList()))
}
val arr = JSONArray(post("$baseUrl/api/search/random", body))
(0 until arr.length()).mapNotNull { i -> parseAsset(arr.getJSONObject(i)) }
}
private fun parseAsset(o: JSONObject): Asset? {
val id = o.optString("id").ifBlank { return null }
val exif = o.optJSONObject("exifInfo")
var w = o.optInt("width", 0)
var h = o.optInt("height", 0)
if (w <= 0 || h <= 0) {
// Older servers: derive from EXIF and swap when the orientation tag rotates the image.
w = exif?.optInt("exifImageWidth", 0) ?: 0
h = exif?.optInt("exifImageHeight", 0) ?: 0
val orientation = exif?.optString("orientation", "")?.toIntOrNull() ?: 1
if (orientation in 5..8) { val t = w; w = h; h = t }
}
return Asset(
id = id,
type = o.optString("type", "IMAGE"),
width = w,
height = h,
fileName = o.optString("originalFileName", ""),
takenAt = o.optString("localDateTime").ifBlank { exif?.optString("dateTimeOriginal") }?.ifBlank { null },
city = exif?.optString("city")?.ifBlank { null },
country = exif?.optString("country")?.ifBlank { null },
durationMs = parseDuration(o.opt("duration")),
)
}
private fun get(url: String): String = execute(Request.Builder().url(url).get().build())
private fun post(url: String, body: JSONObject): String = execute(
Request.Builder().url(url).post(body.toString().toRequestBody(JSON)).build()
)
private fun execute(request: Request): String {
client.newCall(request).execute().use { resp ->
val text = resp.body?.string() ?: ""
if (!resp.isSuccessful) {
val msg = runCatching { JSONObject(text).optString("message") }.getOrNull()
throw ImmichException(
if (!msg.isNullOrBlank()) "HTTP ${resp.code}: $msg" else "HTTP ${resp.code}",
resp.code
)
}
return text
}
}
companion object {
private val JSON = "application/json; charset=utf-8".toMediaType()
fun normalizeUrl(raw: String): String {
var u = raw.trim().trimEnd('/')
if (u.isBlank()) return ""
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
if (u.endsWith("/api")) u = u.removeSuffix("/api")
return u
}
/** Immich 3.x returns milliseconds; older versions return "H:MM:SS.mmm". */
fun parseDuration(v: Any?): Long = when (v) {
null -> 0L
is Number -> v.toLong()
is String -> {
val parts = v.split(":")
if (parts.size == 3) {
val h = parts[0].toLongOrNull() ?: 0
val m = parts[1].toLongOrNull() ?: 0
val s = parts[2].toDoubleOrNull() ?: 0.0
((h * 3600 + m * 60) * 1000 + (s * 1000).toLong())
} else v.toLongOrNull() ?: 0L
}
else -> 0L
}
/** Logs in with email/password and creates a dedicated API key for this device. Returns the key secret. */
suspend fun createApiKeyWithPassword(baseUrl: String, email: String, password: String, deviceName: String): String =
withContext(Dispatchers.IO) {
val base = normalizeUrl(baseUrl)
val plain = OkHttpClient.Builder().connectTimeout(15, TimeUnit.SECONDS).build()
val loginBody = JSONObject().put("email", email.trim()).put("password", password).toString()
val token = plain.newCall(
Request.Builder().url("$base/api/auth/login").post(loginBody.toRequestBody(JSON)).build()
).execute().use { resp ->
val text = resp.body?.string() ?: ""
if (!resp.isSuccessful) {
val msg = runCatching { JSONObject(text).optString("message") }.getOrNull()
throw ImmichException(if (!msg.isNullOrBlank()) msg else "HTTP ${resp.code}", resp.code)
}
JSONObject(text).getString("accessToken")
}
val keyBody = JSONObject()
.put("name", deviceName)
.put("permissions", JSONArray(listOf("asset.read", "asset.view", "album.read", "user.read")))
.toString()
plain.newCall(
Request.Builder().url("$base/api/api-keys")
.header("Authorization", "Bearer $token")
.post(keyBody.toRequestBody(JSON)).build()
).execute().use { resp ->
val text = resp.body?.string() ?: ""
if (!resp.isSuccessful) {
val msg = runCatching { JSONObject(text).optString("message") }.getOrNull()
throw ImmichException(if (!msg.isNullOrBlank()) msg else "HTTP ${resp.code}", resp.code)
}
JSONObject(text).getString("secret")
}
}
}
}
@@ -0,0 +1,56 @@
package cloud.alexandrevazquez.immichtv.data
import android.content.Context
import androidx.preference.PreferenceManager
/** Thin typed wrapper over the default SharedPreferences written by the settings screen. */
class Settings(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val serverUrl: String get() = ImmichApi.normalizeUrl(prefs.getString(KEY_SERVER_URL, "") ?: "")
val apiKey: String get() = prefs.getString(KEY_API_KEY, "")?.trim() ?: ""
val isConfigured: Boolean get() = serverUrl.isNotBlank() && apiKey.isNotBlank()
val albumIds: Set<String> get() = prefs.getStringSet(KEY_ALBUMS, emptySet()) ?: emptySet()
val contentType: String get() = prefs.getString(KEY_CONTENT_TYPE, "both") ?: "both"
val favoritesOnly: Boolean get() = prefs.getBoolean(KEY_FAVORITES_ONLY, false)
val quality: String get() = prefs.getString(KEY_QUALITY, "preview") ?: "preview"
val intervalMs: Long get() = (prefs.getString(KEY_INTERVAL, "20")?.toLongOrNull() ?: 20L) * 1000L
val motion: String get() = prefs.getString(KEY_MOTION, "kenburns") ?: "kenburns"
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"
val showInfo: Boolean get() = prefs.getBoolean(KEY_SHOW_INFO, true)
val showClock: Boolean get() = prefs.getBoolean(KEY_SHOW_CLOCK, true)
val muteVideos: Boolean get() = prefs.getBoolean(KEY_MUTE_VIDEOS, true)
/** 0 means "play the whole video". */
val maxVideoMs: Long get() = (prefs.getString(KEY_MAX_VIDEO, "60")?.toLongOrNull() ?: 60L) * 1000L
fun setServer(url: String, apiKey: String) {
prefs.edit()
.putString(KEY_SERVER_URL, ImmichApi.normalizeUrl(url))
.putString(KEY_API_KEY, apiKey.trim())
.apply()
}
companion object {
const val KEY_SERVER_URL = "server_url"
const val KEY_API_KEY = "api_key"
const val KEY_ALBUMS = "albums"
const val KEY_CONTENT_TYPE = "content_type"
const val KEY_FAVORITES_ONLY = "favorites_only"
const val KEY_QUALITY = "quality"
const val KEY_INTERVAL = "interval"
const val KEY_MOTION = "motion"
const val KEY_TRANSITION = "transition"
const val KEY_PAIR_PORTRAITS = "pair_portraits"
const val KEY_SCALING = "scaling"
const val KEY_SHOW_INFO = "show_info"
const val KEY_SHOW_CLOCK = "show_clock"
const val KEY_MUTE_VIDEOS = "mute_videos"
const val KEY_MAX_VIDEO = "max_video"
}
}
@@ -0,0 +1,83 @@
package cloud.alexandrevazquez.immichtv.data
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
sealed class Slide {
abstract val assets: List<Asset>
data class Single(val asset: Asset) : Slide() {
override val assets get() = listOf(asset)
}
data class Pair(val left: Asset, val right: Asset) : Slide() {
override val assets get() = listOf(left, right)
}
}
/**
* Produces an endless, shuffled stream of slides from Immich's random search,
* pairing portrait photos two by two and avoiding recently shown assets.
*/
class SlideSource(
private val api: ImmichApi,
private val settings: Settings,
private val onStatus: (String?) -> Unit = {},
) {
private val queue = ArrayDeque<Slide>()
private val recent = LinkedHashSet<String>()
private val mutex = Mutex()
suspend fun next(): Slide = mutex.withLock {
var backoff = 3_000L
while (queue.isEmpty()) {
try {
refill()
if (queue.isEmpty()) {
onStatus("No assets found with the current filters")
delay(30_000)
} else {
onStatus(null)
}
} catch (e: Exception) {
onStatus(e.message ?: e.javaClass.simpleName)
delay(backoff)
backoff = (backoff * 2).coerceAtMost(60_000)
}
}
queue.removeFirst()
}
private suspend fun refill() {
val type = when (settings.contentType) {
"images" -> "IMAGE"
"videos" -> "VIDEO"
else -> null
}
var assets = api.random(BATCH, settings.albumIds, type, settings.favoritesOnly)
.filter { it.width > 0 && it.height > 0 }
val fresh = assets.filter { it.id !in recent }
// Small libraries: once everything has been shown, start over.
assets = if (fresh.isEmpty()) { recent.clear(); assets } else fresh
val slides = mutableListOf<Slide>()
if (settings.pairPortraits) {
val (portraits, others) = assets.partition { !it.isVideo && it.isPortrait }
others.forEach { slides += Slide.Single(it) }
portraits.shuffled().chunked(2).forEach { pair ->
slides += if (pair.size == 2) Slide.Pair(pair[0], pair[1]) else Slide.Single(pair[0])
}
} else {
assets.forEach { slides += Slide.Single(it) }
}
slides.shuffle()
queue.addAll(slides)
assets.forEach { recent.add(it.id) }
while (recent.size > RECENT_LIMIT) recent.remove(recent.first())
}
companion object {
private const val BATCH = 60
private const val RECENT_LIMIT = 600
}
}
@@ -0,0 +1,34 @@
package cloud.alexandrevazquez.immichtv.dream
import android.service.dreams.DreamService
import cloud.alexandrevazquez.immichtv.ui.SlideshowView
/** System screensaver entry point (Settings > Screen saver). */
class ImmichDreamService : DreamService() {
private var view: SlideshowView? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
isFullscreen = true
isInteractive = false
isScreenBright = true
view = SlideshowView(this).also { setContentView(it) }
}
override fun onDreamingStarted() {
super.onDreamingStarted()
view?.start()
}
override fun onDreamingStopped() {
view?.stop()
super.onDreamingStopped()
}
override fun onDetachedFromWindow() {
view?.stop()
view = null
super.onDetachedFromWindow()
}
}
@@ -0,0 +1,33 @@
package cloud.alexandrevazquez.immichtv.ui
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.FragmentActivity
import cloud.alexandrevazquez.immichtv.App
import cloud.alexandrevazquez.immichtv.R
/**
* Settings screen. Also accepts `server` / `api_key` string extras so the app can be
* pre-configured from adb:
* adb shell am start -n cloud.alexandrevazquez.immichtv/.ui.MainActivity --es server https://immich.example.com --es api_key XXX
*/
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val server = intent?.getStringExtra("server")
val apiKey = intent?.getStringExtra("api_key")
if (!server.isNullOrBlank() && !apiKey.isNullOrBlank()) {
App.from(this).settings.setServer(server, apiKey)
Toast.makeText(this, R.string.toast_configured_from_intent, Toast.LENGTH_LONG).show()
}
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.settings_container, SettingsFragment())
.commit()
}
}
}
@@ -0,0 +1,188 @@
package cloud.alexandrevazquez.immichtv.ui
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings as SystemSettings
import android.text.InputType
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import android.app.AlertDialog
import androidx.leanback.preference.LeanbackPreferenceFragmentCompat
import androidx.leanback.preference.LeanbackSettingsFragmentCompat
import androidx.lifecycle.lifecycleScope
import androidx.preference.EditTextPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
import cloud.alexandrevazquez.immichtv.App
import cloud.alexandrevazquez.immichtv.R
import cloud.alexandrevazquez.immichtv.data.ImmichApi
import cloud.alexandrevazquez.immichtv.data.Settings
import kotlinx.coroutines.launch
class SettingsFragment : LeanbackSettingsFragmentCompat() {
override fun onPreferenceStartInitialScreen() {
startPreferenceFragment(PrefsFragment())
}
override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference): Boolean {
val f = childFragmentManager.fragmentFactory.instantiate(requireActivity().classLoader, pref.fragment!!)
f.arguments = pref.extras
startPreferenceFragment(f)
return true
}
override fun onPreferenceStartScreen(caller: PreferenceFragmentCompat, pref: PreferenceScreen): Boolean {
val f = PrefsFragment()
f.arguments = Bundle().apply { putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, pref.key) }
startPreferenceFragment(f)
return true
}
class PrefsFragment : LeanbackPreferenceFragmentCompat() {
private val app get() = App.from(requireContext())
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.preferences, rootKey)
findPreference<EditTextPreference>(Settings.KEY_SERVER_URL)?.apply {
setOnBindEditTextListener { it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI }
summaryProvider = Preference.SummaryProvider<EditTextPreference> { p ->
p.text?.ifBlank { null } ?: getString(R.string.summary_not_set)
}
}
findPreference<EditTextPreference>(Settings.KEY_API_KEY)?.apply {
setOnBindEditTextListener { it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD }
summaryProvider = Preference.SummaryProvider<EditTextPreference> { p ->
if (p.text.isNullOrBlank()) getString(R.string.summary_not_set) else getString(R.string.summary_api_key_set)
}
}
findPreference<Preference>("login")?.setOnPreferenceClickListener { showLoginDialog(); true }
findPreference<Preference>("test_connection")?.setOnPreferenceClickListener { testConnection(); true }
findPreference<Preference>("preview")?.setOnPreferenceClickListener {
startActivity(Intent(requireContext(), SlideshowActivity::class.java)); true
}
findPreference<Preference>("dream_settings")?.setOnPreferenceClickListener { openDreamSettings(); true }
findPreference<MultiSelectListPreference>(Settings.KEY_ALBUMS)?.let { loadAlbums(it) }
}
private fun loadAlbums(pref: MultiSelectListPreference) {
val api = app.api()
if (api == null) {
pref.isEnabled = false
pref.summaryProvider = null
pref.summary = getString(R.string.summary_configure_server_first)
return
}
pref.isEnabled = false
pref.summaryProvider = null
pref.summary = getString(R.string.summary_loading_albums)
lifecycleScope.launch {
try {
val albums = api.albums()
pref.entries = albums.map { "${it.name} (${it.assetCount})" }.toTypedArray()
pref.entryValues = albums.map { it.id }.toTypedArray()
pref.isEnabled = true
pref.summaryProvider = Preference.SummaryProvider<MultiSelectListPreference> { p ->
val chosen = albums.filter { it.id in p.values }
if (chosen.isEmpty()) getString(R.string.summary_all_albums)
else chosen.joinToString(", ") { it.name }
}
} catch (e: Exception) {
pref.summaryProvider = null
pref.summary = getString(R.string.summary_albums_error, e.message ?: "")
}
}
}
private fun testConnection() {
val api = app.api()
if (api == null) {
toast(getString(R.string.summary_configure_server_first)); return
}
lifecycleScope.launch {
try {
val name = api.me()
toast(getString(R.string.toast_connected_as, name))
findPreference<MultiSelectListPreference>(Settings.KEY_ALBUMS)?.let { loadAlbums(it) }
} catch (e: Exception) {
toast(getString(R.string.toast_connection_failed, e.message ?: ""))
}
}
}
/** Email + password login that mints a dedicated API key, so the key never has to be typed on the TV. */
private fun showLoginDialog() {
val ctx = requireContext()
val serverPref = findPreference<EditTextPreference>(Settings.KEY_SERVER_URL)
val server = serverPref?.text?.trim().orEmpty()
if (server.isBlank()) {
toast(getString(R.string.toast_enter_server_first)); return
}
val pad = (16 * resources.displayMetrics.density).toInt()
val email = EditText(ctx).apply {
hint = getString(R.string.hint_email)
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
}
val password = EditText(ctx).apply {
hint = getString(R.string.hint_password)
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
val layout = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad * 2, pad, pad * 2, 0)
addView(email)
addView(password)
}
AlertDialog.Builder(ctx)
.setTitle(R.string.pref_login)
.setMessage(getString(R.string.login_message, server))
.setView(layout)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
val deviceName = "Android TV (${Build.MODEL})"
lifecycleScope.launch {
try {
val key = ImmichApi.createApiKeyWithPassword(server, email.text.toString(), password.text.toString(), deviceName)
app.settings.setServer(server, key)
findPreference<EditTextPreference>(Settings.KEY_API_KEY)?.text = key
serverPref?.text = ImmichApi.normalizeUrl(server)
toast(getString(R.string.toast_login_ok))
findPreference<MultiSelectListPreference>(Settings.KEY_ALBUMS)?.let { loadAlbums(it) }
} catch (e: Exception) {
toast(getString(R.string.toast_connection_failed, e.message ?: ""))
}
}
}
.show()
}
private fun openDreamSettings() {
val candidates = listOf(
Intent(SystemSettings.ACTION_DREAM_SETTINGS),
Intent("android.settings.DREAM_SETTINGS"),
Intent(SystemSettings.ACTION_DISPLAY_SETTINGS),
Intent(SystemSettings.ACTION_SETTINGS),
)
for (intent in candidates) {
try {
startActivity(intent)
return
} catch (_: ActivityNotFoundException) {
}
}
toast(getString(R.string.toast_dream_settings_manual))
}
private fun toast(msg: String) {
if (isAdded) Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,53 @@
package cloud.alexandrevazquez.immichtv.ui
import android.os.Bundle
import android.view.KeyEvent
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. */
class SlideshowActivity : ComponentActivity() {
private lateinit var view: SlideshowView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
view = SlideshowView(this)
setContentView(view)
hideSystemUi()
}
override fun onStart() {
super.onStart()
view.start()
}
override fun onStop() {
view.stop()
super.onStop()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) hideSystemUi()
}
@Suppress("DEPRECATION")
private fun hideSystemUi() {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
view.next(); true
}
else -> super.onKeyDown(keyCode, event)
}
}
}
@@ -0,0 +1,498 @@
package cloud.alexandrevazquez.immichtv.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Color
import android.graphics.RenderEffect
import android.graphics.Shader
import android.graphics.Typeface
import android.os.Build
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.animation.LinearInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import cloud.alexandrevazquez.immichtv.App
import cloud.alexandrevazquez.immichtv.R
import cloud.alexandrevazquez.immichtv.data.Asset
import cloud.alexandrevazquez.immichtv.data.ImmichApi
import cloud.alexandrevazquez.immichtv.data.Slide
import cloud.alexandrevazquez.immichtv.data.SlideSource
import coil.ImageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.size.Precision
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.random.Random
/**
* Full-screen ambient slideshow. Used both by the screensaver (DreamService) and the preview activity.
* Call [start] once attached and [stop] when leaving; [next] skips to the next slide.
*/
class SlideshowView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
private val app = App.from(context)
private val settings = app.settings
private var api: ImmichApi? = null
private var imageLoader: ImageLoader? = null
private var source: SlideSource? = null
private val stage = FrameLayout(context)
private val infoText = TextView(context)
private val clockText = TextView(context)
private val statusText = TextView(context)
private var scope: CoroutineScope? = null
private var loopJob: Job? = null
private var currentLayer: Layer? = null
private val advance = Channel<Unit>(Channel.CONFLATED)
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
init {
setBackgroundColor(Color.BLACK)
addView(stage, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
val pad = dp(40)
infoText.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)
}
addView(infoText, 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))
visibility = GONE
}
addView(clockText, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.TOP or Gravity.END).apply {
setMargins(pad, pad, pad, pad)
})
statusText.apply {
setTextColor(Color.LTGRAY)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
gravity = Gravity.CENTER
setPadding(pad, pad, pad, pad)
visibility = GONE
}
addView(statusText, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER))
}
fun start() {
if (scope != null) return
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { this.scope = it }
val api = app.api()
if (api == null) {
showStatus(context.getString(R.string.status_not_configured))
return
}
this.api = api
imageLoader = ImageLoader.Builder(context)
.okHttpClient(api.client)
.crossfade(false)
.diskCachePolicy(CachePolicy.DISABLED)
.build()
source = SlideSource(api, settings) { msg -> post { if (msg == null) hideStatus() else showStatus(msg) } }
clockText.visibility = if (settings.showClock) VISIBLE else GONE
if (settings.showClock) scope.launch { clockLoop() }
showStatus(context.getString(R.string.status_loading))
loopJob = scope.launch { slideshowLoop() }
}
fun stop() {
scope?.cancel()
scope = null
loopJob = null
currentLayer?.release()
currentLayer = null
stage.removeAllViews()
imageLoader?.shutdown()
imageLoader = null
source = null
api = null
}
fun next() {
advance.trySend(Unit)
}
// --- main loop -------------------------------------------------------------------------
private suspend fun slideshowLoop() {
val scope = scope ?: return
var prepared = prepareNext()
while (scope.isActive) {
val layer = prepared
hideStatus()
transitionTo(layer)
val nextJob = scope.async { prepareNext() }
waitForSlide(layer)
prepared = nextJob.await()
}
}
/** Keeps trying until a slide is fully loaded. Errors are surfaced through the status overlay. */
private suspend fun prepareNext(): Layer {
val source = source!!
var failures = 0
while (true) {
val slide = source.next()
val layer = Layer(slide)
try {
withTimeout(45_000) { layer.prepare() }
return layer
} catch (e: CancellationException) {
layer.release()
throw e
} catch (e: Exception) {
layer.release()
failures++
if (failures >= 3) {
showStatus(e.message ?: e.javaClass.simpleName)
delay((failures * 2_000L).coerceAtMost(20_000))
}
}
}
}
private suspend fun waitForSlide(layer: Layer) {
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 { }
}
}
} else {
withTimeoutOrNull(settings.intervalMs) { advance.receive() }
}
}
private suspend fun transitionTo(layer: Layer) {
val old = currentLayer
currentLayer = layer
val root = layer.root
val transition = settings.transition.let { if (it == "random") TRANSITIONS.random() else it }
val duration = if (transition == "cut") 0L else TRANSITION_MS
stage.addView(root, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
layer.startMotion(settings.intervalMs + duration)
layer.player?.play()
updateInfo(layer.slide)
when (transition) {
"fade" -> {
root.alpha = 0f
root.animate().alpha(1f).setDuration(duration).setInterpolator(LinearInterpolator()).start()
}
"slide" -> {
root.translationX = screenW.toFloat()
root.animate().translationX(0f).setDuration(duration).start()
old?.root?.animate()?.translationX(-screenW.toFloat())?.setDuration(duration)?.start()
}
"zoom" -> {
root.alpha = 0f
root.scaleX = 1.12f
root.scaleY = 1.12f
root.animate().alpha(1f).scaleX(1f).scaleY(1f).setDuration(duration).start()
old?.root?.animate()?.alpha(0f)?.setDuration(duration)?.start()
}
}
if (duration > 0) delay(duration)
if (old != null) {
old.release()
stage.removeView(old.root)
}
}
// --- overlays --------------------------------------------------------------------------
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()
}.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 suspend fun clockLoop() {
val fmt = DateTimeFormatter.ofPattern("HH:mm")
while (true) {
val now = LocalTime.now()
clockText.text = now.format(fmt)
delay((60 - now.second) * 1000L + 200)
}
}
private fun showStatus(msg: String) {
statusText.text = msg
statusText.visibility = VISIBLE
}
private fun hideStatus() {
statusText.visibility = GONE
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
// --- layer: one slide worth of views ---------------------------------------------------
private inner class Layer(val slide: Slide) {
val root = FrameLayout(context)
var player: ExoPlayer? = null
val videoEnded = CompletableDeferred<Unit>()
private val animators = mutableListOf<Animator>()
private val motionViews = mutableListOf<View>()
suspend fun prepare() {
when (slide) {
is Slide.Pair -> preparePair(slide.left, slide.right)
is Slide.Single -> if (slide.asset.isVideo) prepareVideo(slide.asset) else prepareImage(slide.asset)
}
}
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 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 row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL }
val gap = dp(3)
val cellW = (screenW - gap) / 2
val l = buildCell(left, fit, cellW, screenH)
val r = buildCell(right, fit, cellW, screenH)
row.addView(l, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f))
row.addView(View(context), LinearLayout.LayoutParams(gap, LinearLayout.LayoutParams.MATCH_PARENT))
row.addView(r, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f))
root.addView(row, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}
/** Loads the photo (and, when fitting, its blurred backdrop) into a clipped container. */
private suspend fun buildCell(asset: Asset, fit: Boolean, cellW: Int, cellH: Int): View {
val api = api!!
val container = FrameLayout(context).apply { clipChildren = true; clipToPadding = true }
val scope = scope!!
val fgJob = scope.async { load(api.imageUrl(asset.id, settings.quality), cellW, cellH) }
val bgJob = if (fit) scope.async { load(api.imageUrl(asset.id, "thumbnail"), 320, 320) } else null
if (bgJob != null) {
val bg = ImageView(context).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageDrawable(bgJob.await())
alpha = 0.55f
scaleX = 1.15f
scaleY = 1.15f
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRenderEffect(RenderEffect.createBlurEffect(60f, 60f, Shader.TileMode.CLAMP))
}
}
container.addView(bg, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}
val fg = ImageView(context).apply {
scaleType = if (fit) ImageView.ScaleType.FIT_CENTER else ImageView.ScaleType.CENTER_CROP
setImageDrawable(fgJob.await())
}
container.addView(fg, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
motionViews += fg
return container
}
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)
.data(url)
.size(w, h)
.precision(Precision.INEXACT)
.allowHardware(true)
.build()
return when (val result = loader.execute(req)) {
is SuccessResult -> result.drawable
else -> throw (result as coil.request.ErrorResult).throwable
}
}
private suspend fun prepareVideo(asset: Asset) {
val api = api!!
val fit = settings.scaling == "fit" || asset.isPortrait
// Backdrop from the video poster so portrait clips do not sit on plain black.
runCatching { load(api.imageUrl(asset.id, "thumbnail"), 320, 320) }.getOrNull()?.let { poster ->
val bg = ImageView(context).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
setImageDrawable(poster)
alpha = 0.55f
scaleX = 1.15f
scaleY = 1.15f
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
setRenderEffect(RenderEffect.createBlurEffect(60f, 60f, Shader.TileMode.CLAMP))
}
}
if (fit) root.addView(bg, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}
val dataSourceFactory = OkHttpDataSource.Factory(api.client)
val exo = ExoPlayer.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.build()
player = exo
exo.volume = if (settings.muteVideos) 0f else 1f
exo.repeatMode = Player.REPEAT_MODE_OFF
exo.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) videoEnded.complete(Unit)
}
override fun onPlayerError(error: PlaybackException) {
videoEnded.complete(Unit)
}
})
val playerView = LayoutInflater.from(context).inflate(R.layout.view_player, root, false) as PlayerView
playerView.useController = false
playerView.resizeMode = if (fit) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_ZOOM
playerView.setShutterBackgroundColor(Color.TRANSPARENT)
playerView.player = exo
root.addView(playerView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
exo.setMediaItem(MediaItem.fromUri(api.videoUrl(asset.id)))
exo.playWhenReady = false
exo.prepare()
awaitReady(exo)
}
private suspend fun awaitReady(exo: ExoPlayer) = suspendCancellableCoroutine { cont ->
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY && cont.isActive) {
exo.removeListener(this)
cont.resume(Unit)
}
}
override fun onPlayerError(error: PlaybackException) {
if (cont.isActive) {
exo.removeListener(this)
cont.resumeWithException(error)
}
}
}
exo.addListener(listener)
if (exo.playbackState == Player.STATE_READY && cont.isActive) {
exo.removeListener(listener)
cont.resume(Unit)
}
cont.invokeOnCancellation { exo.removeListener(listener) }
}
/** Slow pan/zoom (Ken Burns) over the whole time the slide is on screen. */
fun startMotion(durationMs: Long) {
val mode = settings.motion
if (mode == "none" || motionViews.isEmpty()) return
motionViews.forEach { v ->
val zoomIn = Random.nextBoolean()
val sNear = 1f + Random.nextFloat() * 0.06f
val sFar = 1.14f + Random.nextFloat() * 0.12f
val s0 = if (zoomIn) sNear else sFar
val s1 = if (zoomIn) sFar else sNear
val pan = mode == "kenburns"
val dx = if (pan) listOf(-1f, 0f, 1f).random() else 0f
val dy = if (pan) (if (dx == 0f) listOf(-1f, 1f).random() else listOf(-1f, 0f, 1f).random()) else 0f
val anim = ValueAnimator.ofFloat(0f, 1f).apply {
duration = durationMs
interpolator = LinearInterpolator()
addUpdateListener { a ->
val t = a.animatedValue as Float
val s = s0 + (s1 - s0) * t
val w = v.width.toFloat()
val h = v.height.toFloat()
// Keep the scaled view covering its cell: |tx| <= w*(s-1)/2.
val maxTx = w * (s - 1f) / 2f * 0.9f
val maxTy = h * (s - 1f) / 2f * 0.9f
val p = (t * 2f - 1f) // -1 .. 1 across the slide
v.scaleX = s
v.scaleY = s
v.translationX = dx * maxTx * p
v.translationY = dy * maxTy * p
}
}
animators += anim
anim.start()
}
}
fun release() {
animators.forEach { it.cancel() }
animators.clear()
player?.release()
player = null
}
}
companion object {
private const val TRANSITION_MS = 1_600L
private val TRANSITIONS = listOf("fade", "slide", "zoom")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,25 @@
<?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">
<!-- 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 -->
<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" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/settings_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:surface_type="texture_view"
app:use_controller="false"
app:show_buffering="never" />
@@ -0,0 +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" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Immich Screensaver</string>
<string name="cat_server">Servidor</string>
<string name="pref_server_url">URL del servidor Immich</string>
<string name="pref_server_url_hint">p. ej. https://immich.ejemplo.com</string>
<string name="pref_login">Iniciar sesión con email y contraseña</string>
<string name="pref_login_summary">Crea automáticamente una API key para esta TV</string>
<string name="pref_api_key">API key</string>
<string name="pref_api_key_hint">Pega una clave creada en Immich → Ajustes de cuenta → API keys</string>
<string name="pref_test_connection">Probar conexión</string>
<string name="summary_not_set">Sin configurar</string>
<string name="summary_api_key_set">Configurada</string>
<string name="summary_configure_server_first">Configura primero el servidor</string>
<string name="summary_loading_albums">Cargando álbumes…</string>
<string name="summary_all_albums">Todas las fotos</string>
<string name="summary_albums_error">No se pudieron cargar los álbumes: %1$s</string>
<string name="hint_email">Email</string>
<string name="hint_password">Contraseña</string>
<string name="login_message">Inicia sesión en %1$s. La contraseña solo se usa una vez para crear una API key.</string>
<string name="cat_content">Contenido</string>
<string name="pref_albums">Álbumes</string>
<string name="pref_content_type">Mostrar</string>
<string name="opt_photos_and_videos">Fotos y vídeos</string>
<string name="opt_photos_only">Solo fotos</string>
<string name="opt_videos_only">Solo vídeos</string>
<string name="pref_favorites_only">Solo favoritos</string>
<string name="pref_quality">Calidad de imagen</string>
<string name="opt_quality_preview">Vista previa (rápida, recomendada)</string>
<string name="opt_quality_fullsize">Tamaño completo (más lenta)</string>
<string name="cat_display">Presentación</string>
<string name="pref_interval">Tiempo por foto</string>
<string name="pref_motion">Movimiento</string>
<string name="opt_motion_kenburns">Paneo y zoom (Ken Burns)</string>
<string name="opt_motion_zoom">Zoom lento</string>
<string name="opt_motion_none">Ninguno</string>
<string name="pref_transition">Transición</string>
<string name="opt_transition_fade">Fundido</string>
<string name="opt_transition_slide">Deslizar</string>
<string name="opt_transition_zoom">Zoom</string>
<string name="opt_transition_random">Aleatoria</string>
<string name="opt_transition_cut">Ninguna</string>
<string name="pref_pair_portraits">Fotos verticales de dos en dos</string>
<string name="pref_pair_portraits_summary">Dos fotos verticales una al lado de la otra</string>
<string name="pref_scaling">Escalado</string>
<string name="opt_scaling_fill">Llenar pantalla (recorta)</string>
<string name="opt_scaling_fit">Ajustar con fondo difuminado</string>
<string name="pref_show_info">Mostrar fecha y lugar</string>
<string name="pref_show_clock">Mostrar reloj</string>
<string name="cat_video">Vídeo</string>
<string name="pref_mute_videos">Silenciar vídeos</string>
<string name="pref_max_video">Duración máxima de vídeo</string>
<string name="opt_whole_video">Reproducir el vídeo entero</string>
<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_dream_settings">Usar como salvapantallas del sistema</string>
<string name="pref_dream_settings_summary">Abre Ajustes de Android → Salvapantallas</string>
<string name="toast_connected_as">Conectado como %1$s</string>
<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_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_loading">Cargando fotos…</string>
</resources>
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="content_type_entries">
<item>@string/opt_photos_and_videos</item>
<item>@string/opt_photos_only</item>
<item>@string/opt_videos_only</item>
</string-array>
<string-array name="content_type_values" translatable="false">
<item>both</item><item>images</item><item>videos</item>
</string-array>
<string-array name="quality_entries">
<item>@string/opt_quality_preview</item>
<item>@string/opt_quality_fullsize</item>
</string-array>
<string-array name="quality_values" translatable="false">
<item>preview</item><item>fullsize</item>
</string-array>
<string-array name="interval_entries">
<item>5 s</item><item>10 s</item><item>15 s</item><item>20 s</item><item>30 s</item>
<item>45 s</item><item>1 min</item><item>2 min</item><item>5 min</item>
</string-array>
<string-array name="interval_values" translatable="false">
<item>5</item><item>10</item><item>15</item><item>20</item><item>30</item>
<item>45</item><item>60</item><item>120</item><item>300</item>
</string-array>
<string-array name="motion_entries">
<item>@string/opt_motion_kenburns</item>
<item>@string/opt_motion_zoom</item>
<item>@string/opt_motion_none</item>
</string-array>
<string-array name="motion_values" translatable="false">
<item>kenburns</item><item>zoom</item><item>none</item>
</string-array>
<string-array name="transition_entries">
<item>@string/opt_transition_fade</item>
<item>@string/opt_transition_slide</item>
<item>@string/opt_transition_zoom</item>
<item>@string/opt_transition_random</item>
<item>@string/opt_transition_cut</item>
</string-array>
<string-array name="transition_values" translatable="false">
<item>fade</item><item>slide</item><item>zoom</item><item>random</item><item>cut</item>
</string-array>
<string-array name="scaling_entries">
<item>@string/opt_scaling_fill</item>
<item>@string/opt_scaling_fit</item>
</string-array>
<string-array name="scaling_values" translatable="false">
<item>fill</item><item>fit</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>
<string-array name="max_video_values" translatable="false">
<item>15</item><item>30</item><item>60</item><item>120</item><item>0</item>
</string-array>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="settings_background">#FF101418</color>
<color name="ic_launcher_background">#FF1E1E2A</color>
</resources>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Immich Screensaver</string>
<string name="cat_server">Server</string>
<string name="pref_server_url">Immich server URL</string>
<string name="pref_server_url_hint">e.g. https://immich.example.com</string>
<string name="pref_login">Sign in with email and password</string>
<string name="pref_login_summary">Creates an API key for this TV automatically</string>
<string name="pref_api_key">API key</string>
<string name="pref_api_key_hint">Paste a key created in Immich → Account settings → API keys</string>
<string name="pref_test_connection">Test connection</string>
<string name="summary_not_set">Not set</string>
<string name="summary_api_key_set">Configured</string>
<string name="summary_configure_server_first">Configure the server first</string>
<string name="summary_loading_albums">Loading albums…</string>
<string name="summary_all_albums">All photos</string>
<string name="summary_albums_error">Could not load albums: %1$s</string>
<string name="hint_email">Email</string>
<string name="hint_password">Password</string>
<string name="login_message">Sign in to %1$s. The password is only used once to create an API key.</string>
<string name="cat_content">Content</string>
<string name="pref_albums">Albums</string>
<string name="pref_content_type">Show</string>
<string name="opt_photos_and_videos">Photos and videos</string>
<string name="opt_photos_only">Photos only</string>
<string name="opt_videos_only">Videos only</string>
<string name="pref_favorites_only">Favorites only</string>
<string name="pref_quality">Image quality</string>
<string name="opt_quality_preview">Preview (fast, recommended)</string>
<string name="opt_quality_fullsize">Full size (slower)</string>
<string name="cat_display">Slideshow</string>
<string name="pref_interval">Time per photo</string>
<string name="pref_motion">Motion</string>
<string name="opt_motion_kenburns">Pan and zoom (Ken Burns)</string>
<string name="opt_motion_zoom">Slow zoom</string>
<string name="opt_motion_none">None</string>
<string name="pref_transition">Transition</string>
<string name="opt_transition_fade">Crossfade</string>
<string name="opt_transition_slide">Slide</string>
<string name="opt_transition_zoom">Zoom</string>
<string name="opt_transition_random">Random</string>
<string name="opt_transition_cut">None</string>
<string name="pref_pair_portraits">Show portrait photos in pairs</string>
<string name="pref_pair_portraits_summary">Two vertical photos side by side</string>
<string name="pref_scaling">Scaling</string>
<string name="opt_scaling_fill">Fill screen (crop)</string>
<string name="opt_scaling_fit">Fit with blurred background</string>
<string name="pref_show_info">Show date and location</string>
<string name="pref_show_clock">Show clock</string>
<string name="cat_video">Video</string>
<string name="pref_mute_videos">Mute videos</string>
<string name="pref_max_video">Maximum video length</string>
<string name="opt_whole_video">Play whole video</string>
<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_dream_settings">Set as system screensaver</string>
<string name="pref_dream_settings_summary">Opens Android settings → Screen saver</string>
<string name="toast_connected_as">Connected as %1$s</string>
<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_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_loading">Loading photos…</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.Leanback">
<item name="preferenceTheme">@style/PreferenceThemeOverlayLeanback</item>
<item name="android:windowBackground">@color/settings_background</item>
</style>
<style name="SlideshowTheme" parent="android:Theme.Material.NoActionBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="cloud.alexandrevazquez.immichtv/.ui.MainActivity" />
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/app_name">
<PreferenceCategory android:title="@string/cat_server">
<EditTextPreference
android:key="server_url"
android:title="@string/pref_server_url"
android:dialogTitle="@string/pref_server_url"
android:dialogMessage="@string/pref_server_url_hint" />
<Preference
android:key="login"
android:title="@string/pref_login"
android:summary="@string/pref_login_summary" />
<EditTextPreference
android:key="api_key"
android:title="@string/pref_api_key"
android:dialogTitle="@string/pref_api_key"
android:dialogMessage="@string/pref_api_key_hint" />
<Preference
android:key="test_connection"
android:title="@string/pref_test_connection" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/cat_content">
<MultiSelectListPreference
android:key="albums"
android:title="@string/pref_albums"
android:dialogTitle="@string/pref_albums" />
<ListPreference
android:key="content_type"
android:title="@string/pref_content_type"
android:entries="@array/content_type_entries"
android:entryValues="@array/content_type_values"
android:defaultValue="both"
app:useSimpleSummaryProvider="true" />
<SwitchPreference
android:key="favorites_only"
android:title="@string/pref_favorites_only"
android:defaultValue="false" />
<ListPreference
android:key="quality"
android:title="@string/pref_quality"
android:entries="@array/quality_entries"
android:entryValues="@array/quality_values"
android:defaultValue="preview"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/cat_display">
<ListPreference
android:key="interval"
android:title="@string/pref_interval"
android:entries="@array/interval_entries"
android:entryValues="@array/interval_values"
android:defaultValue="20"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:key="motion"
android:title="@string/pref_motion"
android:entries="@array/motion_entries"
android:entryValues="@array/motion_values"
android:defaultValue="kenburns"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:key="transition"
android:title="@string/pref_transition"
android:entries="@array/transition_entries"
android:entryValues="@array/transition_values"
android:defaultValue="fade"
app:useSimpleSummaryProvider="true" />
<SwitchPreference
android:key="pair_portraits"
android:title="@string/pref_pair_portraits"
android:summary="@string/pref_pair_portraits_summary"
android:defaultValue="true" />
<ListPreference
android:key="scaling"
android:title="@string/pref_scaling"
android:entries="@array/scaling_entries"
android:entryValues="@array/scaling_values"
android:defaultValue="fill"
app:useSimpleSummaryProvider="true" />
<SwitchPreference
android:key="show_info"
android:title="@string/pref_show_info"
android:defaultValue="true" />
<SwitchPreference
android:key="show_clock"
android:title="@string/pref_show_clock"
android:defaultValue="true" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/cat_video">
<SwitchPreference
android:key="mute_videos"
android:title="@string/pref_mute_videos"
android:defaultValue="true" />
<ListPreference
android:key="max_video"
android:title="@string/pref_max_video"
android:entries="@array/max_video_entries"
android:entryValues="@array/max_video_values"
android:defaultValue="60"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/cat_screensaver">
<Preference
android:key="preview"
android:title="@string/pref_preview"
android:summary="@string/pref_preview_summary" />
<Preference
android:key="dream_settings"
android:title="@string/pref_dream_settings"
android:summary="@string/pref_dream_settings_summary" />
</PreferenceCategory>
</PreferenceScreen>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.20" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Builds a signed release APK. Keystore password lives in pass (android/immichtv-keystore-password).
set -euo pipefail
cd "$(dirname "$0")/.."
export ANDROID_HOME="${ANDROID_HOME:-$HOME/android-sdk}"
export IMMICHTV_KEYSTORE="${IMMICHTV_KEYSTORE:-$HOME/.android/immichtv-release.jks}"
export IMMICHTV_KEYSTORE_PASSWORD="$(pass show android/immichtv-keystore-password | head -1)"
./gradlew assembleRelease --no-daemon -q
ls -la app/build/outputs/apk/release/app-release.apk
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ImmichTV"
include(":app")