diff --git a/.tmp-gradle-run/escalated/gradle-run/e76e2be01f9477bf3dae4d4f5582546f.finished b/.tmp-gradle-run/escalated/gradle-run/e76e2be01f9477bf3dae4d4f5582546f.finished new file mode 100644 index 0000000..2805836 --- /dev/null +++ b/.tmp-gradle-run/escalated/gradle-run/e76e2be01f9477bf3dae4d4f5582546f.finished @@ -0,0 +1 @@ +e76e2be01f9477bf3dae4d4f5582546f diff --git a/.tmp-gradle-run/escalated/gradle-run/e76e2be01f9477bf3dae4d4f5582546f.lock b/.tmp-gradle-run/escalated/gradle-run/e76e2be01f9477bf3dae4d4f5582546f.lock new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.tmp-gradle-run/escalated/gradle-run/e76e2be01f9477bf3dae4d4f5582546f.lock differ diff --git a/.tmp-gradle-run/escalated/hsperfdata_Admin/25704 b/.tmp-gradle-run/escalated/hsperfdata_Admin/25704 new file mode 100644 index 0000000..92ddd7b Binary files /dev/null and b/.tmp-gradle-run/escalated/hsperfdata_Admin/25704 differ diff --git a/.tmp-gradle-run/escalated/hsperfdata_Admin/4000 b/.tmp-gradle-run/escalated/hsperfdata_Admin/4000 new file mode 100644 index 0000000..8e7115a Binary files /dev/null and b/.tmp-gradle-run/escalated/hsperfdata_Admin/4000 differ diff --git a/.tmp-gradle-run/escalated/jna-63116079/jna4079305537621798898.dll b/.tmp-gradle-run/escalated/jna-63116079/jna4079305537621798898.dll new file mode 100644 index 0000000..bcca391 Binary files /dev/null and b/.tmp-gradle-run/escalated/jna-63116079/jna4079305537621798898.dll differ diff --git a/.tmp-gradle-run/escalated/jna-63116079/jna9392113226132068809.dll b/.tmp-gradle-run/escalated/jna-63116079/jna9392113226132068809.dll new file mode 100644 index 0000000..bcca391 Binary files /dev/null and b/.tmp-gradle-run/escalated/jna-63116079/jna9392113226132068809.dll differ diff --git a/.tmp-gradle-run/escalated/jna-63116079/jna9392113226132068809.dll.x b/.tmp-gradle-run/escalated/jna-63116079/jna9392113226132068809.dll.x new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-gradle-run/escalated/kotlin-compiler-in-emby-14545020421804070614.alive b/.tmp-gradle-run/escalated/kotlin-compiler-in-emby-14545020421804070614.alive new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-gradle-run/escalated/kotlin-daemon.2026-08-22.06-26-48-841.00.log b/.tmp-gradle-run/escalated/kotlin-daemon.2026-08-22.06-26-48-841.00.log new file mode 100644 index 0000000..a698037 --- /dev/null +++ b/.tmp-gradle-run/escalated/kotlin-daemon.2026-08-22.06-26-48-841.00.log @@ -0,0 +1,12 @@ +2026-08-22 06:26:48.954 [daemon] INFO: Kotlin compiler daemon version 2.2.10-release-430 +2026-08-22 06:26:49.155 [daemon] INFO: daemon JVM args: -Djava.awt.headless=true -Djava.rmi.server.hostname=127.0.0.1 -Xmx2048m -XX:ReservedCodeCacheSize=320m -Dkotlin.environment.keepalive -ea -XX:+UseCodeCacheFlushing -XX:+UseParallelGC -Dkotlin.daemon.initiator.marker.file=C:\Users\Matt\emby-client\.tmp-gradle-run\escalated\kotlin-compiler-in-emby-14545020421804070614.alive --add-exports=java.base/sun.nio.ch=ALL-UNNAMED +2026-08-22 06:26:49.201 [daemon] INFO: daemon args: --daemon-runFilesPath C:\Users\Matt\AppData\Local\kotlin\daemon --daemon-autoshutdownIdleSeconds=7200 --daemon-compilerClasspath C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-compiler-embeddable\2.2.10\6882d39ef03b18879c1a5d2a83c2212361bed5fe\kotlin-compiler-embeddable-2.2.10.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-stdlib\2.2.10\30de6faa127a4a012db8e71bf1b9c0a99b1402b2\kotlin-stdlib-2.2.10.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-script-runtime\2.2.10\712f9bc08f378c13a4a00f0bbb28c76e59183e83\kotlin-script-runtime-2.2.10.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-reflect\1.6.10\1cbe9c92c12a94eea200d23c2bbaedaf3daf5132\kotlin-reflect-1.6.10.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-daemon-embeddable\2.2.10\212fd3c2c6e4e5d0f4db3c4191233e2b622380cc\kotlin-daemon-embeddable-2.2.10.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlinx\kotlinx-coroutines-core-jvm\1.8.0\ac1dc37a30a93150b704022f8d895ee1bd3a36b3\kotlinx-coroutines-core-jvm-1.8.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains\annotations\13.0\919f0dfe192fb4e063e7dacadee7f8bb9a2672a9\annotations-13.0.jar +2026-08-22 06:26:49.320 [daemon] INFO: starting daemon +2026-08-22 06:26:49.753 [compiler] INFO: Registered a client alive file: C:\Users\Matt\emby-client\.tmp-gradle-run\escalated\kotlin-compiler-in-emby-14545020421804070614.alive +2026-08-22 06:26:49.754 [compiler] INFO: Periodic liveness check activities configured +2026-08-22 06:26:49.758 [daemon] INFO: daemon is listening on port: 17097 +2026-08-22 06:26:49.763 [compiler] INFO: initiate elections +2026-08-22 06:26:50.435 [compiler] INFO: getDaemonJVMOptions: DaemonJVMOptions(maxMemory=2048m, maxMetaspaceSize=, reservedCodeCacheSize=320m, jvmParams=[-Djava.awt.headless=true, -Djava.rmi.server.hostname=127.0.0.1, -Dkotlin.environment.keepalive, -ea, -XX:+UseCodeCacheFlushing, -XX:+UseParallelGC, -Dkotlin.daemon.initiator.marker.file=C:\Users\Matt\emby-client\.tmp-gradle-run\escalated\kotlin-compiler-in-emby-14545020421804070614.alive, --add-exports=java.base/sun.nio.ch=ALL-UNNAMED, Dkotlin.environment.keepalive, ea]) +2026-08-22 06:26:50.450 [compiler] INFO: Registered a client alive file: C:\Users\Matt\emby-client\.tmp-gradle-run\escalated\kotlin-compiler-in-emby-14545020421804070614.alive +2026-08-22 06:26:50.455 [compiler] INFO: leased a new session 1, session alive file: C:\Users\Matt\emby-client\.kotlin\sessions\kotlin-compiler-11810756879782999173.salive +2026-08-22 06:26:50.679 [compiler] INFO: Starting compilation with args: -Xallow-no-source-files -classpath C:\Users\Matt\AppData\Local\Android\Sdk\platforms\android-37.0\android.jar;C:\Users\Matt\AppData\Local\Android\Sdk\build-tools\36.0.0\core-lambda-stubs.jar;C:\Users\Matt\emby-client\app\build\intermediates\compile_r_class_jar\debug\generateDebugRFile\R.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\fcd33e72a0f24c5d5ff7de2c496a3472\transformed\tv-material-1.1.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\ef05ba05310ee15495d8113051398841\transformed\coil-compose-2.7.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\7ad293321b1532bb2660224d3abfe0ca\transformed\coil-compose-base-2.7.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\f6c102c3d337ae006351dbccc5862a8d\transformed\animation-core-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\52e5d0bb5d24075c4941608ebc281994\transformed\animation-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\378189ed27d712920ae32c5c4f7f5679\transformed\foundation-layout-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\9a5fe74ff49b56e408063f2ff8640e0d\transformed\foundation-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\2942c132ece0e27bee2c67202aa66e85\transformed\ui-tooling-data-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\1a7f1832449474f46b4932d8cbffd621\transformed\ui-unit-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\62c2b2dc69e915f9cba5b6ab8b544104\transformed\ui-geometry-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\566887c084caaf13cbcd2f062cab35c9\transformed\ui-util-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\7477cc8e8242deea6db05821dea84a5f\transformed\ui-text-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\a5b23bdae9677c807a0c669802b8fd6b\transformed\ui-graphics-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\1aaaf133bb0649b46f01704f896c584c\transformed\ui-tooling-preview-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\08da316e556b4ad4a7c4d445859b5df2\transformed\ui-tooling-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\8b1230e0134613d5da1b3fc8ca6d2922\transformed\material-icons-extended-release-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\2c94f99705994c4d7bc1ed82e444b196\transformed\material-icons-core-release-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\9fe0a77b492008f3a34a8977cedd1a7c\transformed\ui-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\5d9abe8ed55b4421eceb0fc006d46cd6\transformed\ui-test-manifest-1.11.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\ba2120f8063ff4462426152f0cdb27f0\transformed\activity-1.13.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\7bec91aa2f38b98a628b79f24b87d2a2\transformed\activity-compose-1.13.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\96e3b5615e0a210f93f0caec5ef024e7\transformed\work-runtime-ktx-2.11.2-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\ef867c2232cbdc796efa9b077ce63119\transformed\work-runtime-2.11.2-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\574995c386bd33e3ed7ed2b94a935866\transformed\tvprovider-1.1.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\eb7be4bf625539c638d624ca33b4032e\transformed\lifecycle-livedata-core-ktx-2.11.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\359fd2579e5e29831a6090184b86aa66\transformed\lifecycle-livedata-core-2.11.0-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\androidx.lifecycle\lifecycle-common-jvm\2.11.0\2c4f7d92f1fd723121d394c93455f50158403ad9\lifecycle-common-jvm-2.11.0.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\6591c3fb11f423b821bb096f41c499f3\transformed\lifecycle-viewmodel-savedstate-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\895b7630969bba367a4b1c5e12124895\transformed\core-ktx-1.18.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\582b2ac83812cff66db9a4d73ab5c6bb\transformed\core-1.18.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\e583c44ab1eeec8e706ebeb617629782\transformed\coil-2.7.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\fb28eeb249c82bb1e5d24f5ec8ff5a62\transformed\coil-base-2.7.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\f05bbdb281a7d4f2c6d728ab1437396d\transformed\lifecycle-runtime-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\ac7e039a805f07906a8b54a7e72d92f7\transformed\lifecycle-viewmodel-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\cf43c9ba4f04feb1af2776d42d941a16\transformed\lifecycle-livedata-2.11.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\a177a8a04b2226cd1aa0ce411818c4a3\transformed\lifecycle-runtime-compose-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\766bc57201133b91446c725b40463b09\transformed\lifecycle-viewmodel-compose-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\66105a40f5a431d14ca76d737a595689\transformed\lifecycle-runtime-ktx-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\f3095c0bcb4b9931e29651d3789f3d18\transformed\lifecycle-process-2.11.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\e874e3c919968417a54eb38016b94851\transformed\navigationevent-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\56fc4a76d72e65150ec9b998ab60e909\transformed\navigationevent-compose-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\d6febd1a2f6849d2e6c0fcdffb22a704\transformed\runtime-annotation-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\bd9f2284b6fd8746fe4217be7a859be7\transformed\runtime-retain-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\ea979b01b0e53ab83254bb3c2afaed44\transformed\runtime-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\7d491c83456b76ed261b6248888ea36b\transformed\runtime-saveable-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\963179fbcc1126e664ceacb523152cc6\transformed\savedstate-compose-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\2526b9bc87fe714b70dbc94e560a72e4\transformed\savedstate-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\276366275336425799df9b70b4c26c34\transformed\savedstate-ktx-1.4.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\c19d11737aeedf5cd4e7c9bc87406b95\transformed\icons-lucide-cmp-debug-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\09add3c03b72abbfc4ff2d3d6c7541a3\transformed\metrics-performance-1.0.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\d22c9dcc74d1e5d42c84e0d152428b05\transformed\tracing-1.2.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\51429d69cf8ecb05d353b3e4bcfea176\transformed\tracing-ktx-1.2.0-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\19605f87701560d9f44e22695123647c\transformed\media3-exoplayer-hls-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\b105b83aa8ce22a3de74a3a1a210d9ee\transformed\media3-ffmpeg-decoder-1.9.0+1-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\c82a73d80b5dcab9e3aa978dbe9b49d1\transformed\media3-exoplayer-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\0e6991b0ae13e5ce80ccb94d758af990\transformed\icons-font-awesome-solid-cmp-debug-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.squareup.okhttp3\logging-interceptor\4.12.0\e922c1f14d365c0f2bed140cc0825e18462c2778\logging-interceptor-4.12.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.jakewharton.retrofit\retrofit2-kotlinx-serialization-converter\1.0.0\99f70a2daf3256f048893c79a2b795f476f1789f\retrofit2-kotlinx-serialization-converter-1.0.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.squareup.retrofit2\retrofit\2.11.0\6ca8c6caf842271f3232e075519fe04081ef7069\retrofit-2.11.0.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\09b4aad7158d293bd1591887a8d43778\transformed\media3-datasource-okhttp-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.squareup.okhttp3\okhttp\4.12.0\2f4525d4a200e97e1b87449c2cd9bd2e25b7e8cd\okhttp-4.12.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-stdlib-jdk8\1.8.21\67f57e154437cd9e6e9cf368394b95814836ff88\kotlin-stdlib-jdk8-1.8.21.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlinx\kotlinx-serialization-core-jvm\1.7.3\1f226780b845ff9206474c05159245d861556249\kotlinx-serialization-core-jvm-1.7.3.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlinx\kotlinx-serialization-json-jvm\1.7.3\6701e8c68d9e82387ce72ee96e8ddf058208d58f\kotlinx-serialization-json-jvm-1.7.3.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\androidx.datastore\datastore-core-okio-jvm\1.2.1\16e01a0603ce8cf206b21a304ff1ded7a9bff1da\datastore-core-okio-jvm-1.2.1.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\7c62f251f9c337730d3507248dc7a14b\transformed\datastore-core-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\0e20609b7a5303b553fba77891b0ed6b\transformed\datastore-preferences-core-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\1aa6c4c0f40078d4ce7191bfc4933ed5\transformed\datastore-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\8244a188ff03dafad052672fc5c3c797\transformed\datastore-preferences-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\773860ff5b6ac31a51eafe8e0f1ccf1e\transformed\core-viewtree-1.0.0-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlinx\kotlinx-coroutines-core-jvm\1.9.0\9beade4c1c1569e4f36cbd2c37e02e3e41502601\kotlinx-coroutines-core-jvm-1.9.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlinx\kotlinx-coroutines-android\1.9.0\6cf3b8df5a2ce8c6b0dffe55657a827c405ee2fe\kotlinx-coroutines-android-1.9.0.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\e69e161091c198a6ed2d314bab8c0b2b\transformed\media3-ui-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\72268b58d1cc7ec684064780ae75bff4\transformed\media3-extractor-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\a42431b82947ec35d737ade7b816cc06\transformed\media3-container-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\78d8c9b0e11b9c780cefb5bd368cc058\transformed\media3-datasource-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\fe310b25b0f2be5d2aa4acc0f8b26a8d\transformed\media3-decoder-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\8e96d8a22e15e9c9c06ea63b91b67df9\transformed\media3-database-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\213f8391964996895059935c91de1b44\transformed\media3-common-1.9.4-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\416b63a7e1e29134f2be962586a8d21b\transformed\annotation-experimental-1.4.1-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\50d0091183a9731c356aee45fa99b1c1\transformed\versionedparcelable-1.1.1-api.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\ceee13b578c114d6ab3143046ec48e8d\transformed\core-runtime-2.2.0-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\androidx.arch.core\core-common\2.2.0\5e1b8b81dfd5f52c56a8d53b18ca759c19a301f3\core-common-2.2.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\androidx.collection\collection-jvm\1.5.0\7ba2c69414d46ebc2dd76598bdd0a75c54281a57\collection-jvm-1.5.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\androidx.annotation\annotation-jvm\1.9.1\b17951747e38bf3986a24431b9ba0d039958aa5f\annotation-jvm-1.9.1.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-stdlib-jdk7\1.8.21\7473b8cd3c0ef9932345baf569bc398e8a717046\kotlin-stdlib-jdk7-1.8.21.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\c4d56c67f744eed2c1d2fa33cc09d4e5\transformed\icons-font-awesome-cmp-debug-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.squareup.okio\okio-jvm\3.9.1\79696e3f4ff05d41ba2b3dc68adb5808a2e5dc53\okio-jvm-3.9.1.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-stdlib\2.2.21\fa374a986e128314c3db00a20aae55f72a258511\kotlin-stdlib-2.2.21.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\98097b476cfbb491663c2e2453aefce7\transformed\profileinstaller-1.4.1-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains\annotations\23.0.0\8cc20c07506ec18e0834947b84a864bfc094484e\annotations-23.0.0.jar;C:\Users\Matt\.gradle\caches\9.5.0\transforms\ed5d42cd74a99179745b5a1c59d63761\transformed\startup-runtime-1.1.1-api.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.google.guava\guava\33.3.1-android\36162b3b4e9df67dae90db62a2c54616ca42c1bd\guava-33.3.1-android.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.google.guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\b421526c5f297295adef1c886e5246c39d4ac629\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jspecify\jspecify\1.0.0\7425a601c1c7ec76645a78d22b8c6a627edee507\jspecify-1.0.0.jar;C:\Users\Matt\.gradle\caches\modules-2\files-2.1\com.google.guava\failureaccess\1.0.2\c4a06a64e650562f30b7bf9aaec1bfed43aca12b\failureaccess-1.0.2.jar -d C:\Users\Matt\emby-client\app\build\tmp\kotlin-classes\debug -jvm-target 17 -module-name app_debug -no-jdk -no-reflect -no-stdlib -Xplugin=C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-compose-compiler-plugin-embeddable\2.2.10\fd3cd12d4e9702cfcd731120564af86814cf95b7\kotlin-compose-compiler-plugin-embeddable-2.2.10.jar,C:\Users\Matt\.gradle\caches\modules-2\files-2.1\org.jetbrains.kotlin\kotlin-serialization-compiler-plugin-embeddable\2.2.10\8410bb1b4213e690e29954abbba401af599afafc\kotlin-serialization-compiler-plugin-embeddable-2.2.10.jar -P plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true,plugin:androidx.compose.compiler.plugins.kotlin:traceMarkersEnabled=true -Xuse-inline-scopes-numbers -Xallow-unstable-dependencies C:\Users\Matt\emby-client\app\build\generated\source\buildConfig\debug\com\ponzischeme89\memby\BuildConfig.java C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\analytics\JourneyAnalytics.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\analytics\JourneySink.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\analytics\JourneyTracker.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\analytics\PlaybackEntryPoint.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\analytics\PlaybackJourney.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\analytics\RowAnalytics.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\CastMetadata.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\ClientTrailerResolver.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\ContinueWatching.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\Credits.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\EmbyRepository.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\GenreBrowse.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\ImageCache.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\Intro.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\LocalDays.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\MaintenanceMonitor.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\model\EmbyModels.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\model\GatewayModels.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\model\Ratings.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\playback\AudioCapabilityProbe.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\playback\DeviceAudioCapabilities.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\playback\DevicePlaybackCapabilities.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\PreferencesSync.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remote\EmbyApi.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remote\EmbyServiceFactory.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remote\GatewayApi.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remote\GatewayServiceFactory.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remote\HttpStack.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remote\StreamWarmer.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remote\TrickplayClient.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\remoteconfig\MembyRemoteConfig.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\SearchRepository.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\SeekPreference.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\SeriesPace.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\ServerConfig.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\SettingsStore.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\SkipIntroPreference.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\SubtitleSupport.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\Themes.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\ThemeSync.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\TrailerSupport.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\Trickplay.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\data\UserPreferences.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\diagnostics\MembyDiagnostics.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\MembyApp.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\performance\PerformanceMonitor.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\performance\StartupTrace.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\screensaver\MembyDreamService.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ServiceLocator.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\tvhome\HomeChannelPublisher.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\tvhome\TvDeepLinkActivity.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\tvhome\TvDeepLinks.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\tvhome\TvHomeIntegrationService.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\tvhome\TvHomeProgram.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\tvhome\WatchNextPublisher.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\alerts\AlertsFormat.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\alerts\AlertsPage.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\alerts\AlertsPaging.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\AppRoot.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\calendar\CalendarAgenda.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\calendar\CalendarGrid.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\calendar\CalendarScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\calendar\CalendarViewModel.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\FocusScaleContainer.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\ContinueWatchingCard.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\LandscapeCard.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\MediaBadges.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\MediaCardMetadata.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\MediaCardSizing.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\MediaLoading.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\MediaResultCard.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\components\media\PosterCard.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\detail\AiringNotice.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\detail\CastGrid.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\detail\DetailCardFocus.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\detail\DetailFacts.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\detail\DetailHeroPhases.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\detail\DetailPosition.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\detail\DetailTabs.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\DetailPageComponents.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\EmbyOutageBanner.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\EpisodeDetailsOverlay.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\ExitMembyScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\genre\GenreBrowseScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\genre\GenreBrowseViewModel.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\genre\GenreCategories.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\genre\GenrePersonalisation.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\home\HomeMetadataPanel.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\home\HomeNavigation.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\home\HomeQuickActions.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\home\HomeRow.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\home\HomeRowBrowse.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\HomeGreeting.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\HomeMovieHero.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\HomeOverlays.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\HomeScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\HomeViewModel.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\InstallPermissionScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\LaunchPreroll.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\ListKeys.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\MainActivity.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\MaintenanceScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\MediaDetailsOverlay.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\MediaReportControl.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\MembyButtons.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\MetadataHero.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\MyShowsNotifications.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\Onboarding.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\AudioPassthrough.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\CastPanel.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\CreditsCountdown.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\CreditsSpeed.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\NextEpisodePreloader.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\NextUpPipeline.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlaybackIdentity.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlaybackIdentityLogo.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlaybackRecovery.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlaybackStopWorker.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlaybackTrace.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlaybackTraceSections.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlayerActivity.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PlayerEngine.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PreloadPlan.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PrerollCountdownView.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\PrerollPreloader.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\SeekControls.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\SkipIntroCountdownView.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\SubtitleMenu.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\player\TrickplayPreview.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\PreviewSupport.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\profiles\ProfileChooser.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\ProfileViewModelStoreOwner.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\QuickActionsNavigation.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\RadarrMovieDetailsOverlay.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\RatingsStrip.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\RemoteLongPress.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\requests\RequestCards.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\requests\RequestPresentation.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\requests\RequestsScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\requests\RequestsViewModel.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\ResumableMediaCard.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\RowNavigation.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\screensaver\EmbyAppLauncher.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\screensaver\ScreensaverActivity.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\screensaver\ScreensaverContent.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\search\components\SearchGenres.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\search\components\SearchResults.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\search\SearchScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\search\SearchViewModel.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\seasonal\SeasonalDecorations.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\SeriesDetailsOverlay.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\ServiceAlertBanner.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\settings\SettingsSheet.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\settings\VersionHistory.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\setup\SignInContent.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\theme\DesignTokens.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\theme\MembyIconPackFontAwesome.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\theme\MembyIconPackLucide.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\theme\MembyIconPacks.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\theme\MembyIcons.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\theme\Theme.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\TitleLogo.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\UpdateScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\UserQuickActions.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\UserSwitcherNavigation.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\viewers\ViewerControls.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\viewers\ViewerEditing.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\viewers\ViewerManageScreen.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\viewers\ViewerNameEntry.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\viewers\ViewerPicker.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\viewers\ViewerSelection.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\WelcomeQuotes.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\ui\whatsnew\WhatsNew.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\InstallPermission.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\InstallResultReceiver.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\RequiredUpdateGuard.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\RequiredUpdateSignal.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\RequiredUpdateState.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\ServerUpdateService.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\UpdateChecker.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\UpdateCheckSchedule.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\UpdateModels.kt C:\Users\Matt\emby-client\app\src\main\java\com\ponzischeme89\memby\update\UpdateRecoveryReceiver.kt diff --git a/.tmp-gradle-run/escalated/kotlin-daemon.2026-08-22.06-26-48-841.00.log.lck b/.tmp-gradle-run/escalated/kotlin-daemon.2026-08-22.06-26-48-841.00.log.lck new file mode 100644 index 0000000..e69de29 diff --git a/CHANGELOG.md b/CHANGELOG.md index 566a3c9..65fb783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,78 @@ +## 0.2.98 - 2026-08-22 +- v298 closes the following issues: 116 (Feature: Integrate Memby with Android TV Home Screen), 117 (Improve Row Navigation & Add “View All” Actions) and 115 (Add ratings strip to movie continue watching). +- Added: Continue Watching support on the Google TV launcher homescreen (116) +- Added: Ratings strip to continue watching cards (115) +- Added: Buttons to rows which can take you back to main genre (117). +- Improved: Cleaned up genres chips row on search page. +- Improved: Switched cards to list cards for Search. +- Fixed: RequeststCards.kt shares its component with Search. +- Improved: Bug fixes & improvements to ResumeableMediaCard.kt, ContinueWatchingCard.kt +- Updated: Server updated to v0.1.64 +- Publishes partially watched movies/episodes and Next Up episodes through Android TV’s native Watch Next API. +- Updates launcher progress from existing playback reports. +- Removes completed, stale, deleted, duplicate, signed-out, or previous-user entries. +- Launcher cards deep-link through a validated activity directly into PlayerActivity, resuming from current Emby/Memby state. +- Uses appropriate episode, series, season, runtime, description, and backdrop/poster metadata. +- Keeps provider failures and offline reconciliation completely supplementary to playback. +- Added an isolated tvhome architecture: +- app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeIntegrationService.kt +- app/src/main/java/com/ponzischeme89/memby/tvhome/WatchNextPublisher.kt +- app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinkActivity.kt +- app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinks.kt +- app/src/main/java/com/ponzischeme89/memby/tvhome/HomeChannelPublisher.kt + +## 0.2.97 - 2026-08-21 +- Improved: Modularised MembyButtons, ProfileChooser, SettingsSheet, ViewerControls +- Added: Traliers no longer show controls that are outside of our control (e.g. Youtube) +- Improved: Simplified the Emby exit screen prompt. Moved to EmbyMembyScreen.kt + +## 0.2.96 - 2026-08-21 +- Improved: Refactor EmbyRepo +- Improved: HomeNavigation.kt +- Added: ServiceLocator.kt + +## 0.2.95 - 2026-08-21 +- Improved: MetadataHero.kt +- Added: MetadataHero.kt opts to Preferences + +## 0.2.94 - 2026-08-21 +- Improved: MetadataHero.kt + +## 0.2.93 - 2026-08-21 +- Fixed: Bugs with HomeOverlay + ## 0.2.92 - 2026-08-21 - Improved: App design / layout tweaks. - Improved: Many behind the scenes tweaks, bug fixes to make the app run smoother. - Improved: Gateway updated to version 0.1.63. +## 0.2.91 - 2026-08-21 +- Fixed: Bugs with HomeComponents/HomeScreen +- Fixed: Bugs with FocusScaleContainer +- Improved: LandscapeCard, ContinueWatchingCard, MediaBadges, MediaCardMetadata, MediaCardSizing, MediaLoading, PosterCard + +## 0.2.90 - 2026-08-21 +- Fixed: Padding fixes with HomeComponents.kt + +## 0.2.89 - 2026-08-21 +- Fixed: Responsive row sizing fixes + +## 0.2.88 - 2026-08-21 +- Fixed: More bug fixes to HomeComponents.kt, HomeScreen.kt. +- Tested: Tests passed with HomeMovieHeroTest. + +## 0.2.87 - 2026-08-21 +- Fixed: Bugs with HomeComponents.kt +- Fixed: Bugs with HomeOverlays.kt +- Fixed: Bugs with HomeScreen.kt + +## 0.2.86 - 2026-08-21 +- Fixed: Bugs with HomeComponents + +## 0.2.85 - 2026-08-21 +- Fixed: Bug with LaunchPreroll +- Fixed: Bug fixes to PrerollPreLoader +- Fixed: Bug which prevented ResumableMediaCardTest from firing ## 0.2.84 - 2026-08-21 - Improved: Loading and performance of the continue watching row. diff --git a/CLAUDE.md b/CLAUDE.md index a8f8763..1475437 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -708,7 +708,9 @@ of a copy, wrong the moment Tracearr corrects a session. It is read two ways: about a title somebody abandoned, and a feed carrying those is one nobody opens. **Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed -6×6 on-screen keyboard on the left, a results grid on the right that updates as you type. +6×6 on-screen keyboard on the left, with `SearchGenres` above `SearchResults` on the right. +The results list updates as you type and uses the same shared `MediaResultCard` presentation +as My Requests; request status and actions remain feature-owned slots around that shared row. Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` → `trim` → `distinctUntilChanged` → `collectLatest { repository.search(it) }` — and `collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow @@ -785,17 +787,17 @@ at a time. Things to preserve: the server is the same judgement from the other side, for an Emby that did not count. - **A page is appended by its own offset**, never by order of arrival: a response for an offset already scrolled past, or for a genre the viewer has left, is dropped rather than - pasted into the middle of the grid. A page that fails part way down keeps what is on + pasted into the middle of the list. A page that fails part way down keeps what is on screen and simply stops paging. - **The scroll trigger is a `snapshotFlow`, not a composable read.** The last visible index - changes on every frame of a scroll, and reading it in the body would recompose the grid + changes on every frame of a scroll, and reading it in the body would recompose the list the whole way down a genre — the same rule as the animation one above. It asks - `LOAD_MORE_ROWS_AHEAD` rows early, since a request that starts when the viewer arrives at + several items early, since a request that starts when the viewer arrives at the end is one they watch. -- **Something has to take the focus the chip was holding**, because opening a shelf unmounts - the whole discovery pane. The grid claims it when the first page lands, and an empty or - failed genre hands it back to the keyboard rather than leaving a television with nothing - focused. +- **Genre and result focus targets are separate.** The genre strip stays mounted above the + results; a genre result claims its own requester when the first page lands, Back restores + the strip, and an empty or failed genre hands focus back deterministically rather than + leaving a television with nothing focused. - **Episodes are excluded on both paths.** An episode inherits its series' genres, so including them fills a page with twenty entries of one comedy and buries the rest. - **Movies and TV Series have their own full-width genre browser.** A fixed row of colourful @@ -862,9 +864,9 @@ at a time. Things to preserve: missing the route, and search results pasted onto the end of a genre would be nonsense. Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the -rightmost goes to the results grid, the grid's first column goes back to the *last key -used* (a `FocusRequester` attached to whichever key that is), and the grid has a -`focusRestorer`. Back moves results → keyboard → genre → leave, one step per press. +rightmost goes to Genres, Down enters Search Results, and every result goes back to the +*last key used* (a `FocusRequester` attached to whichever key that is). Back closes an open +genre shelf before moving results → keyboard → leave, one step per press. Physical keyboards and phone-remote apps feed the same state through one `onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back must fall through. The voice button needs the `android.speech.RecognitionService` entry in diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90e0ae8..f6303fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,7 +64,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.97" +val defaultVersionName = "0.2.98" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() @@ -275,6 +275,8 @@ dependencies { // Compose for TV implementation("androidx.tv:tv-material:1.1.0") + // Android TV's system-owned Watch Next row and optional app-owned home channels. + implementation("androidx.tvprovider:tvprovider:1.1.0") // Image loading implementation("io.coil-kt:coil-compose:2.7.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 323ff76..45cef05 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + + + + + + + = Build.VERSION_CODES.O + + fun eligibleRows(rows: List): List = rows.filter { row -> + row.items.isNotEmpty() && row.kind.lowercase() !in WATCH_NEXT_KINDS + } + + private companion object { + val WATCH_NEXT_KINDS = setOf("continue", "nextup", "next_up") + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinkActivity.kt b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinkActivity.kt new file mode 100644 index 0000000..98754fe --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinkActivity.kt @@ -0,0 +1,61 @@ +package com.ponzischeme89.memby.tvhome + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.analytics.PlaybackEntryPoint +import com.ponzischeme89.memby.ui.player.PlayerActivity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** Narrow exported trampoline for launcher-owned deep links; [PlayerActivity] stays private. */ +class TvDeepLinkActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val itemId = TvDeepLinks.itemId(intent.data) + val requestedProfile = TvDeepLinks.profileKey(intent.data) + if (itemId == null || requestedProfile == null) { + finish() + return + } + + lifecycleScope.launch { + val current = withTimeoutOrNull(SETTINGS_TIMEOUT_MS) { + ServiceLocator.settings.settingsFlow.first() + } + val profile = current?.let(::tvHomeProfile) + if (profile?.key != requestedProfile) { + finish() + return@launch + } + + val item = runCatching { ServiceLocator.repository.getItemDetails(itemId) }.getOrNull() + if (item == null || item.userData?.played == true || (!item.isMovie && !item.isEpisode)) { + finish() + return@launch + } + val request = runCatching { ServiceLocator.repository.playbackRequest(item) }.getOrNull() + if (request == null) { + finish() + return@launch + } + startActivity( + PlayerActivity.intent( + context = this@TvDeepLinkActivity, + request = request, + posterUrl = ServiceLocator.repository.primaryUrl(item, maxWidth = 500), + backdropUrl = ServiceLocator.repository.backdropUrl(item, maxWidth = 1920) + ?: ServiceLocator.repository.primaryUrl(item, maxWidth = 1920), + journeySource = PlaybackEntryPoint.TV_HOME.id, + ), + ) + finish() + } + } + + private companion object { + const val SETTINGS_TIMEOUT_MS = 5_000L + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinks.kt b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinks.kt new file mode 100644 index 0000000..31882e7 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinks.kt @@ -0,0 +1,23 @@ +package com.ponzischeme89.memby.tvhome + +import android.net.Uri + +internal object TvDeepLinks { + fun playback(itemId: String, profileKey: String): Uri = Uri.Builder() + .scheme("memby") + .authority("play") + .appendPath(itemId) + .appendQueryParameter("profile", profileKey) + .build() + + fun itemId(uri: Uri?): String? = uri + ?.takeIf { it.scheme == "memby" && it.host == "play" } + ?.pathSegments + ?.singleOrNull() + ?.takeIf(String::isNotBlank) + + fun profileKey(uri: Uri?): String? = uri + ?.takeIf { it.scheme == "memby" && it.host == "play" } + ?.getQueryParameter("profile") + ?.takeIf(String::isNotBlank) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeIntegrationService.kt b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeIntegrationService.kt new file mode 100644 index 0000000..25b3cbd --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeIntegrationService.kt @@ -0,0 +1,141 @@ +package com.ponzischeme89.memby.tvhome + +import android.content.Context +import com.ponzischeme89.memby.data.EmbyRepository +import com.ponzischeme89.memby.data.PlaybackPosition +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.SettingsStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap + +/** + * Process-scoped bridge between Memby's repositories and Android TV's launcher provider. + * Every operation is best-effort: a missing provider, a launcher defect, or an offline + * server can cost a launcher card but can never delay or fail playback. + */ +class TvHomeIntegrationService internal constructor( + context: Context, + private val repository: EmbyRepository, + private val settings: SettingsStore, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val publisher = WatchNextPublisher(context) + private val reconciliation = Mutex() + private val latestPositions = ConcurrentHashMap() + private val completedItems = ConcurrentHashMap.newKeySet() + + @Volatile + private var activeProfile: TvHomeProfile? = null + private var started = false + + fun start() { + if (started) return + started = true + + scope.launch { + settings.settingsFlow + .map(::tvHomeProfile) + .distinctUntilChanged() + .collectLatest { profile -> + latestPositions.clear() + completedItems.clear() + activeProfile = profile + if (profile == null) { + publisher.clear() + } else { + publisher.clearOtherProfiles(profile.key) + // A process may have been created by a launcher-card press. Keep the + // supplementary row refresh behind item lookup and stream negotiation. + delay(INITIAL_RECONCILE_DELAY_MS) + reconcile(profile) + } + } + } + scope.launch { + repository.playbackPositions.collect { position -> + activeProfile?.let { updateProgress(it, position) } + } + } + scope.launch { + repository.playbackStops.collect { + activeProfile?.let { profile -> reconcile(profile) } + } + } + } + + private suspend fun reconcile(profile: TvHomeProfile) { + reconciliation.withLock { + if (activeProfile != profile) return + val items = runCatching { repository.getContinueWatching(WATCH_NEXT_LIMIT) } + .getOrNull() + ?: return + if (activeProfile != profile) return + val serverIds = items.mapTo(mutableSetOf()) { it.id } + completedItems.retainAll(serverIds) + val now = System.currentTimeMillis() + val programmes = items.mapIndexedNotNull { index, item -> + if (item.id in completedItems) return@mapIndexedNotNull null + val local = latestPositions[item.id] + val localTicks = local?.positionMs?.times(10_000L) ?: 0L + val currentTicks = item.userData?.playbackPositionTicks ?: 0L + val currentItem = if (localTicks > currentTicks) { + item.copy( + userData = (item.userData ?: com.ponzischeme89.memby.data.model.UserItemData()) + .copy(playbackPositionTicks = localTicks), + ) + } else { + item + } + tvHomeProgram( + item = currentItem, + profileKey = profile.key, + artworkUrl = repository.backdropUrl(item, maxWidth = 1280) + ?: repository.primaryUrl(item, maxWidth = 780), + // Preserve the repository's recency order even where a Next Up item has + // no LastPlayedDate of its own. + nowMs = now - index, + ) + } + publisher.reconcile(programmes) + } + } + + private fun updateProgress(profile: TvHomeProfile, position: PlaybackPosition) { + latestPositions[position.itemId] = position + if (position.completed) completedItems += position.itemId else completedItems -= position.itemId + publisher.updateProgress( + profileKey = profile.key, + itemId = position.itemId, + positionMs = position.positionMs, + durationMs = position.durationMs, + completed = position.completed, + ) + } + + private companion object { + const val WATCH_NEXT_LIMIT = 40 + const val INITIAL_RECONCILE_DELAY_MS = 15_000L + } +} + +internal data class TvHomeProfile(val key: String) + +internal fun tvHomeProfile(settings: Settings): TvHomeProfile? { + if (!settings.isSignedIn) return null + return TvHomeProfile( + tvHomeProfileKey( + serverUrl = requireNotNull(settings.serverUrl), + userId = requireNotNull(settings.userId), + viewerId = settings.activeViewerId, + ), + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeProgram.kt b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeProgram.kt new file mode 100644 index 0000000..2996098 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeProgram.kt @@ -0,0 +1,69 @@ +package com.ponzischeme89.memby.tvhome + +import com.ponzischeme89.memby.data.model.BaseItem +import java.security.MessageDigest + +internal enum class TvHomeProgramKind { CONTINUE, NEXT } + +internal data class TvHomeProgram( + val providerId: String, + val itemId: String, + val profileKey: String, + val kind: TvHomeProgramKind, + val mediaType: String, + val title: String, + val episodeTitle: String? = null, + val description: String? = null, + val seasonNumber: Int? = null, + val episodeNumber: Int? = null, + val positionMs: Long = 0L, + val durationMs: Long = 0L, + val artworkUrl: String? = null, + val engagementTimeMs: Long, +) + +internal fun tvHomeProfileKey(serverUrl: String, userId: String, viewerId: String): String { + val identity = "${serverUrl.trimEnd('/')}|$userId|$viewerId" + return MessageDigest.getInstance("SHA-256") + .digest(identity.toByteArray(Charsets.UTF_8)) + .take(12) + .joinToString("") { "%02x".format(it) } +} + +internal fun tvHomeProgram( + item: BaseItem, + profileKey: String, + artworkUrl: String?, + nowMs: Long, +): TvHomeProgram? { + if (item.id.isBlank() || item.userData?.played == true) return null + if (!item.isMovie && !item.isEpisode) return null + + val positionMs = ((item.userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L) + val durationMs = ((item.runTimeTicks ?: 0L) / 10_000L).coerceAtLeast(0L) + val kind = when { + positionMs > 0L -> TvHomeProgramKind.CONTINUE + item.isEpisode -> TvHomeProgramKind.NEXT + else -> return null + } + if (kind == TvHomeProgramKind.CONTINUE && durationMs > 0L && positionMs >= durationMs * 95 / 100) { + return null + } + + return TvHomeProgram( + providerId = "memby:$profileKey:${item.id}", + itemId = item.id, + profileKey = profileKey, + kind = kind, + mediaType = if (item.isMovie) "movie" else "episode", + title = if (item.isEpisode) item.seriesName?.takeIf(String::isNotBlank) ?: item.name else item.name, + episodeTitle = item.name.takeIf { item.isEpisode }, + description = item.overview, + seasonNumber = item.parentIndexNumber, + episodeNumber = item.indexNumber, + positionMs = positionMs, + durationMs = durationMs, + artworkUrl = artworkUrl, + engagementTimeMs = nowMs, + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/tvhome/WatchNextPublisher.kt b/app/src/main/java/com/ponzischeme89/memby/tvhome/WatchNextPublisher.kt new file mode 100644 index 0000000..3fe64be --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/tvhome/WatchNextPublisher.kt @@ -0,0 +1,150 @@ +package com.ponzischeme89.memby.tvhome + +import android.content.ContentUris +import android.content.Context +import android.net.Uri +import android.os.Build +import android.provider.BaseColumns +import androidx.tvprovider.media.tv.TvContractCompat +import androidx.tvprovider.media.tv.WatchNextProgram + +internal class WatchNextPublisher(context: Context) { + private val resolver = context.applicationContext.contentResolver + + val supported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + + fun reconcile(programmes: List) { + if (!supported) return + runCatching { + val existing = existingProgrammes() + val desired = programmes.associateBy(TvHomeProgram::providerId) + + for ((providerId, rows) in existing) { + val programme = desired[providerId] + if (programme == null) { + rows.forEach(::delete) + } else { + val keeper = rows.first() + resolver.update(keeper.uri, programme.toWatchNext().toContentValues(), null, null) + rows.drop(1).forEach(::delete) + } + } + for ((providerId, programme) in desired) { + if (providerId !in existing) { + resolver.insert( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + programme.toWatchNext().toContentValues(), + ) + } + } + } + } + + fun updateProgress(profileKey: String, itemId: String, positionMs: Long, durationMs: Long, completed: Boolean) { + if (!supported) return + runCatching { + val providerId = "memby:$profileKey:$itemId" + val rows = existingProgrammes()[providerId].orEmpty() + if (completed) { + rows.forEach(::delete) + return@runCatching + } + rows.firstOrNull()?.let { row -> + val values = android.content.ContentValues().apply { + put(TvContractCompat.WatchNextPrograms.COLUMN_LAST_PLAYBACK_POSITION_MILLIS, positionMs.toIntMillis()) + if (durationMs > 0L) { + put(TvContractCompat.WatchNextPrograms.COLUMN_DURATION_MILLIS, durationMs.toIntMillis()) + } + put(TvContractCompat.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS, System.currentTimeMillis()) + } + resolver.update(row.uri, values, null, null) + rows.drop(1).forEach(::delete) + } + } + } + + fun clear() { + if (!supported) return + runCatching { existingProgrammes().values.flatten().forEach(::delete) } + } + + fun clearOtherProfiles(profileKey: String) { + if (!supported) return + val ownPrefix = "memby:$profileKey:" + runCatching { + existingProgrammes() + .filterKeys { !it.startsWith(ownPrefix) } + .values + .flatten() + .forEach(::delete) + } + } + + private fun existingProgrammes(): Map> { + val found = linkedMapOf>() + resolver.query( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + arrayOf(BaseColumns._ID, TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID), + null, + null, + null, + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(BaseColumns._ID) + val providerColumn = cursor.getColumnIndexOrThrow( + TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID, + ) + while (cursor.moveToNext()) { + val providerId = cursor.getString(providerColumn) ?: continue + if (!providerId.startsWith("memby:")) continue + val uri = ContentUris.withAppendedId( + TvContractCompat.WatchNextPrograms.CONTENT_URI, + cursor.getLong(idColumn), + ) + found.getOrPut(providerId) { mutableListOf() } += ExistingProgramme(uri) + } + } + return found + } + + private fun delete(programme: ExistingProgramme) { + resolver.delete(programme.uri, null, null) + } + + private fun TvHomeProgram.toWatchNext(): WatchNextProgram { + val builder = WatchNextProgram.Builder() + builder.setType( + if (mediaType == "movie") { + TvContractCompat.PreviewPrograms.TYPE_MOVIE + } else { + TvContractCompat.PreviewPrograms.TYPE_TV_EPISODE + }, + ) + builder.setWatchNextType( + if (kind == TvHomeProgramKind.CONTINUE) { + TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE + } else { + TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT + }, + ) + builder.setTitle(title) + builder.setInternalProviderId(providerId) + builder.setContentId(itemId) + builder.setIntentUri(TvDeepLinks.playback(itemId, profileKey)) + builder.setLastEngagementTimeUtcMillis(engagementTimeMs) + + description?.takeIf(String::isNotBlank)?.let(builder::setDescription) + artworkUrl?.let(Uri::parse)?.let(builder::setPosterArtUri) + if (kind == TvHomeProgramKind.CONTINUE) { + builder.setLastPlaybackPositionMillis(positionMs.toIntMillis()) + builder.setDurationMillis(durationMs.toIntMillis()) + } + episodeTitle?.let(builder::setEpisodeTitle) + seasonNumber?.let(builder::setSeasonNumber) + episodeNumber?.let(builder::setEpisodeNumber) + return builder.build() + } + + private fun Long.toIntMillis(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + + private data class ExistingProgramme(val uri: Uri) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index a386093..eb02d11 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -854,7 +854,7 @@ internal fun ForYouNudgeBanner( } /** - * Turns Emby account-style usernames into a friendlier home-screen name. + * Turns Memby account-style usernames into a friendlier home-screen name. * * Household accounts commonly use a trailing capital as a disambiguating surname * initial (PeterC, PaulR). Only that clear camel-case shape is trimmed, so ordinary diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 63c1789..b34970f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -285,6 +285,9 @@ internal fun HomeScreen( var quickMenuTrailerAvailable by remember { mutableStateOf(false) } var quickMenuRowId by remember { mutableStateOf(null) } var focusedHomeRowId by remember { mutableStateOf(null) } + var rowBrowseTarget by remember { mutableStateOf(null) } + var rowBrowseOriginRowId by remember { mutableStateOf(null) } + var rowBrowseOriginItemId by remember { mutableStateOf(null) } var sectionHeroRows by remember(settings.userId) { mutableStateOf>>(emptyMap()) } @@ -843,6 +846,7 @@ internal fun HomeScreen( ) LaunchedEffect(selectedDestination) { focusedHomeRowId = null + rowBrowseTarget = null } LaunchedEffect(selectedDestination, settings.forYouMinutes) { if ( @@ -863,6 +867,7 @@ internal fun HomeScreen( showNotifications -> "notifications" userSwitcherVisible || showProfiles -> "profiles" detailsItem != null -> "details" + rowBrowseTarget != null -> "row_browse" selectedMyShow != null -> "my_show_details" else -> selectedDestination.name.lowercase() } @@ -935,6 +940,7 @@ internal fun HomeScreen( navigationExpanded = it }, onDestinationSelected = { destination -> + rowBrowseTarget = null homeViewModel.trackJourney( category = "navigation", action = "select", screen = journeyScreen, feature = destination.name.lowercase(), @@ -1050,6 +1056,53 @@ internal fun HomeScreen( return@BoxWithConstraints } + rowBrowseTarget?.let { browseTarget -> + GenreBrowseScreen( + itemType = browseTarget.itemType, + initialCategoryId = browseTarget.categoryId, + favouriteStates = favoriteChanges, + playedStates = playedChanges, + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + returnFocusItemId = returnItemId.takeIf { + returnRowId == ROW_BROWSE_RESULTS_ID + }, + returnFocusRequester = cardReturnFocusRequester, + onItemFocused = homeViewModel::focusItem, + onItemSelected = { item -> + returnRowId = ROW_BROWSE_RESULTS_ID + returnRowKind = null + returnItemId = item.id + homeViewModel.focusItem(item) + homeViewModel.trackJourney( + category = "content", action = "open", screen = "row_browse", + feature = "view_all", source = "row_browse_results", + target = "details", itemName = item.name, itemType = item.type, + ) + detailsAiringNotice = null + detailsItem = item + }, + onContentFocused = { navigationExpanded = false }, + onClose = { + rowBrowseTarget = null + returnRowId = rowBrowseOriginRowId + returnRowKind = rows.firstOrNull { + it.id == rowBrowseOriginRowId + }?.kind?.name + returnItemId = rowBrowseOriginItemId + scope.launch { + delay(16.milliseconds) + if (rowBrowseOriginItemId != null) { + runCatching { cardReturnFocusRequester.requestFocus() } + } else { + runCatching { contentFocusRequester.requestFocus() } + } + } + }, + ) + return@BoxWithConstraints + } + if (selectedDestination == BrowseDestination.SEARCH) { // Remembered, or this list is a fresh instance on every recomposition // and the screen re-derives its genre chips each time. @@ -1511,6 +1564,7 @@ internal fun HomeScreen( row.items.take(8).map { it.id }, ) } + val browseTarget = homeRowBrowseTarget(row) MediaRow( modifier = Modifier, row = row, @@ -1672,6 +1726,24 @@ internal fun HomeScreen( horizontalState = horizontalStates.getOrPut( "${selectedDestination.name}:${row.id}", ) { LazyListState() }, + viewAllLabel = browseTarget?.actionLabel, + onViewAll = browseTarget?.let { target -> + { + rowBrowseOriginRowId = row.id + rowBrowseOriginItemId = returnItemId.takeIf { + returnRowId == row.id + } ?: row.items.getOrNull( + rowFocusPositions[row.id] ?: 0, + )?.id + rowBrowseTarget = target + homeViewModel.trackJourney( + category = "navigation", action = "open", + screen = selectedDestination.name.lowercase(), + feature = "view_all", source = row.id, + target = "row_browse", + ) + } + }, ) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt index 3d3577e..ad4f2b8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ResumableMediaCard.kt @@ -141,7 +141,7 @@ internal fun expectedFinishEpochMillis( * A self-contained card for anything that can be resumed. * * The component owns the progress, episode identity and expected-finish presentation; - * callers own placement, artwork shape and actions. + * callers own placement, artwork shape, actions and optional content beneath the title. */ @Composable fun ResumableMediaCard( @@ -152,6 +152,7 @@ fun ResumableMediaCard( onFocused: () -> Unit, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, + titleSupplement: (@Composable (focused: Boolean) -> Unit)? = null, ) { val context = LocalContext.current val artworkUrl = if (portraitArtwork) { @@ -299,6 +300,7 @@ fun ResumableMediaCard( overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), ) + titleSupplement?.invoke(focused) model.episodeLabel?.let { label -> Text( text = label, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt index c06fe70..7439de6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt @@ -1,5 +1,6 @@ package com.ponzischeme89.memby.ui.components.media +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -7,6 +8,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.ItemRatingsStrip import com.ponzischeme89.memby.ui.ResumableMediaCard import com.ponzischeme89.memby.ui.responsiveRowCardWidth import com.ponzischeme89.memby.ui.toResumableMediaCardModel @@ -55,5 +57,18 @@ fun ContinueWatchingCard( onClick = onClick, onLongClick = onLongClick, modifier = modifier, + titleSupplement = if (item.isMovie) { + { focused -> + ItemRatingsStrip( + item = item, + load = focused, + modifier = Modifier.padding(top = 2.dp), + reserveSpace = false, + compact = true, + ) + } + } else { + null + }, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaResultCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaResultCard.kt new file mode 100644 index 0000000..070968f --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaResultCard.kt @@ -0,0 +1,214 @@ +package com.ponzischeme89.memby.ui.components.media + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import androidx.tv.material3.Icon +import coil.compose.AsyncImage +import com.ponzischeme89.memby.ui.FocusScaleContainer +import com.ponzischeme89.memby.ui.theme.MembyIcon +import com.ponzischeme89.memby.ui.theme.mark +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised + +/** + * The shared film/series result row used by Search and My Requests. + * + * This component owns the invariant 118dp row, 214dp artwork, typography, spacing and + * focus border. Feature-specific state belongs in the optional artwork, body and trailing + * slots: Requests supplies status and actions, while Search supplies library metadata. + */ +@Composable +internal fun MediaResultCard( + title: String, + subtitle: String, + detail: String, + artworkUrl: String?, + contentDescription: String, + onFocused: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + detailColour: Color = MembyAccent, + artworkTopStartContent: (@Composable () -> Unit)? = null, + artworkBottomStartContent: (@Composable () -> Unit)? = null, + bodyContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable (focused: Boolean) -> Unit)? = null, +) { + val shape = RoundedCornerShape(MembyCardCorner) + FocusScaleContainer( + onFocused = onFocused, + onClick = onClick, + contentDescription = contentDescription, + modifier = modifier.fillMaxWidth(), + ) { focused -> + Row( + Modifier + .fillMaxWidth() + .height(118.dp) + .clip(shape) + .background(MembySurfaceRaised.copy(alpha = 0.72f)) + .border( + if (focused) 2.dp else 1.dp, + if (focused) Color.White else Color.White.copy(alpha = 0.07f), + shape, + ), + ) { + Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface)) { + Box( + Modifier.fillMaxSize().background( + Brush.linearGradient( + listOf(MembyAccent.copy(alpha = 0.30f), Color(0xFF102523), MembySurfaceRaised), + ), + ), + contentAlignment = Alignment.Center, + ) { + Text( + title.trim().firstOrNull()?.uppercase().orEmpty(), + color = Color.White.copy(alpha = 0.16f), + fontSize = 52.sp, + fontWeight = FontWeight.Black, + ) + } + if (!artworkUrl.isNullOrBlank()) { + AsyncImage( + model = artworkUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + listOf(Color.Transparent, MembySurfaceRaised.copy(alpha = 0.12f), MembySurfaceRaised), + ), + ), + ) + artworkTopStartContent?.let { content -> + Box(Modifier.align(Alignment.TopStart).padding(9.dp)) { content() } + } + artworkBottomStartContent?.let { content -> + Box(Modifier.align(Alignment.BottomStart).padding(9.dp)) { content() } + } + } + Column( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 10.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + title, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle.isNotBlank()) { + Spacer(Modifier.height(5.dp)) + Text( + subtitle, + color = Color.White.copy(alpha = 0.82f), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + bodyContent?.invoke() + Spacer(Modifier.height(4.dp)) + Text( + detail, + color = detailColour, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + trailingContent?.invoke(focused) + } + } +} + +/** Film/series mark shared by both result surfaces. */ +@Composable +internal fun MediaResultTypeGlyph(isSeries: Boolean, modifier: Modifier = Modifier) { + Box( + modifier + .size(24.dp) + .background(MembySurface.copy(alpha = 0.72f), RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + if (isSeries) MembyIcon.Tv.mark else MembyIcon.Movie.mark, + contentDescription = if (isSeries) "Series" else "Film", + tint = Color.White.copy(alpha = 0.82f), + modifier = Modifier.size(13.dp), + ) + } +} + +/** Loading form of [MediaResultCard]; intentionally not focusable. */ +@Composable +internal fun MediaResultCardSkeleton(modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(MembyCardCorner) + Row( + modifier + .fillMaxWidth() + .height(118.dp) + .clip(shape) + .background(MembySurfaceRaised.copy(alpha = 0.34f)) + .border(1.dp, Color.White.copy(alpha = 0.05f), shape), + ) { + Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface.copy(alpha = 0.55f))) + Column( + Modifier.weight(1f).fillMaxHeight().padding(horizontal = 14.dp), + verticalArrangement = Arrangement.Center, + ) { + SkeletonBar(widthFraction = 0.52f, height = 15.dp) + Spacer(Modifier.height(9.dp)) + SkeletonBar(widthFraction = 0.32f, height = 11.dp) + Spacer(Modifier.height(7.dp)) + SkeletonBar(widthFraction = 0.22f, height = 9.dp) + } + } +} + +@Composable +private fun SkeletonBar(widthFraction: Float, height: androidx.compose.ui.unit.Dp) { + Box( + Modifier + .fillMaxWidth(widthFraction) + .height(height) + .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(4.dp)), + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt index 178c3f7..21f6147 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt @@ -27,10 +27,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -53,11 +55,11 @@ import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.ui.theme.MembyChipCorner +import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds // No NEXT_UP: those episodes are part of CONTINUE, which is one row. @@ -173,11 +175,18 @@ internal fun MediaRow( density: String = "standard", artworkStyle: String = "automatic", horizontalState: LazyListState? = null, + viewAllLabel: String? = null, + onViewAll: (() -> Unit)? = null, ) { val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } val rowState = horizontalState ?: savedRowState val verticalEntryFocusRequester = remember { FocusRequester() } - val scope = rememberCoroutineScope() + val previousPageFocusRequester = remember { FocusRequester() } + val nextPageFocusRequester = remember { FocusRequester() } + val viewAllFocusRequester = remember { FocusRequester() } + val pagedCardFocusRequester = remember { FocusRequester() } + var pagedCardIndex by remember(row.id) { mutableStateOf(null) } + var pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) } val requestedEntryIndex = verticalFocusRequest ?.takeIf { it.rowId == row.id && row.items.isNotEmpty() } ?.itemIndex @@ -205,10 +214,22 @@ internal fun MediaRow( } currentOnVerticalFocusRequestConsumed(request.requestId) } + LaunchedEffect(pageFocusRequestId) { + val target = pagedCardIndex ?: return@LaunchedEffect + rowState.scrollToItem(target) + // The target can begin outside the composed LazyRow window. Give layout a frame to + // attach it, then keep the transfer bounded so a refreshed row can never trap focus. + repeat(6) { + delay(16.milliseconds) + if (runCatching { pagedCardFocusRequester.requestFocus() }.isSuccess) { + return@LaunchedEffect + } + } + } // firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so // only the button's enabled/disabled flip recomposes the row header. val canScrollBack by remember(rowState) { - derivedStateOf { rowState.firstVisibleItemIndex > 0 } + derivedStateOf { rowState.canScrollBackward } } val canScrollForward by remember(rowState) { derivedStateOf { rowState.canScrollForward } @@ -232,22 +253,34 @@ internal fun MediaRow( ) Spacer(Modifier.weight(1f)) if (row.items.isNotEmpty()) { + if (viewAllLabel != null && onViewAll != null) { + ViewAllButton( + label = viewAllLabel, + focusRequester = viewAllFocusRequester, + onClick = onViewAll, + ) + Spacer(Modifier.width(8.dp)) + } GalleryJumpButton( forward = false, enabled = canScrollBack, + focusRequester = previousPageFocusRequester, onClick = { val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0) - scope.launch { rowState.scrollToItem(target) } + pagedCardIndex = target + pageFocusRequestId += 1 }, ) Spacer(Modifier.width(8.dp)) GalleryJumpButton( forward = true, enabled = canScrollForward, + focusRequester = nextPageFocusRequester, onClick = { val target = (rowState.firstVisibleItemIndex + pageSize) .coerceAtMost(row.items.lastIndex) - scope.launch { rowState.scrollToItem(target) } + pagedCardIndex = target + pageFocusRequestId += 1 }, ) } @@ -321,6 +354,9 @@ internal fun MediaRow( if (index == requestedEntryIndex) { cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester) } + if (index == pagedCardIndex) { + cardModifier = cardModifier.focusRequester(pagedCardFocusRequester) + } cardModifier = cardModifier.onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) { return@onPreviewKeyEvent false @@ -328,6 +364,29 @@ internal fun MediaRow( when (event.key) { Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP) Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN) + Key.DirectionLeft -> { + val firstVisible = rowState.layoutInfo.visibleItemsInfo + .firstOrNull()?.index + if (canScrollBack && index == firstVisible) { + runCatching { previousPageFocusRequester.requestFocus() } + .isSuccess + } else { + false + } + } + Key.DirectionRight -> { + val lastVisible = rowState.layoutInfo.visibleItemsInfo + .lastOrNull()?.index + if (index != lastVisible) { + false + } else if (viewAllLabel != null && onViewAll != null) { + runCatching { viewAllFocusRequester.requestFocus() }.isSuccess + } else if (canScrollForward) { + runCatching { nextPageFocusRequester.requestFocus() }.isSuccess + } else { + false + } + } else -> false } } @@ -374,6 +433,7 @@ internal fun MediaRow( private fun GalleryJumpButton( forward: Boolean, enabled: Boolean, + focusRequester: FocusRequester, onClick: () -> Unit, ) { FocusScaleContainer( @@ -381,12 +441,11 @@ private fun GalleryJumpButton( onClick = { if (enabled) onClick() }, onLongClick = null, contentDescription = if (forward) "Next page" else "Previous page", - // These mirror remote left/right paging for pointer users. Keeping them out of - // the focus graph prevents Up from a card landing in a tiny header control. modifier = Modifier .width(42.dp) .height(36.dp) - .focusProperties { canFocus = false }, + .focusRequester(focusRequester) + .focusProperties { canFocus = enabled }, ) { focused -> Box( Modifier @@ -394,10 +453,15 @@ private fun GalleryJumpButton( .clip(RoundedCornerShape(MembyChipCorner)) .background( when { - focused -> Color.White.copy(alpha = 0.16f) + focused -> MembyAccent.copy(alpha = 0.34f) enabled -> Color.White.copy(alpha = 0.08f) else -> Color.White.copy(alpha = 0.03f) }, + ) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(MembyChipCorner), ), contentAlignment = Alignment.Center, ) { @@ -411,6 +475,53 @@ private fun GalleryJumpButton( } } +@Composable +private fun ViewAllButton( + label: String, + focusRequester: FocusRequester, + onClick: () -> Unit, +) { + FocusScaleContainer( + onFocused = {}, + onClick = onClick, + contentDescription = label, + modifier = Modifier + .height(36.dp) + .focusRequester(focusRequester), + ) { focused -> + Row( + modifier = Modifier + .height(36.dp) + .clip(RoundedCornerShape(MembyChipCorner)) + .background( + if (focused) MembyAccent.copy(alpha = 0.34f) + else Color.White.copy(alpha = 0.08f), + ) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(MembyChipCorner), + ) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + Icon( + imageVector = MembyIcon.ChevronRight.mark, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(17.dp), + ) + } + } +} + private enum class MediaCardFormat { PORTRAIT, LANDSCAPE } private fun cardFormat( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRowBrowse.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRowBrowse.kt new file mode 100644 index 0000000..53ffed5 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRowBrowse.kt @@ -0,0 +1,59 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID +import com.ponzischeme89.memby.ui.genre.genreCategoryTabs + +internal const val ROW_BROWSE_RESULTS_ID = "row-browse-results" + +/** The existing paged catalogue screen a home shelf can expand into. */ +internal data class HomeRowBrowseTarget( + val categoryId: String, + val itemType: String, + val actionLabel: String, +) + +/** + * Resolves only shelves whose complete catalogue query is known on both backend paths. + * + * Recommendation and studio shelves deliberately return null: the horizontal row is a + * ranked answer, while the catalogue browser can currently page only media types and + * genres. Labelling an unfiltered library as "all Pixar" would be worse than omitting the + * action until that filter exists. + */ +internal fun homeRowBrowseTarget(row: HomeBrowseRow): HomeRowBrowseTarget? { + if (row.id == "latest-movies") { + return HomeRowBrowseTarget( + categoryId = ALL_MEDIA_CATEGORY_ID, + itemType = "Movie", + actionLabel = "View All", + ) + } + + val itemType = when { + row.id.startsWith("curated:movies:genre:") -> "Movie" + row.id.startsWith("curated:shows:genre:") -> "Series" + row.id.startsWith("curated:") && row.id.endsWith("-shows") -> "Series" + else -> return null + } + val rowLabel = row.title + .removeSuffix(" TV Shows") + .removeSuffix(" Shows") + .removeSuffix(" Movies") + .trim() + val normalisedLabel = rowLabel.normalisedBrowseLabel() + val category = genreCategoryTabs(itemType).firstOrNull { candidate -> + candidate.id != ALL_MEDIA_CATEGORY_ID && ( + candidate.label.normalisedBrowseLabel() == normalisedLabel || + candidate.genres.any { it.normalisedBrowseLabel() == normalisedLabel } + ) + } ?: return null + + return HomeRowBrowseTarget( + categoryId = category.id, + itemType = itemType, + actionLabel = "View All ${category.label}", + ) +} + +private fun String.normalisedBrowseLabel(): String = + lowercase().filter(Char::isLetterOrDigit) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt index c5ce626..4a15ac6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/requests/RequestCards.kt @@ -35,6 +35,9 @@ import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage import com.ponzischeme89.memby.ui.FocusScaleContainer +import com.ponzischeme89.memby.ui.components.media.MediaResultCard +import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton +import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyCardCorner @@ -85,131 +88,40 @@ internal fun RequestCard( /** The trailing affordance, when pressing the card would do something. */ action: RequestCardAction? = null, ) { - val shape = RoundedCornerShape(MembyCardCorner) - FocusScaleContainer( + MediaResultCard( + title = title, + subtitle = subtitle, + detail = detail, + artworkUrl = artworkUrl, + detailColour = requestToneColour(status), onFocused = onFocused, onClick = onClick, contentDescription = listOf(title, requestStatusChipLabel(status, statusLabel, progress), detail) .filter(String::isNotBlank) .joinToString(", "), - modifier = modifier.fillMaxWidth(), - ) { focused -> - Row( - Modifier - .fillMaxWidth() - .height(118.dp) - .clip(shape) - .background(MembySurfaceRaised.copy(alpha = 0.72f)) - .border( - if (focused) 2.dp else 1.dp, - if (focused) Color.White else Color.White.copy(alpha = 0.07f), - shape, - ), - ) { - Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface)) { - // The monogram sits behind the artwork rather than instead of it, so nothing - // has to decide in advance whether a poster will arrive — the cast panel's - // rule. Radarr and Sonarr posters are remote URLs and often slow. - Box( - Modifier.fillMaxSize().background( - Brush.linearGradient( - listOf(MembyAccent.copy(alpha = 0.30f), Color(0xFF102523), MembySurfaceRaised), - ), - ), - contentAlignment = Alignment.Center, - ) { - Text( - title.trim().firstOrNull()?.uppercase().orEmpty(), - color = Color.White.copy(alpha = 0.16f), - fontSize = 52.sp, - fontWeight = FontWeight.Black, - ) - } - if (!artworkUrl.isNullOrBlank()) { - AsyncImage( - model = artworkUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - Box( - Modifier.fillMaxSize().background( - Brush.horizontalGradient( - listOf(Color.Transparent, MembySurfaceRaised.copy(alpha = 0.12f), MembySurfaceRaised), - ), - ), - ) - // A requestable title wears no badge. The trailing affordance already says - // "Request" in the accent, and a second grey chip saying the same word is - // the one thing on the card that is not a *state* — badges here mean what - // has become of something, and this one has had nothing become of it yet. - if (status != RequestStatus.REQUESTABLE) { - RequestStatusBadge( - status = status, - label = requestStatusChipLabel(status, statusLabel, progress), - modifier = Modifier.align(Alignment.TopStart).padding(9.dp), - ) - } - MediaTypeGlyph( - mediaType = mediaType, - modifier = Modifier.align(Alignment.BottomStart).padding(9.dp), - ) - } - Column( - Modifier - .weight(1f) - .fillMaxHeight() - .padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 10.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - title, - color = Color.White, - fontSize = 17.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (subtitle.isNotBlank()) { - Spacer(Modifier.height(5.dp)) - Text( - subtitle, - color = Color.White.copy(alpha = 0.82f), - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - // The bar sits above the sentence rather than under it, so the quiet line - // stays last on every card whether or not there is a bar — a card with no - // measurable download and one with a finished one must read as the same - // shape. requestProgressFraction is what decides there is one at all: only - // moving bytes have a denominator, and a bar standing somewhere arbitrary - // under a card that is merely being searched for would be the single most - // misleading thing on the page. - requestProgressFraction(status, progress)?.let { fraction -> - Spacer(Modifier.height(7.dp)) - RequestProgressBar(fraction = fraction, colour = requestToneColour(status)) - } - Spacer(Modifier.height(4.dp)) - Text( - detail, - color = requestToneColour(status), - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + modifier = modifier, + artworkTopStartContent = if (status != RequestStatus.REQUESTABLE) { + { RequestStatusBadge(status, requestStatusChipLabel(status, statusLabel, progress)) } + } else { + null + }, + artworkBottomStartContent = { + MediaResultTypeGlyph(isSeries = mediaType.equals("series", ignoreCase = true)) + }, + bodyContent = requestProgressFraction(status, progress)?.let { fraction -> + { + Spacer(Modifier.height(7.dp)) + RequestProgressBar(fraction = fraction, colour = requestToneColour(status)) } + }, + trailingContent = { focused -> if (busy) { RequestTrailing(icon = MembyIcon.Schedule.mark, label = "Asking", focused = focused) } else if (action != null) { RequestTrailing(icon = action.icon.mark, label = action.label, focused = focused) } - } - } + }, + ) } /** The trailing affordance on a card: what the centre button would do. */ @@ -337,24 +249,6 @@ internal fun requestToneColour(status: String): Color = when (requestStatusTone( * series takes weeks to arrive and a film does not — but it is not worth a second text * badge competing with the state. */ -@Composable -private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) { - val icon = if (mediaType == "series") MembyIcon.Tv.mark else MembyIcon.Movie.mark - Box( - modifier - .size(24.dp) - .background(MembySurface.copy(alpha = 0.72f), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - icon, - contentDescription = if (mediaType == "series") "Series" else "Film", - tint = Color.White.copy(alpha = 0.82f), - modifier = Modifier.size(13.dp), - ) - } -} - /** * The card's shape with nothing in it, shown while the first list is loading. * @@ -364,35 +258,5 @@ private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) { */ @Composable internal fun RequestCardSkeleton(modifier: Modifier = Modifier) { - val shape = RoundedCornerShape(MembyCardCorner) - Row( - modifier - .fillMaxWidth() - .height(118.dp) - .clip(shape) - .background(MembySurfaceRaised.copy(alpha = 0.34f)) - .border(1.dp, Color.White.copy(alpha = 0.05f), shape), - ) { - Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface.copy(alpha = 0.55f))) - Column( - Modifier.weight(1f).fillMaxHeight().padding(horizontal = 14.dp), - verticalArrangement = Arrangement.Center, - ) { - SkeletonBar(widthFraction = 0.52f, height = 15.dp) - Spacer(Modifier.height(9.dp)) - SkeletonBar(widthFraction = 0.32f, height = 11.dp) - Spacer(Modifier.height(7.dp)) - SkeletonBar(widthFraction = 0.22f, height = 9.dp) - } - } -} - -@Composable -private fun SkeletonBar(widthFraction: Float, height: androidx.compose.ui.unit.Dp) { - Box( - Modifier - .fillMaxWidth(widthFraction) - .height(height) - .background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(4.dp)), - ) + MediaResultCardSkeleton(modifier) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 34a0a1c..44282a8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -37,11 +37,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -69,7 +66,6 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.utf16CodePoint import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight @@ -83,13 +79,13 @@ import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.imageLoader -import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.MembyChoiceChip -import com.ponzischeme89.memby.ui.PosterGridCard +import com.ponzischeme89.memby.ui.search.components.SearchGenres +import com.ponzischeme89.memby.ui.search.components.SearchResults import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyAccentMuted @@ -127,18 +123,6 @@ private val KeyboardRows = listOf( /** Left pane share of the width. Wide enough for six comfortable keys at TV distance. */ private const val KEYBOARD_PANE_FRACTION = 0.35f -/** Posters prefetched as soon as results land, so the first visible row is never blank. */ -private const val PREFETCHED_POSTERS = 8 - -/** - * How far from the end of a genre the next page is asked for, in rows. - * - * Two rows rather than one: a D-pad walks a row at a time and the request has to be in - * flight before the viewer arrives at the bottom, or the scroll stops dead and the shelf - * reads as having ended. - */ -private const val LOAD_MORE_ROWS_AHEAD = 2 - /** * Full-screen search: keyboard on the left, results on the right, updating as you type. * @@ -168,7 +152,8 @@ fun SearchScreen( LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) } - val resultsEntry = remember { FocusRequester() } + val genresEntry = remember { FocusRequester() } + val searchResultsEntry = remember { FocusRequester() } // The rail and the screen agree on one entry target: Search opens on the keyboard, // just as browse destinations open on their primary content action. val keyboardEntry = contentFocusRequester @@ -191,23 +176,21 @@ fun SearchScreen( } val hasResultsTarget = when { state.errorMessage != null && state.results.isEmpty() -> true - state.isDiscovery -> discoveryItems.isNotEmpty() || state.genreSuggestions.isNotEmpty() - else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty() || + state.isDiscovery -> state.genreSuggestions.isNotEmpty() + else -> state.results.any { it.isMovie || it.isSeries } || state.requestCandidates.isNotEmpty() || (state.requestsAvailable && shouldSearch(state.query)) } LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } } - // A genre chip unmounts the moment its shelf opens — the whole discovery pane goes with - // it — so the focus it was holding belongs to nothing unless something takes it. The - // grid claims it as soon as there is a card to land on, which is where the viewer is - // already looking; a genre that came back empty or failed hands the remote back to the - // keyboard rather than leaving a television with nothing focused at all. + // The genre strip remains above its shelf. Once a first result exists the list takes + // focus, which is where the viewer is already looking; an empty genre hands the remote + // back to the keyboard rather than leaving the television on a dead end. LaunchedEffect(state.genre, state.results.isEmpty(), state.isLoading, state.errorMessage) { if (state.genre == null) return@LaunchedEffect when { - state.results.isNotEmpty() || state.errorMessage != null -> - if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true + state.results.any { it.isMovie || it.isSeries } || state.errorMessage != null -> + if (runCatching { searchResultsEntry.requestFocus() }.isSuccess) focusInResults = true !state.isLoading -> runCatching { keyboardReturn.requestFocus() } // Still loading: the branch above takes it the moment the first page lands. else -> Unit @@ -216,10 +199,9 @@ fun SearchScreen( LaunchedEffect(state.genre, restoreGenreChipFocus) { if (state.genre == null && restoreGenreChipFocus) { - // clearGenre remounts discovery and its genre chips. Wait for the first chip's - // focus node before handing the remote back to where this level was opened. - kotlinx.coroutines.delay(16L) - if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true + // The strip stays mounted while a genre is open, so Back can restore its stable + // entry target as soon as the result list has left composition. + if (runCatching { genresEntry.requestFocus() }.isSuccess) focusInResults = true restoreGenreChipFocus = false } } @@ -267,7 +249,7 @@ fun SearchScreen( SearchPane( state = state, navigationFocusRequester = navigationFocusRequester, - resultsEntry = resultsEntry, + resultsEntry = if (state.genreSuggestions.isNotEmpty()) genresEntry else searchResultsEntry, keyboardEntry = keyboardEntry, keyboardReturn = keyboardReturn, lastKeyIndex = lastKeyIndex, @@ -285,27 +267,61 @@ fun SearchScreen( .fillMaxWidth(KEYBOARD_PANE_FRACTION) .fillMaxHeight(), ) - ResultsPane( - state = state, - resultsEntry = resultsEntry, - keyboardReturn = keyboardReturn, - returnFocusItemId = returnFocusItemId, - returnFocusRequester = returnFocusRequester, - discoveryItems = discoveryItems, - onItemFocused = { item -> - focusInResults = true - onContentFocused() - onItemFocused(item) - }, - onItemSelected = onItemSelected, - onRetry = viewModel::retry, - onRequest = viewModel::request, - onShowRequests = viewModel::showRequests, - onGenreSelected = viewModel::onGenreSelected, - onBackFromGenre = closeGenre, - onLoadMore = viewModel::loadMore, - modifier = Modifier.fillMaxHeight(), - ) + Column( + Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 28.dp, end = 32.dp, top = 24.dp, bottom = 12.dp), + ) { + SearchGenres( + genres = state.genreSuggestions, + entryFocusRequester = genresEntry, + keyboardReturnFocusRequester = keyboardReturn, + resultsFocusRequester = searchResultsEntry, + resultsHaveFocusTarget = state.results.any { it.isMovie || it.isSeries } || + (state.errorMessage != null && state.results.isEmpty()) || + (state.requestsAvailable && shouldSearch(state.query)), + onFocused = { + focusInResults = true + onContentFocused() + }, + onSelected = viewModel::onGenreSelected, + ) + Spacer(Modifier.height(14.dp)) + + if (state.requestMode) { + RequestOptions( + state = state, + resultsEntry = searchResultsEntry, + keyboardReturn = keyboardReturn, + onRequest = viewModel::request, + modifier = Modifier.weight(1f), + ) + } else { + SearchResults( + state = state, + entryFocusRequester = searchResultsEntry, + genresFocusRequester = genresEntry, + genresPresent = state.genreSuggestions.isNotEmpty(), + keyboardReturnFocusRequester = keyboardReturn, + returnFocusItemId = returnFocusItemId, + returnFocusRequester = returnFocusRequester, + artworkUrlFor = { item -> + repository.primaryUrl(item, 500) ?: repository.backdropUrl(item, 500) + }, + onItemFocused = { item -> + focusInResults = true + onContentFocused() + onItemFocused(item) + }, + onItemSelected = onItemSelected, + onRetry = viewModel::retry, + onShowRequests = viewModel::showRequests, + onLoadMore = viewModel::loadMore, + modifier = Modifier.weight(1f), + ) + } + } } } @@ -558,7 +574,7 @@ internal fun TvKeyboard( modifier = Modifier .weight(1f) // Explicit edges. Left of the first column is the navigation - // rail, right of the last is the results grid: focus can + // rail, right of the last enters Search's discovery/results pane: focus can // always get out of this pane, and never falls off it. .focusProperties { if (columnIndex == 0) left = navigationFocusRequester @@ -791,293 +807,6 @@ private fun LoadingDot() { ) } -@Composable -private fun ResultsPane( - state: SearchUiState, - resultsEntry: FocusRequester, - keyboardReturn: FocusRequester, - returnFocusItemId: String?, - returnFocusRequester: FocusRequester, - discoveryItems: List, - onItemFocused: (BaseItem) -> Unit, - onItemSelected: (BaseItem) -> Unit, - onRetry: () -> Unit, - onRequest: (GatewayRequestCandidate) -> Unit, - onShowRequests: () -> Unit, - onGenreSelected: (String) -> Unit, - onBackFromGenre: () -> Unit, - onLoadMore: () -> Unit, - modifier: Modifier = Modifier, -) { - BoxWithConstraints( - modifier = modifier - .fillMaxWidth() - .padding(start = 28.dp, end = 32.dp, top = 28.dp, bottom = 12.dp), - ) { - val columns = if (maxWidth > 760.dp) 5 else 4 - val cardWidth = ((maxWidth - CARD_SPACING * (columns - 1)) / columns) - .coerceIn(120.dp, 200.dp) - - // Discovery, results, error and "no matches" all share this pane. Only the - // heading and the item source change, so the grid never unmounts and remounts. - val showingDiscovery = state.isDiscovery - val items = if (showingDiscovery) discoveryItems else state.results - - Column(Modifier.fillMaxSize()) { - ResultsHeading( - state = state, - showingDiscovery = showingDiscovery, - onBackFromGenre = onBackFromGenre, - onShowRequests = onShowRequests, - requestFocusRequester = resultsEntry, - keyboardReturn = keyboardReturn, - ) - val genres = state.genreSuggestions - if (showingDiscovery && genres.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - GenreTiles( - genres = genres, - resultsEntry = resultsEntry, - keyboardReturn = keyboardReturn, - // A genre opens the shelf of titles that are in it: running its name - // through search matched a film called Drama and missed most of the - // drama. - onSelected = onGenreSelected, - ) - } - Spacer(Modifier.height(12.dp)) - - when { - state.errorMessage != null && state.results.isEmpty() -> SearchError( - message = state.errorMessage, - onRetry = onRetry, - resultsEntry = resultsEntry, - keyboardReturn = keyboardReturn, - ) - !showingDiscovery && state.requestMode -> - RequestOptions( - state = state, - resultsEntry = resultsEntry, - keyboardReturn = keyboardReturn, - onRequest = onRequest, - modifier = Modifier.weight(1f), - ) - items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery) - else -> ResultsGrid( - items = items, - columns = columns, - cardWidth = cardWidth, - resultsEntry = resultsEntry, - keyboardReturn = keyboardReturn, - returnFocusItemId = returnFocusItemId, - returnFocusRequester = returnFocusRequester, - onItemFocused = onItemFocused, - onItemSelected = onItemSelected, - // Only a genre pages. A search is one response, and asking the grid to - // watch for the end of a list that has no more behind it is a scroll - // listener running for nothing. - paging = state.genre != null && (state.canLoadMore || state.isLoadingMore), - loadingMore = state.isLoadingMore, - pagingErrorMessage = state.pagingErrorMessage, - onRetryPage = onRetry, - onLoadMore = onLoadMore, - ) - } - } - } -} - -@Composable -private fun ResultsHeading( - state: SearchUiState, - showingDiscovery: Boolean, - onBackFromGenre: () -> Unit, - onShowRequests: () -> Unit, - requestFocusRequester: FocusRequester, - keyboardReturn: FocusRequester, -) { - val title = when { - showingDiscovery -> "Browse your library" - // A genre says what it is. It was never searched for, so calling it a search result - // would misdescribe both where the titles came from and how to get out of it. - state.genre != null -> state.genre - state.isEmptyResult -> "Search results — no matches" - else -> "Search results for “${state.query.trim()}”" - } - Row(verticalAlignment = Alignment.CenterVertically) { - if (state.genre != null) { - FocusScaleContainer( - onFocused = {}, - onClick = onBackFromGenre, - contentDescription = "Back to search", - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)), - ) { focused -> - Box( - modifier = Modifier - .fillMaxSize() - .background(if (focused) Color.White else KeyIdle), - contentAlignment = Alignment.Center, - ) { - Icon( - MembyIcon.ArrowBack.mark, - contentDescription = null, - tint = if (focused) KeyLabelFocused else Heading, - modifier = Modifier.size(21.dp), - ) - } - } - Spacer(Modifier.width(12.dp)) - } - Text( - title, - color = Heading, - fontSize = 22.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - // The count belongs to a search, where it is the whole answer. On a genre it would - // be the number of cards fetched so far, which grows as the viewer scrolls and - // describes the paging rather than the library. - if (state.genre == null && !showingDiscovery && state.results.isNotEmpty()) { - Spacer(Modifier.width(10.dp)) - Text("${state.results.size}", color = Muted, fontSize = 16.sp) - } - if (state.requestsAvailable && state.genre == null && !showingDiscovery && !state.requestMode) { - Spacer(Modifier.width(14.dp)) - MembyChoiceChip( - label = "Request", - selected = false, - onClick = onShowRequests, - modifier = Modifier - .then(if (state.results.isEmpty()) Modifier.focusRequester(requestFocusRequester) else Modifier) - .focusProperties { left = keyboardReturn }, - ) - } - } -} - -@Composable -private fun ResultsGrid( - items: List, - columns: Int, - cardWidth: Dp, - resultsEntry: FocusRequester, - keyboardReturn: FocusRequester, - returnFocusItemId: String?, - returnFocusRequester: FocusRequester, - onItemFocused: (BaseItem) -> Unit, - onItemSelected: (BaseItem) -> Unit, - paging: Boolean = false, - loadingMore: Boolean = false, - pagingErrorMessage: String? = null, - onRetryPage: () -> Unit = {}, - onLoadMore: () -> Unit = {}, -) { - val context = LocalContext.current - val density = LocalDensity.current - val gridState = rememberLazyGridState() - val currentOnLoadMore by rememberUpdatedState(onLoadMore) - - // Infinite scroll. Read in a snapshotFlow rather than from the composable body: the - // last visible index changes on every frame of a scroll, and reading it up here would - // recompose the whole grid the entire way down a genre. The next page is asked for a - // row early — the request has to be in flight before the viewer arrives at the end, or - // the scroll stops dead while they wait for it. - if (paging) { - LaunchedEffect(gridState, items.size, columns) { - snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } - .distinctUntilChanged() - .collect { last -> - if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) currentOnLoadMore() - } - } - } - - // Warm the first screenful so the grid does not fill in card by card. Keyed on the - // ids rather than the list, so an unchanged result set never re-fetches. - val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } } - LaunchedEffect(prefetchKey, cardWidth) { - val repo = ServiceLocator.repository - val widthPx = with(density) { cardWidth.roundToPx() }.coerceIn(180, 720) - items.take(PREFETCHED_POSTERS).forEach { item -> - val url = repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx) - ?: return@forEach - context.imageLoader.execute( - ImageRequest.Builder(context) - .data(url) - .size(widthPx, (widthPx * 3f / 2f).toInt()) - .allowHardware(true) - .crossfade(false) - .build(), - ) - } - } - - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - state = gridState, - horizontalArrangement = Arrangement.spacedBy(CARD_SPACING), - verticalArrangement = Arrangement.spacedBy(18.dp), - modifier = Modifier - .fillMaxSize(), - ) { - // itemsIndexed, not items + indexOf: BaseItem is a data class, so indexOf would - // run a deep equals per card per composition on the app's hottest new screen. - itemsIndexed(items, key = { _, item -> item.id }, contentType = { _, _ -> "search-result" }) { index, item -> - PosterGridCard( - item = item, - width = cardWidth, - onFocused = { onItemFocused(item) }, - onClick = { onItemSelected(item) }, - onLongClick = { onItemSelected(item) }, - modifier = Modifier - .then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier) - .then( - if (item.id == returnFocusItemId) { - Modifier.focusRequester(returnFocusRequester) - } else { - Modifier - }, - ) - // Leftmost column goes back to the keyboard rather than nowhere. - .focusProperties { - if (index % columns == 0) left = keyboardReturn - }, - ) - } - if (loadingMore) { - // A full-width row rather than a card-shaped placeholder: a skeleton card is - // something a remote tries to focus, and there is nothing there to open. - item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging") { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - LoadingDot() - Spacer(Modifier.width(10.dp)) - Text("Loading more", color = Muted, fontSize = 13.sp) - } - } - } - if (pagingErrorMessage != null) { - item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging-error") { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Couldn’t load more titles.", color = Muted, fontSize = 13.sp) - Spacer(Modifier.width(10.dp)) - MembyChoiceChip(label = "Try again", selected = false, onClick = onRetryPage) - } - } - } - } -} - @Composable private fun RequestOptions( state: SearchUiState, @@ -1346,118 +1075,6 @@ internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) { } } -private val GenreColors = listOf( - Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6), - Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76), -) - -private fun genreIcon(label: String): ImageVector = when { - label.contains("comedy", true) -> MembyIcon.Drama.mark - label.contains("music", true) -> MembyIcon.LiveTv.mark - label.contains("children", true) || label.contains("family", true) -> MembyIcon.Happy.mark - label.contains("sport", true) -> MembyIcon.PlayCircle.mark - label.contains("document", true) -> MembyIcon.Movie.mark - else -> MembyIcon.Sparkle.mark -} - -@Composable -private fun GenreTiles( - genres: List, - resultsEntry: FocusRequester, - keyboardReturn: FocusRequester, - onSelected: (String) -> Unit, -) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - rowItemsIndexed(genres.take(6), key = { _, item -> item }) { index, genre -> - val color = GenreColors[index % GenreColors.size] - FocusScaleContainer( - onFocused = {}, - onClick = { onSelected(genre) }, - contentDescription = "Search the $genre genre", - modifier = Modifier - .width(126.dp) - .height(72.dp) - .clip(RoundedCornerShape(10.dp)) - .then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier) - .focusProperties { if (index == 0) left = keyboardReturn }, - ) { focused -> - Box( - Modifier - .fillMaxSize() - .background(if (focused) color.copy(alpha = 0.95f) else color), - ) { - Icon( - genreIcon(genre), - contentDescription = null, - tint = Color.White.copy(alpha = 0.9f), - modifier = Modifier - .align(Alignment.TopEnd) - .padding(10.dp) - .size(30.dp), - ) - Text( - genre, - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.BottomStart).padding(10.dp), - ) - } - } - } - } -} - -@Composable -private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) { - val message = when { - showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off." - // A genre was never searched for, and saying so while a shelf opens is the one - // moment the difference is invisible on screen. - state.genre != null && state.isLoading -> "Loading ${state.genre}…" - state.genre != null -> "Nothing in this library is tagged ${state.genre}." - state.isLoading -> "Searching…" - state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…" - else -> "Nothing in this library matches that. Try fewer letters, or a different spelling." - } - Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp)) -} - -@Composable -private fun SearchError( - message: String, - onRetry: () -> Unit, - resultsEntry: FocusRequester, - keyboardReturn: FocusRequester, -) { - Column(Modifier.padding(top = 36.dp)) { - Text(message, color = Heading, fontSize = 18.sp) - Spacer(Modifier.height(14.dp)) - FocusScaleContainer( - onFocused = {}, - onClick = onRetry, - contentDescription = "Try the search again", - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .focusRequester(resultsEntry) - .focusProperties { left = keyboardReturn }, - ) { focused -> - Text( - "Try again", - color = if (focused) KeyLabelFocused else KeyLabel, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .background(if (focused) KeyFocused else KeyIdle) - .padding(horizontal = 22.dp, vertical = 11.dp), - ) - } - } -} - -private val CARD_SPACING = 16.dp private const val KEYBOARD_COLUMNS = 6 private const val ACTION_ROW_INDEX = 36 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index 5a0926c..852a820 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -36,13 +36,13 @@ data class SearchUiState( * clearing something the viewer never typed. */ val genre: String? = null, - /** A further page is on its way. The grid keeps what it has and adds a footer. */ + /** A further page is on its way. The result list keeps what it has and adds a footer. */ val isLoadingMore: Boolean = false, /** * How far into the genre the shelf has read, counted the way the *backend* counts it. * * Deliberately not `results.size`. A duplicate arriving across a page boundary is - * dropped on the way in — the grid is keyed by item id, so a repeat would be a crash + * dropped on the way in — the result list is keyed by item id, so a repeat would be a crash * rather than a doubled poster — and if the offset were then derived from the length * of the list, the next page would be requested from before where the last one ended * and would return the same cards again, for ever. @@ -216,8 +216,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { } /** - * The grid is near the end of what it holds. Ignored unless there is a genre open, more - * of it to fetch and nothing already in flight — the grid asks on every scroll, and it + * The result list is near the end of what it holds. Ignored unless there is a genre open, + * more of it to fetch and nothing already in flight — the list asks on every scroll, and it * is cheaper to refuse here than to make the screen keep track. */ fun loadMore() { @@ -340,7 +340,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { * Appending by [GenrePage.offset] rather than trusting the order of arrival is what * makes a slow page harmless: only a page that starts where the shelf currently ends is * taken, so a response for an offset the viewer has already scrolled past — or one from - * a genre they have left — is dropped rather than pasted into the middle of the grid. + * a genre they have left — is dropped rather than pasted into the middle of the list. */ private suspend fun loadGenrePage(genre: String, offset: Int) { runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) } @@ -350,7 +350,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { // A paging boundary is the one place a backend realistically repeats a // card: the shelf is ordered by premiere date and sort name precisely // because two titles sharing a date could otherwise swap places between - // two requests. If one slips through anyway, the grid is keyed by item + // two requests. If one slips through anyway, the result list is keyed by item // id and a repeat is a crash, not a duplicate poster — so the page is // appended minus anything already on the shelf, and how far the shelf // has read is counted in what the backend sent rather than in what @@ -435,7 +435,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { } // The gateway answers from the imported library and falls back to Emby // before the first import has finished, so one title reaching the pane by - // both routes is a shape this search genuinely has. The grid is keyed by + // both routes is a shape this search genuinely has. The result list is keyed by // item id, where that is a crash rather than a repeated poster. val items = found.distinctItems() // Gateway payloads carry the backend ranker's score. Preserve that diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt new file mode 100644 index 0000000..cbecfd6 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchGenres.kt @@ -0,0 +1,117 @@ +package com.ponzischeme89.memby.ui.search.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import com.ponzischeme89.memby.ui.FocusScaleContainer +import com.ponzischeme89.memby.ui.theme.MembyOnSurface + +/** Search's compact discovery strip. Genre colours stay recognisable but sit near-black. */ +@Composable +internal fun SearchGenres( + genres: List, + entryFocusRequester: FocusRequester, + keyboardReturnFocusRequester: FocusRequester, + resultsFocusRequester: FocusRequester, + resultsHaveFocusTarget: Boolean, + onFocused: () -> Unit, + onSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (genres.isEmpty()) return + + Column(modifier) { + Text( + "Genres ›", + color = MembyOnSurface, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + itemsIndexed(genres.take(6), key = { _, genre -> genre }) { index, genre -> + val colour = GenreColours[index % GenreColours.size] + FocusScaleContainer( + onFocused = onFocused, + onClick = { onSelected(genre) }, + contentDescription = "Browse the $genre genre", + modifier = Modifier + .width(126.dp) + .height(64.dp) + .clip(GenreShape) + .then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier) + .focusProperties { + if (index == 0) left = keyboardReturnFocusRequester + if (resultsHaveFocusTarget) down = resultsFocusRequester + }, + ) { focused -> + Box( + Modifier + .fillMaxSize() + .background(if (focused) colour.copy(alpha = 0.98f) else colour) + .border( + if (focused) 2.dp else 1.dp, + if (focused) Color.White else Color.White.copy(alpha = 0.10f), + GenreShape, + ) + .padding(horizontal = 11.dp, vertical = 9.dp), + ) { + Text( + genre, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.CenterStart).padding(end = 18.dp), + ) + Text( + "›", + color = Color.White.copy(alpha = if (focused) 1f else 0.72f), + fontSize = 22.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.align(Alignment.CenterEnd), + ) + } + } + } + } + } +} + +private val GenreShape = RoundedCornerShape(10.dp) + +/** Dark, desaturated counterparts of Search's established genre palette. */ +private val GenreColours = listOf( + Color(0xFF4A332C), + Color(0xFF303B50), + Color(0xFF3D334B), + Color(0xFF263F3B), + Color(0xFF4A3A29), + Color(0xFF49303A), +) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt new file mode 100644 index 0000000..6995069 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/components/SearchResults.kt @@ -0,0 +1,257 @@ +package com.ponzischeme89.memby.ui.search.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Text +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.MembyChoiceChip +import com.ponzischeme89.memby.ui.components.media.MediaResultCard +import com.ponzischeme89.memby.ui.components.media.MediaResultCardSkeleton +import com.ponzischeme89.memby.ui.components.media.MediaResultTypeGlyph +import com.ponzischeme89.memby.ui.search.SearchUiState +import com.ponzischeme89.memby.ui.search.shouldSearch +import com.ponzischeme89.memby.ui.theme.FactSeparator +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyOnSurface +import kotlinx.coroutines.flow.distinctUntilChanged + +/** Search-owned result state rendered through the same media row as My Requests. */ +@Composable +internal fun SearchResults( + state: SearchUiState, + entryFocusRequester: FocusRequester, + genresFocusRequester: FocusRequester, + genresPresent: Boolean, + keyboardReturnFocusRequester: FocusRequester, + returnFocusItemId: String?, + returnFocusRequester: FocusRequester, + artworkUrlFor: (BaseItem) -> String?, + onItemFocused: (BaseItem) -> Unit, + onItemSelected: (BaseItem) -> Unit, + onRetry: () -> Unit, + onShowRequests: () -> Unit, + onLoadMore: () -> Unit, + modifier: Modifier = Modifier, +) { + val items = state.results.filter { it.isMovie || it.isSeries } + val listState = rememberLazyListState() + val currentOnLoadMore by rememberUpdatedState(onLoadMore) + + if (state.genre != null && (state.canLoadMore || state.isLoadingMore)) { + LaunchedEffect(listState, items.size) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + .distinctUntilChanged() + .collect { last -> + if (last >= items.lastIndex - LOAD_MORE_ITEMS_AHEAD) currentOnLoadMore() + } + } + } + + Column(modifier) { + SearchResultsHeading( + state = state, + resultCount = items.size, + onShowRequests = onShowRequests, + requestFocusRequester = entryFocusRequester, + keyboardReturnFocusRequester = keyboardReturnFocusRequester, + ) + Spacer(Modifier.height(8.dp)) + + when { + state.isLoading && items.isEmpty() -> Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxSize(), + ) { + repeat(3) { MediaResultCardSkeleton() } + } + + state.errorMessage != null && items.isEmpty() -> SearchResultMessage( + message = state.errorMessage, + action = "Try again", + onAction = onRetry, + actionFocusRequester = entryFocusRequester, + genresFocusRequester = genresFocusRequester.takeIf { genresPresent }, + keyboardReturnFocusRequester = keyboardReturnFocusRequester, + ) + + items.isEmpty() -> SearchResultMessage(message = emptyMessage(state)) + + else -> LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + MediaResultCard( + title = item.name, + subtitle = searchResultSubtitle(item), + detail = item.genres.firstOrNull().orEmpty().ifBlank { "In your library" }, + artworkUrl = artworkUrlFor(item), + contentDescription = listOf(item.name, searchResultSubtitle(item)) + .filter(String::isNotBlank) + .joinToString(", "), + onFocused = { onItemFocused(item) }, + onClick = { onItemSelected(item) }, + artworkBottomStartContent = { + MediaResultTypeGlyph(isSeries = item.isSeries) + }, + modifier = Modifier + .then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier) + .then( + if (item.id == returnFocusItemId) { + Modifier.focusRequester(returnFocusRequester) + } else { + Modifier + }, + ) + .focusProperties { + left = keyboardReturnFocusRequester + if (index == 0 && genresPresent) up = genresFocusRequester + }, + ) + } + if (state.isLoadingMore) { + item(key = "search-loading-more") { + Text( + "Loading more", + color = MembyMutedText, + fontSize = 13.sp, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + ) + } + } + state.pagingErrorMessage?.let { + item(key = "search-paging-error") { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Couldn’t load more titles.", color = MembyMutedText, fontSize = 13.sp) + Spacer(Modifier.width(10.dp)) + MembyChoiceChip(label = "Try again", selected = false, onClick = onRetry) + } + } + } + } + } + } +} + +@Composable +private fun SearchResultsHeading( + state: SearchUiState, + resultCount: Int, + onShowRequests: () -> Unit, + requestFocusRequester: FocusRequester, + keyboardReturnFocusRequester: FocusRequester, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + when { + state.genre != null -> state.genre + shouldSearch(state.query) -> "Search results for “${state.query.trim()}”" + else -> "Search results" + }, + color = MembyOnSurface, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + if (resultCount > 0 && !state.isLoading) { + Spacer(Modifier.width(9.dp)) + Text(resultCount.toString(), color = MembyMutedText, fontSize = 14.sp) + } + if (state.isLoading && resultCount > 0) { + Spacer(Modifier.width(9.dp)) + Text("Searching…", color = MembyAccent, fontSize = 12.sp) + } + if ( + state.requestsAvailable && state.genre == null && shouldSearch(state.query) && + !state.requestMode && state.errorMessage == null + ) { + Spacer(Modifier.width(12.dp)) + MembyChoiceChip( + label = "Request", + selected = false, + onClick = onShowRequests, + modifier = Modifier + .then(if (resultCount == 0) Modifier.focusRequester(requestFocusRequester) else Modifier) + .focusProperties { left = keyboardReturnFocusRequester }, + ) + } + } +} + +@Composable +private fun SearchResultMessage( + message: String, + action: String? = null, + onAction: () -> Unit = {}, + actionFocusRequester: FocusRequester? = null, + genresFocusRequester: FocusRequester? = null, + keyboardReturnFocusRequester: FocusRequester? = null, +) { + Column(Modifier.fillMaxSize().padding(top = 22.dp)) { + Text(message, color = MembyMutedText, fontSize = 15.sp) + if (action != null) { + Spacer(Modifier.height(12.dp)) + MembyChoiceChip( + label = action, + selected = false, + onClick = onAction, + modifier = Modifier + .then(actionFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .focusProperties { + genresFocusRequester?.let { up = it } + keyboardReturnFocusRequester?.let { left = it } + }, + ) + } + } +} + +private fun emptyMessage(state: SearchUiState): String = when { + state.genre != null -> "Nothing in this library is tagged ${state.genre}." + shouldSearch(state.query) && state.hasSearched -> + "Nothing in this library matches that. Try fewer letters, or a different spelling." + else -> "Type a couple of letters to search movies and TV series." +} + +private fun searchResultSubtitle(item: BaseItem): String = listOfNotNull( + item.productionYear?.toString(), + when { + item.isSeries -> "Series" + item.isMovie -> "Film" + else -> null + }, +).joinToString(FactSeparator) + +private const val LOAD_MORE_ITEMS_AHEAD = 4 diff --git a/app/src/test/java/com/ponzischeme89/memby/tvhome/TvHomeProgramTest.kt b/app/src/test/java/com/ponzischeme89/memby/tvhome/TvHomeProgramTest.kt new file mode 100644 index 0000000..cfaec8f --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/tvhome/TvHomeProgramTest.kt @@ -0,0 +1,95 @@ +package com.ponzischeme89.memby.tvhome + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.UserItemData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TvHomeProgramTest { + @Test + fun `partially watched movie becomes a continue programme`() { + val programme = tvHomeProgram( + item = BaseItem( + id = "movie-1", + name = "A Film", + type = "Movie", + runTimeTicks = 7_200_000_000L, + userData = UserItemData(playbackPositionTicks = 1_800_000_000L), + ), + profileKey = "viewer", + artworkUrl = "https://example.test/backdrop.jpg", + nowMs = 123L, + ) + + requireNotNull(programme) + assertEquals(TvHomeProgramKind.CONTINUE, programme.kind) + assertEquals(180_000L, programme.positionMs) + assertEquals(720_000L, programme.durationMs) + assertEquals("memby:viewer:movie-1", programme.providerId) + } + + @Test + fun `unwatched next episode carries series metadata`() { + val programme = tvHomeProgram( + item = BaseItem( + id = "episode-4", + name = "Fourth", + type = "Episode", + seriesName = "The Show", + parentIndexNumber = 2, + indexNumber = 4, + ), + profileKey = "viewer", + artworkUrl = null, + nowMs = 123L, + ) + + requireNotNull(programme) + assertEquals(TvHomeProgramKind.NEXT, programme.kind) + assertEquals("The Show", programme.title) + assertEquals("Fourth", programme.episodeTitle) + assertEquals(2, programme.seasonNumber) + assertEquals(4, programme.episodeNumber) + } + + @Test + fun `completed watched and unsupported items are omitted`() { + assertNull(tvHomeProgram(item("Movie", played = true), "p", null, 1L)) + assertNull( + tvHomeProgram( + item("Movie", positionTicks = 9_500_000L, runtimeTicks = 10_000_000L), + "p", + null, + 1L, + ), + ) + assertNull(tvHomeProgram(item("Series"), "p", null, 1L)) + } + + @Test + fun `profile key is stable without exposing account details`() { + val first = tvHomeProfileKey("https://emby.test/", "user-1", "viewer-1") + val same = tvHomeProfileKey("https://emby.test", "user-1", "viewer-1") + val other = tvHomeProfileKey("https://emby.test", "user-2", "viewer-1") + assertEquals(first, same) + assertNotEquals(first, other) + assertTrue("user-1" !in first) + assertTrue("emby" !in first) + } + + private fun item( + type: String, + played: Boolean = false, + positionTicks: Long = 0L, + runtimeTicks: Long? = null, + ) = BaseItem( + id = "id", + name = "Name", + type = type, + runTimeTicks = runtimeTicks, + userData = UserItemData(played = played, playbackPositionTicks = positionTicks), + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeRowBrowseTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeRowBrowseTest.kt new file mode 100644 index 0000000..0cf88d3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeRowBrowseTest.kt @@ -0,0 +1,61 @@ +package com.ponzischeme89.memby.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HomeRowBrowseTest { + @Test + fun `recent releases opens the complete movie catalogue`() { + val target = homeRowBrowseTarget(row("latest-movies", "Recent New Releases", MediaRowKind.MOVIES)) + + assertEquals("all", target?.categoryId) + assertEquals("Movie", target?.itemType) + assertEquals("View All", target?.actionLabel) + } + + @Test + fun `movie and show genre rows open their matching paged catalogue`() { + val crime = homeRowBrowseTarget( + row("curated:movies:genre:crime", "Crime Movies", MediaRowKind.MOVIES), + ) + val drama = homeRowBrowseTarget( + row("curated:shows:genre:drama", "Drama Shows", MediaRowKind.SHOWS), + ) + + assertEquals("crime", crime?.categoryId) + assertEquals("Movie", crime?.itemType) + assertEquals("View All Crime", crime?.actionLabel) + assertEquals("drama", drama?.categoryId) + assertEquals("Series", drama?.itemType) + assertEquals("View All Drama", drama?.actionLabel) + } + + @Test + fun `legacy show genres remain browsable`() { + val target = homeRowBrowseTarget( + row("curated:comedy-shows", "Comedy TV Shows", MediaRowKind.SHOWS), + ) + + assertEquals("comedy", target?.categoryId) + assertEquals("Series", target?.itemType) + } + + @Test + fun `ranked and studio rows do not claim an unrelated complete catalogue`() { + assertNull(homeRowBrowseTarget(row("recommended", "Recommended", MediaRowKind.MOVIES))) + assertNull( + homeRowBrowseTarget( + row("curated:movies:studio:pixar", "More from Pixar", MediaRowKind.MOVIES), + ), + ) + } + + private fun row(id: String, title: String, kind: MediaRowKind) = HomeBrowseRow( + id = id, + title = title, + items = emptyList(), + kind = kind, + emptyMessage = "", + ) +} diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index abf5cfc..9810a3b 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.63 +0.1.64