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:
+10
@@ -0,0 +1,10 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
*.jks
|
||||
*.keystore
|
||||
signing.properties
|
||||
app/release/
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Executable
+9
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user