diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index a6a737a5..5135239e 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -12,14 +12,14 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v1
+ uses: gradle/actions/wrapper-validation@v3
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
- name: Setup Gradle
- uses: gradle/gradle-build-action@v2
+ uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle
run: ./gradlew lintDebug ktlintCheck
assemble:
@@ -29,14 +29,14 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
- uses: gradle/wrapper-validation-action@v1
+ uses: gradle/actions/wrapper-validation@v3
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
- name: Setup Gradle
- uses: gradle/gradle-build-action@v2
+ uses: gradle/actions/setup-gradle@v3
- name: Build with Gradle
run: ./gradlew assembleDebug
# Upload all build artifacts in separate steps. This can be shortened once https://github.com/actions/upload-artifact/pull/354 is merged.
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
new file mode 100644
index 00000000..a3cf7674
--- /dev/null
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,65 @@
+name: Publish
+
+on:
+ push:
+ tags:
+ - v*
+
+jobs:
+ publish:
+ name: Publish
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.3.0
+ bundler-cache: true
+
+ - name: Validate Gradle Wrapper
+ uses: gradle/actions/wrapper-validation@v3
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: 17
+ distribution: temurin
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v3
+
+ - name: Decode keystore file
+ uses: timheuer/base64-to-file@v1
+ id: findroid_keystore
+ with:
+ fileName: 'findroid-keystore.jks'
+ encodedString: ${{ secrets.FINDROID_KEYSTORE }}
+
+ - name: Decode Play API credentials file
+ uses: timheuer/base64-to-file@v1
+ id: findroid_play_api_credentials
+ with:
+ fileName: 'findroid-play-api-credentials.json'
+ encodedString: ${{ secrets.FINDROID_PLAY_API_CREDENTIALS }}
+
+ - name: Build and publish
+ run: bundle exec fastlane publish
+ env:
+ FINDROID_KEYSTORE: ${{ steps.findroid_keystore.outputs.filePath }}
+ FINDROID_KEYSTORE_PASSWORD: ${{ secrets.FINDROID_KEYSTORE_PASSWORD }}
+ FINDROID_KEY_ALIAS: ${{ secrets.FINDROID_KEY_ALIAS }}
+ FINDROID_KEY_PASSWORD: ${{ secrets.FINDROID_KEY_PASSWORD }}
+ FINDROID_PLAY_API_CREDENTIALS: ${{ steps.findroid_play_api_credentials.outputs.filePath }}
+
+ - name: Create release
+ uses: softprops/action-gh-release@v2
+ with:
+ draft: true
+ files: |
+ ./app/phone/build/outputs/apk/libre/release/findroid-${{ github.ref_name }}-libre-arm64-v8a.apk
+ ./app/phone/build/outputs/apk/libre/release/findroid-${{ github.ref_name }}-libre-armeabi-v7a.apk
+ ./app/phone/build/outputs/apk/libre/release/findroid-${{ github.ref_name }}-libre-x86_64.apk
+ ./app/phone/build/outputs/apk/libre/release/findroid-${{ github.ref_name }}-libre-x86.apk
diff --git a/.gitignore b/.gitignore
index 16dcd122..69c4d737 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,4 +30,10 @@ render.experimental.xml
google-services.json
# Android Profiling
-*.hprof
\ No newline at end of file
+*.hprof
+
+# Fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 00000000..adc90d98
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,3 @@
+source "https://rubygems.org"
+
+gem "fastlane"
\ No newline at end of file
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 00000000..712d595b
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,218 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ CFPropertyList (3.0.7)
+ base64
+ nkf
+ rexml
+ addressable (2.8.6)
+ public_suffix (>= 2.0.2, < 6.0)
+ artifactory (3.0.17)
+ atomos (0.1.3)
+ aws-eventstream (1.3.0)
+ aws-partitions (1.913.0)
+ aws-sdk-core (3.191.6)
+ aws-eventstream (~> 1, >= 1.3.0)
+ aws-partitions (~> 1, >= 1.651.0)
+ aws-sigv4 (~> 1.8)
+ jmespath (~> 1, >= 1.6.1)
+ aws-sdk-kms (1.79.0)
+ aws-sdk-core (~> 3, >= 3.191.0)
+ aws-sigv4 (~> 1.1)
+ aws-sdk-s3 (1.146.1)
+ aws-sdk-core (~> 3, >= 3.191.0)
+ aws-sdk-kms (~> 1)
+ aws-sigv4 (~> 1.8)
+ aws-sigv4 (1.8.0)
+ aws-eventstream (~> 1, >= 1.0.2)
+ babosa (1.0.4)
+ base64 (0.2.0)
+ claide (1.1.0)
+ colored (1.2)
+ colored2 (3.1.2)
+ commander (4.6.0)
+ highline (~> 2.0.0)
+ declarative (0.0.20)
+ digest-crc (0.6.5)
+ rake (>= 12.0.0, < 14.0.0)
+ domain_name (0.6.20240107)
+ dotenv (2.8.1)
+ emoji_regex (3.2.3)
+ excon (0.110.0)
+ faraday (1.10.3)
+ faraday-em_http (~> 1.0)
+ faraday-em_synchrony (~> 1.0)
+ faraday-excon (~> 1.1)
+ faraday-httpclient (~> 1.0)
+ faraday-multipart (~> 1.0)
+ faraday-net_http (~> 1.0)
+ faraday-net_http_persistent (~> 1.0)
+ faraday-patron (~> 1.0)
+ faraday-rack (~> 1.0)
+ faraday-retry (~> 1.0)
+ ruby2_keywords (>= 0.0.4)
+ faraday-cookie_jar (0.0.7)
+ faraday (>= 0.8.0)
+ http-cookie (~> 1.0.0)
+ faraday-em_http (1.0.0)
+ faraday-em_synchrony (1.0.0)
+ faraday-excon (1.1.0)
+ faraday-httpclient (1.0.1)
+ faraday-multipart (1.0.4)
+ multipart-post (~> 2)
+ faraday-net_http (1.0.1)
+ faraday-net_http_persistent (1.2.0)
+ faraday-patron (1.0.0)
+ faraday-rack (1.0.0)
+ faraday-retry (1.0.3)
+ faraday_middleware (1.2.0)
+ faraday (~> 1.0)
+ fastimage (2.3.1)
+ fastlane (2.220.0)
+ CFPropertyList (>= 2.3, < 4.0.0)
+ addressable (>= 2.8, < 3.0.0)
+ artifactory (~> 3.0)
+ aws-sdk-s3 (~> 1.0)
+ babosa (>= 1.0.3, < 2.0.0)
+ bundler (>= 1.12.0, < 3.0.0)
+ colored (~> 1.2)
+ commander (~> 4.6)
+ dotenv (>= 2.1.1, < 3.0.0)
+ emoji_regex (>= 0.1, < 4.0)
+ excon (>= 0.71.0, < 1.0.0)
+ faraday (~> 1.0)
+ faraday-cookie_jar (~> 0.0.6)
+ faraday_middleware (~> 1.0)
+ fastimage (>= 2.1.0, < 3.0.0)
+ gh_inspector (>= 1.1.2, < 2.0.0)
+ google-apis-androidpublisher_v3 (~> 0.3)
+ google-apis-playcustomapp_v1 (~> 0.1)
+ google-cloud-env (>= 1.6.0, < 2.0.0)
+ google-cloud-storage (~> 1.31)
+ highline (~> 2.0)
+ http-cookie (~> 1.0.5)
+ json (< 3.0.0)
+ jwt (>= 2.1.0, < 3)
+ mini_magick (>= 4.9.4, < 5.0.0)
+ multipart-post (>= 2.0.0, < 3.0.0)
+ naturally (~> 2.2)
+ optparse (>= 0.1.1, < 1.0.0)
+ plist (>= 3.1.0, < 4.0.0)
+ rubyzip (>= 2.0.0, < 3.0.0)
+ security (= 0.1.5)
+ simctl (~> 1.6.3)
+ terminal-notifier (>= 2.0.0, < 3.0.0)
+ terminal-table (~> 3)
+ tty-screen (>= 0.6.3, < 1.0.0)
+ tty-spinner (>= 0.8.0, < 1.0.0)
+ word_wrap (~> 1.0.0)
+ xcodeproj (>= 1.13.0, < 2.0.0)
+ xcpretty (~> 0.3.0)
+ xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
+ gh_inspector (1.1.3)
+ google-apis-androidpublisher_v3 (0.54.0)
+ google-apis-core (>= 0.11.0, < 2.a)
+ google-apis-core (0.11.3)
+ addressable (~> 2.5, >= 2.5.1)
+ googleauth (>= 0.16.2, < 2.a)
+ httpclient (>= 2.8.1, < 3.a)
+ mini_mime (~> 1.0)
+ representable (~> 3.0)
+ retriable (>= 2.0, < 4.a)
+ rexml
+ google-apis-iamcredentials_v1 (0.17.0)
+ google-apis-core (>= 0.11.0, < 2.a)
+ google-apis-playcustomapp_v1 (0.13.0)
+ google-apis-core (>= 0.11.0, < 2.a)
+ google-apis-storage_v1 (0.31.0)
+ google-apis-core (>= 0.11.0, < 2.a)
+ google-cloud-core (1.7.0)
+ google-cloud-env (>= 1.0, < 3.a)
+ google-cloud-errors (~> 1.0)
+ google-cloud-env (1.6.0)
+ faraday (>= 0.17.3, < 3.0)
+ google-cloud-errors (1.4.0)
+ google-cloud-storage (1.47.0)
+ addressable (~> 2.8)
+ digest-crc (~> 0.4)
+ google-apis-iamcredentials_v1 (~> 0.1)
+ google-apis-storage_v1 (~> 0.31.0)
+ google-cloud-core (~> 1.6)
+ googleauth (>= 0.16.2, < 2.a)
+ mini_mime (~> 1.0)
+ googleauth (1.8.1)
+ faraday (>= 0.17.3, < 3.a)
+ jwt (>= 1.4, < 3.0)
+ multi_json (~> 1.11)
+ os (>= 0.9, < 2.0)
+ signet (>= 0.16, < 2.a)
+ highline (2.0.3)
+ http-cookie (1.0.5)
+ domain_name (~> 0.5)
+ httpclient (2.8.3)
+ jmespath (1.6.2)
+ json (2.7.2)
+ jwt (2.8.1)
+ base64
+ mini_magick (4.12.0)
+ mini_mime (1.1.5)
+ multi_json (1.15.0)
+ multipart-post (2.4.0)
+ nanaimo (0.3.0)
+ naturally (2.2.1)
+ nkf (0.2.0)
+ optparse (0.4.0)
+ os (1.1.4)
+ plist (3.7.1)
+ public_suffix (5.0.5)
+ rake (13.2.1)
+ representable (3.2.0)
+ declarative (< 0.1.0)
+ trailblazer-option (>= 0.1.1, < 0.2.0)
+ uber (< 0.2.0)
+ retriable (3.1.2)
+ rexml (3.2.6)
+ rouge (2.0.7)
+ ruby2_keywords (0.0.5)
+ rubyzip (2.3.2)
+ security (0.1.5)
+ signet (0.19.0)
+ addressable (~> 2.8)
+ faraday (>= 0.17.5, < 3.a)
+ jwt (>= 1.5, < 3.0)
+ multi_json (~> 1.10)
+ simctl (1.6.10)
+ CFPropertyList
+ naturally
+ terminal-notifier (2.0.0)
+ terminal-table (3.0.2)
+ unicode-display_width (>= 1.1.1, < 3)
+ trailblazer-option (0.1.2)
+ tty-cursor (0.7.1)
+ tty-screen (0.8.2)
+ tty-spinner (0.9.3)
+ tty-cursor (~> 0.7)
+ uber (0.1.0)
+ unicode-display_width (2.5.0)
+ word_wrap (1.0.0)
+ xcodeproj (1.24.0)
+ CFPropertyList (>= 2.3.3, < 4.0)
+ atomos (~> 0.1.3)
+ claide (>= 1.0.2, < 2.0)
+ colored2 (~> 3.1)
+ nanaimo (~> 0.3.0)
+ rexml (~> 3.2.4)
+ xcpretty (0.3.0)
+ rouge (~> 2.0.7)
+ xcpretty-travis-formatter (1.0.1)
+ xcpretty (~> 0.2, >= 0.0.7)
+
+PLATFORMS
+ ruby
+ x86_64-linux
+
+DEPENDENCIES
+ fastlane
+
+BUNDLED WITH
+ 2.5.4
diff --git a/README.md b/README.md
index a6b5152c..10842fa3 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,12 @@

# Findroid
+
+
+
+
+
+
Findroid is third-party Android application for Jellyfin that provides a native user interface to browse and play movies and series.
@@ -8,7 +14,7 @@ I am developing this application in my spare time.
**This project is in its early stages so expect bugs.**
-


+

## Screenshots
| Home | Library | Movie | Season | Episode |
@@ -23,7 +29,7 @@ I am developing this application in my spare time.
- ExoPlayer
- Video codecs: H.263, H.264, H.265, VP8, VP9, AV1
- Support depends on Android device
- - Audio codecs: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AMR-NB, AMR-WB, AAC, AC-3, E-AC-3, DTS, DTS-HD, TrueHD
+ - Audio codecs: Vorbis, Opus, FLAC, ALAC, PCM, MP3, AAC, AC-3, E-AC-3, DTS, DTS-HD, TrueHD
- Support provided by ExoPlayer FFmpeg extension
- Subtitle codecs: SRT, VTT, SSA/ASS, PGSSUB
- SSA/ASS has limited styling support see [this issue](https://github.com/google/ExoPlayer/issues/8435)
@@ -34,6 +40,9 @@ I am developing this application in my spare time.
- Subtitle codecs: SRT, VTT, SSA/ASS, DVDSUB
- Optionally force software decoding when hardware decoding has issues.
- Picture-in-picture mode
+- Media chapters
+ - Timeline markers
+ - Chapter navigation gestures
## Planned features
- Android TV
diff --git a/app/phone/build.gradle.kts b/app/phone/build.gradle.kts
index 670bf9fc..9aa095ae 100644
--- a/app/phone/build.gradle.kts
+++ b/app/phone/build.gradle.kts
@@ -21,6 +21,20 @@ android {
versionCode = Versions.appCode
versionName = Versions.appName
+
+ testInstrumentationRunner = "dev.jdtech.jellyfin.HiltTestRunner"
+ }
+
+ applicationVariants.all {
+ val variant = this
+ variant.outputs
+ .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
+ .forEach { output ->
+ if (variant.buildType.name == "release") {
+ val outputFileName = "findroid-v${variant.versionName}-${variant.flavorName}-${output.getFilter("ABI")}.apk"
+ output.outputFileName = outputFileName
+ }
+ }
}
buildTypes {
@@ -47,9 +61,6 @@ android {
dimension = "variant"
isDefault = true
}
- register("huawei") {
- dimension = "variant"
- }
}
splits {
@@ -61,6 +72,8 @@ android {
}
compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+
sourceCompatibility = Versions.java
targetCompatibility = Versions.java
}
@@ -78,11 +91,11 @@ ktlint {
}
dependencies {
- implementation(project(":core"))
- implementation(project(":data"))
- implementation(project(":preferences"))
- implementation(project(":player:core"))
- implementation(project(":player:video"))
+ implementation(projects.core)
+ implementation(projects.data)
+ implementation(projects.preferences)
+ implementation(projects.player.core)
+ implementation(projects.player.video)
implementation(libs.aboutlibraries.core)
implementation(libs.aboutlibraries)
implementation(libs.androidx.activity)
@@ -90,9 +103,7 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
implementation(libs.androidx.hilt.work)
- implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewmodel)
- implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.navigation.fragment)
@@ -109,7 +120,14 @@ dependencies {
implementation(libs.jellyfin.core)
compileOnly(libs.libmpv)
implementation(libs.material)
+ implementation(libs.media3.ffmpeg.decoder)
implementation(libs.timber)
- implementation(rootProject.files("libs/lib-decoder-ffmpeg-release.aar"))
+ coreLibraryDesugaring(libs.android.desugar.jdk)
+
+ androidTestImplementation(libs.androidx.room.runtime)
+ androidTestImplementation(libs.junit)
+ androidTestImplementation(libs.bundles.androidx.test)
+ androidTestImplementation(libs.hilt.android.testing)
+ kspTest(libs.hilt.android.compiler)
}
diff --git a/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/HiltTestRunner.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/HiltTestRunner.kt
new file mode 100644
index 00000000..e7f52dcb
--- /dev/null
+++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/HiltTestRunner.kt
@@ -0,0 +1,16 @@
+package dev.jdtech.jellyfin
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+import dagger.hilt.android.testing.HiltTestApplication
+
+class HiltTestRunner : AndroidJUnitRunner() {
+ override fun newApplication(
+ cl: ClassLoader?,
+ className: String?,
+ context: Context?,
+ ): Application {
+ return super.newApplication(cl, HiltTestApplication::class.java.name, context)
+ }
+}
diff --git a/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/MainActivityTest.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/MainActivityTest.kt
new file mode 100644
index 00000000..486b6759
--- /dev/null
+++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/MainActivityTest.kt
@@ -0,0 +1,95 @@
+package dev.jdtech.jellyfin
+
+import android.util.Log
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.test.core.app.launchActivity
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
+import androidx.test.espresso.action.ViewActions.typeText
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.isEnabled
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.work.Configuration
+import androidx.work.testing.SynchronousExecutor
+import androidx.work.testing.WorkManagerTestInitHelper
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.UninstallModules
+import dev.jdtech.jellyfin.di.DatabaseModule
+import org.hamcrest.CoreMatchers.allOf
+import org.hamcrest.CoreMatchers.not
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import javax.inject.Inject
+
+@HiltAndroidTest
+@UninstallModules(DatabaseModule::class)
+class MainActivityTest {
+ @get:Rule
+ var hiltRule = HiltAndroidRule(this)
+
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
+
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val config = Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .setMinimumLoggingLevel(Log.DEBUG)
+ .setExecutor(SynchronousExecutor())
+ .build()
+
+ // Initialize WorkManager for instrumentation tests.
+ WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
+ }
+
+ @Test
+ fun testMainFlow() {
+ launchActivity().use {
+ // Wait for the app to load
+ waitForElement(allOf(withId(R.id.edit_text_server_address), isDisplayed()))
+
+ // Connect to demo server
+ onView(withId(R.id.edit_text_server_address)).perform(typeText("https://demo.jellyfin.org/stable"), closeSoftKeyboard())
+ onView(withId(R.id.button_connect)).perform(click())
+
+ // Connecting to the server
+ waitForElement(allOf(withId(R.id.edit_text_username), isDisplayed()))
+
+ // Login
+ onView(withId(R.id.edit_text_username)).perform(typeText("demo"), closeSoftKeyboard())
+ onView(withId(R.id.button_login)).perform(click())
+
+ // Navigate to My media
+ waitForElement(allOf(withText("Continue Watching"), isDisplayed()))
+ onView(withId(R.id.mediaFragment)).perform(click())
+
+ // Navigate to movies
+ waitForElement(allOf(withText("Movies"), isDisplayed()))
+ onView(withText("Movies")).perform(click())
+
+ // Navigate to Battle of the Stars
+ waitForElement(allOf(withText("Battle of the Stars"), isDisplayed()))
+ onView(withText("Battle of the Stars")).perform(click())
+
+ // Play the movie
+ waitForElement(allOf(withId(R.id.play_button), isEnabled()))
+ onView(withId(R.id.play_button)).perform(click())
+
+ // Wait for movie to start playing
+ waitForElement(allOf(withId(androidx.media3.ui.R.id.exo_buffering), isDisplayed()))
+ waitForElement(allOf(withId(androidx.media3.ui.R.id.exo_buffering), not(isDisplayed())))
+
+ // Navigate back
+ Espresso.pressBack()
+ }
+ }
+}
diff --git a/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/ViewPropertyChangeCallback.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/ViewPropertyChangeCallback.kt
new file mode 100644
index 00000000..a3a0c8b1
--- /dev/null
+++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/ViewPropertyChangeCallback.kt
@@ -0,0 +1,64 @@
+package dev.jdtech.jellyfin
+
+import android.view.View
+import android.view.ViewTreeObserver
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.IdlingRegistry
+import androidx.test.espresso.IdlingResource
+import androidx.test.espresso.UiController
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
+import androidx.test.espresso.matcher.ViewMatchers.isRoot
+import org.hamcrest.CoreMatchers
+import org.hamcrest.Matcher
+import org.hamcrest.StringDescription
+
+private class ViewPropertyChangeCallback(private val matcher: Matcher, private val view: View) : IdlingResource, ViewTreeObserver.OnDrawListener {
+ private lateinit var callback: IdlingResource.ResourceCallback
+ private var matched = false
+
+ override fun getName() = "View property change callback"
+
+ override fun isIdleNow() = matched
+
+ override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
+ this.callback = callback
+ }
+
+ override fun onDraw() {
+ matched = matcher.matches(view)
+ callback.onTransitionToIdle()
+ }
+}
+
+fun waitUntil(matcher: Matcher): ViewAction = object : ViewAction {
+ override fun getConstraints(): Matcher {
+ return CoreMatchers.any(View::class.java)
+ }
+
+ override fun getDescription(): String {
+ return StringDescription().let {
+ matcher.describeTo(it)
+ "wait until: $it"
+ }
+ }
+
+ override fun perform(uiController: UiController, view: View) {
+ if (!matcher.matches(view)) {
+ ViewPropertyChangeCallback(matcher, view).run {
+ try {
+ IdlingRegistry.getInstance().register(this)
+ view.viewTreeObserver.addOnDrawListener(this)
+ uiController.loopMainThreadUntilIdle()
+ } finally {
+ view.viewTreeObserver.removeOnDrawListener(this)
+ IdlingRegistry.getInstance().unregister(this)
+ }
+ }
+ }
+ }
+}
+
+fun waitForElement(matcher: Matcher) {
+ onView(isRoot()).perform(waitUntil(hasDescendant(matcher)))
+}
diff --git a/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/di/DatabaseTestModule.kt b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/di/DatabaseTestModule.kt
new file mode 100644
index 00000000..e5a367b1
--- /dev/null
+++ b/app/phone/src/androidTest/kotlin/dev/jdtech/jellyfin/di/DatabaseTestModule.kt
@@ -0,0 +1,29 @@
+package dev.jdtech.jellyfin.di
+
+import android.content.Context
+import androidx.room.Room
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import dev.jdtech.jellyfin.database.ServerDatabase
+import dev.jdtech.jellyfin.database.ServerDatabaseDao
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseTestModule {
+ @Singleton
+ @Provides
+ fun provideServerDatabaseDao(@ApplicationContext app: Context): ServerDatabaseDao {
+ return Room.inMemoryDatabaseBuilder(
+ app.applicationContext,
+ ServerDatabase::class.java,
+ )
+ .fallbackToDestructiveMigration()
+ .allowMainThreadQueries()
+ .build()
+ .getServerDatabaseDao()
+ }
+}
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt
index 7e3b5a32..721e8f5e 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/BaseApplication.kt
@@ -10,9 +10,11 @@ import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.request.CachePolicy
import com.google.android.material.color.DynamicColors
+import com.google.android.material.color.DynamicColorsOptions
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject
+import dev.jdtech.jellyfin.core.R as CoreR
@HiltAndroidApp
class BaseApplication : Application(), Configuration.Provider, ImageLoaderFactory {
@@ -40,7 +42,12 @@ class BaseApplication : Application(), Configuration.Provider, ImageLoaderFactor
"dark" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
- if (appPreferences.dynamicColors) DynamicColors.applyToActivitiesIfAvailable(this)
+ if (appPreferences.dynamicColors) {
+ val dynamicColorsOptions = DynamicColorsOptions.Builder()
+ .setThemeOverlay(CoreR.style.ThemeOverlay_Findroid_DynamicColors)
+ .build()
+ DynamicColors.applyToActivitiesIfAvailable(this, dynamicColorsOptions)
+ }
}
override fun newImageLoader(): ImageLoader {
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
index 653c4ce6..989988ca 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/BasePlayerActivity.kt
@@ -8,7 +8,6 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.updatePadding
-import androidx.media3.exoplayer.trackselection.MappingTrackSelector
import androidx.media3.session.MediaSession
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
@@ -72,19 +71,6 @@ abstract class BasePlayerActivity : AppCompatActivity() {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
- protected fun isRendererType(
- mappedTrackInfo: MappingTrackSelector.MappedTrackInfo,
- rendererIndex: Int,
- type: Int,
- ): Boolean {
- val trackGroupArray = mappedTrackInfo.getTrackGroups(rendererIndex)
- if (trackGroupArray.length == 0) {
- return false
- }
- val trackType = mappedTrackInfo.getRendererType(rendererIndex)
- return type == trackType
- }
-
protected fun configureInsets(playerControls: View) {
playerControls.setOnApplyWindowInsetsListener { _, windowInsets ->
val cutout = windowInsets.displayCutout
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
index 912322ad..cded4ef6 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/MainActivity.kt
@@ -165,7 +165,7 @@ class MainActivity : AppCompatActivity() {
private fun applyTheme() {
if (appPreferences.amoledTheme) {
- setTheme(CoreR.style.Theme_FindroidAMOLED)
+ setTheme(CoreR.style.ThemeOverlay_Findroid_Amoled)
}
}
diff --git a/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt b/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
index 84982dbb..e21c79b3 100644
--- a/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
+++ b/app/phone/src/main/java/dev/jdtech/jellyfin/PlayerActivity.kt
@@ -7,11 +7,13 @@ import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
+import android.graphics.Color
import android.graphics.Rect
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Process
+import android.provider.Settings
import android.util.Rational
import android.view.View
import android.view.WindowManager
@@ -27,15 +29,14 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.C
-import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
+import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import androidx.navigation.navArgs
import dagger.hilt.android.AndroidEntryPoint
import dev.jdtech.jellyfin.databinding.ActivityPlayerBinding
import dev.jdtech.jellyfin.dialogs.SpeedSelectionDialogFragment
import dev.jdtech.jellyfin.dialogs.TrackSelectionDialogFragment
-import dev.jdtech.jellyfin.mpv.MPVPlayer
import dev.jdtech.jellyfin.utils.PlayerGestureHelper
import dev.jdtech.jellyfin.utils.PreviewScrubListener
import dev.jdtech.jellyfin.viewmodels.PlayerActivityViewModel
@@ -56,6 +57,7 @@ class PlayerActivity : BasePlayerActivity() {
private var playerGestureHelper: PlayerGestureHelper? = null
override val viewModel: PlayerActivityViewModel by viewModels()
private var previewScrubListener: PreviewScrubListener? = null
+ private var wasZoom: Boolean = false
private val isPipSupported by lazy {
// Check if device has PiP feature
@@ -112,10 +114,6 @@ class PlayerActivity : BasePlayerActivity() {
finish()
}
- binding.playerView.findViewById(R.id.back_button_alt).setOnClickListener {
- finish()
- }
-
val videoNameTextView = binding.playerView.findViewById(R.id.video_name)
val audioButton = binding.playerView.findViewById(R.id.btn_audio_track)
@@ -143,9 +141,21 @@ class PlayerActivity : BasePlayerActivity() {
}
}
- // Trick Play
+ // Trickplay
previewScrubListener?.let {
- it.currentTrickPlay = currentTrickPlay
+ it.currentTrickplay = currentTrickplay
+ }
+
+ // Chapters
+ if (appPreferences.showChapterMarkers && currentChapters != null) {
+ currentChapters?.let { chapters ->
+ val playerControlView = findViewById(R.id.exo_controller)
+ val numOfChapters = chapters.size
+ playerControlView.setExtraAdGroupMarkers(
+ LongArray(numOfChapters) { index -> chapters[index].startPosition },
+ BooleanArray(numOfChapters) { false },
+ )
+ }
}
// File Loaded
@@ -169,6 +179,13 @@ class PlayerActivity : BasePlayerActivity() {
viewModel.eventsChannelFlow.collect { event ->
when (event) {
is PlayerEvents.NavigateBack -> finish()
+ is PlayerEvents.IsPlayingChanged -> {
+ if (appPreferences.playerPipGesture) {
+ try {
+ setPictureInPictureParams(pipParams(event.isPlaying))
+ } catch (_: IllegalArgumentException) { }
+ }
+ }
}
}
}
@@ -238,9 +255,12 @@ class PlayerActivity : BasePlayerActivity() {
pictureInPicture()
}
- if (appPreferences.playerTrickPlay) {
+ // Set marker color
+ val timeBar = binding.playerView.findViewById(R.id.exo_progress)
+ timeBar.setAdMarkerColor(Color.WHITE)
+
+ if (appPreferences.playerTrickplay) {
val imagePreview = binding.playerView.findViewById(R.id.image_preview)
- val timeBar = binding.playerView.findViewById(R.id.exo_progress)
previewScrubListener = PreviewScrubListener(
imagePreview,
timeBar,
@@ -254,7 +274,7 @@ class PlayerActivity : BasePlayerActivity() {
hideSystemUI()
}
- override fun onNewIntent(intent: Intent?) {
+ override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
@@ -263,12 +283,17 @@ class PlayerActivity : BasePlayerActivity() {
}
override fun onUserLeaveHint() {
- if (appPreferences.playerPipGesture && viewModel.player.isPlaying && !isControlsLocked) {
+ super.onUserLeaveHint()
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
+ appPreferences.playerPipGesture &&
+ viewModel.player.isPlaying &&
+ !isControlsLocked
+ ) {
pictureInPicture()
}
}
- private fun pipParams(): PictureInPictureParams {
+ private fun pipParams(enableAutoEnter: Boolean = viewModel.player.isPlaying): PictureInPictureParams {
val displayAspectRatio = Rational(binding.playerView.width, binding.playerView.height)
val aspectRatio = binding.playerView.player?.videoSize?.let {
@@ -296,24 +321,21 @@ class PlayerActivity : BasePlayerActivity() {
)
}
- return PictureInPictureParams.Builder()
+ val builder = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.setSourceRectHint(sourceRectHint)
- .build()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ builder.setAutoEnterEnabled(enableAutoEnter)
+ }
+
+ return builder.build()
}
private fun pictureInPicture() {
if (!isPipSupported) {
return
}
- binding.playerView.useController = false
- binding.playerView.findViewById