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 banner](images/findroid-banner.png) # Findroid +![GitHub release (with filter)](https://img.shields.io/github/v/release/jarnedemeulemeester/findroid?style=for-the-badge) +![GitHub repo stars](https://img.shields.io/github/stars/jarnedemeulemeester/findroid?style=for-the-badge) +![GitHub issues](https://img.shields.io/github/issues/jarnedemeulemeester/findroid?style=for-the-badge) +![GitHub pull requests](https://img.shields.io/github/issues-pr/jarnedemeulemeester/findroid?style=for-the-badge) +![GitHub all releases](https://img.shields.io/github/downloads/jarnedemeulemeester/findroid/total?style=for-the-badge) +![GitHub](https://img.shields.io/github/license/jarnedemeulemeester/findroid?style=for-the-badge) 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.** -Get it on Google PlayAvailable at Amazon AppstoreExplore it on Huawei AppGalleryGet it on IzzyOnDroid +Get it on Google PlayAvailable at Amazon AppstoreGet it on IzzyOnDroid ## 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