diff --git a/.gitignore b/.gitignore index 221c490..28d8564 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea/ *.iml *.apk .DS_Store @@ -21,3 +22,4 @@ releases /plugins/.idea/workspace.xml /.kotlin /.idea/deviceManager.xml +ffmpeg/libs/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 312346f..9123f69 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,20 +127,20 @@ android { unitTests.isIncludeAndroidResources = true execution = "ANDROIDX_TEST_ORCHESTRATOR" managedDevices { - devices.create("pixel5") { + allDevices.create("pixel5") { device = "Pixel 5" apiLevel = 31 } - devices.create("nexus7") { + allDevices.create("nexus7") { device = "Nexus 7" apiLevel = 31 } - devices.create("nexus10") { + allDevices.create("nexus10") { device = "Nexus 10" apiLevel = 31 } groups.create("screenshotDevices") { - targetDevices.addAll(devices.toList()) + targetDevices.addAll(allDevices.toList()) } } } @@ -172,6 +172,8 @@ dependencies { implementation(projects.data) implementation(projects.playback) implementation(projects.ffmpeg) + implementation(files("../ffmpeg/build/downloaded-libs/ffmpeg-kit-full-gpl-6.0-2.LTS.aar")) // Point to build dir + implementation("com.arthenica:smart-exception-java9:0.2.1") implementation(projects.scanner) implementation(projects.playbackScreen) implementation(projects.sleepTimer) diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml index feefd9d..3746381 100644 --- a/app/src/main/res/drawable/ic_launcher.xml +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/ic_launcher_background_gradient.xml b/app/src/main/res/drawable/ic_launcher_background_gradient.xml new file mode 100644 index 0000000..6b58b42 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background_gradient.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 1797f12..0a10b2d 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -3,25 +3,25 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 29aaaaa..00736a5 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -10,7 +10,10 @@ #FFFFFF - #1B2529 + #FF5722 + #BF360C + #FF5722 + #E6E6E6 #000000 #4D000000 diff --git a/build.gradle.kts b/build.gradle.kts index e21c1da..35a7250 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,4 @@ plugins { - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.jvm) apply false - alias(libs.plugins.android.app) apply false - alias(libs.plugins.android.library) apply false - alias(libs.plugins.compose.compiler) apply false id("voice.ktlint") } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..34fcc95 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + `kotlin-dsl` +} + +// Add a repository for these dependencies if not already inherited +// For buildSrc, it's good practice to define repositories explicitly. +repositories { + mavenCentral() + google() + gradlePluginPortal() +} + +dependencies { + implementation("com.android.tools.build:gradle:8.13.0") // androidPluginForGradle + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10") // kotlin.pluginForGradle + compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.10") // kotlin.compilerEmbeddable + implementation("io.github.usefulness:ktlint-gradle-plugin:0.10.0") // ktlint.gradlePlugin + implementation("org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.1.10")} + +gradlePlugin { + plugins { + create("library") { + id = "voice.library" + implementationClass = "LibraryPlugin" + } + create("app") { + id = "voice.app" + implementationClass = "AppPlugin" + } + create("compose") { + id = "voice.compose" + implementationClass = "ComposePlugin" + } + create("ktlint") { + id = "voice.ktlint" + implementationClass = "KtlintPlugin" + } + } +} + +kotlin { + jvmToolchain { + // Ensure JavaLanguageVersion is imported or use the fully qualified name + languageVersion.set(org.gradle.jvm.toolchain.JavaLanguageVersion.of(17)) + } +} diff --git a/plugins/src/main/kotlin/AppPlugin.kt b/buildSrc/src/main/kotlin/AppPlugin.kt similarity index 100% rename from plugins/src/main/kotlin/AppPlugin.kt rename to buildSrc/src/main/kotlin/AppPlugin.kt diff --git a/plugins/src/main/kotlin/ComposePlugin.kt b/buildSrc/src/main/kotlin/ComposePlugin.kt similarity index 100% rename from plugins/src/main/kotlin/ComposePlugin.kt rename to buildSrc/src/main/kotlin/ComposePlugin.kt diff --git a/plugins/src/main/kotlin/KtlintPlugin.kt b/buildSrc/src/main/kotlin/KtlintPlugin.kt similarity index 100% rename from plugins/src/main/kotlin/KtlintPlugin.kt rename to buildSrc/src/main/kotlin/KtlintPlugin.kt diff --git a/buildSrc/src/main/kotlin/LibraryPlugin.kt b/buildSrc/src/main/kotlin/LibraryPlugin.kt new file mode 100644 index 0000000..b4ec572 --- /dev/null +++ b/buildSrc/src/main/kotlin/LibraryPlugin.kt @@ -0,0 +1,38 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.configure // Required for the configure syntax + +class LibraryPlugin : Plugin { + + override fun apply(target: Project) { + target.pluginManager.apply("com.android.library") + target.pluginManager.apply("kotlin-android") + target.pluginManager.apply("voice.ktlint") + + // Access the version catalog + val libs = target.extensions.getByType(VersionCatalogsExtension::class.java).named("libs") + val minSdkVer = libs.findVersion("sdk-min").get().requiredVersion.toInt() + val compileSdkVer = libs.findVersion("sdk-compile").get().requiredVersion.toInt() + + // Configure the LibraryExtension. This block will execute once the extension is available. + target.extensions.configure { + namespace = "voice." + target.path.removePrefix(":").replace(':', '.') + this.compileSdk = compileSdkVer // Use this.compileSdk to be explicit about the receiver + + defaultConfig { + this.minSdk = minSdkVer // Use this.minSdk + // targetSdk will typically default based on compileSdk or can be set explicitly if needed + } + } + + // The rest of your setup, which might re-apply or further configure these settings + target.pluginManager.withPlugin("com.android.library") { + target.baseSetup() // baseSetup() will re-apply/override some settings, which is fine. + target.tasks.register("voiceUnitTest") { + dependsOn("testDebugUnitTest") + } + } + } +} diff --git a/plugins/src/main/kotlin/baseSetup.kt b/buildSrc/src/main/kotlin/baseSetup.kt similarity index 100% rename from plugins/src/main/kotlin/baseSetup.kt rename to buildSrc/src/main/kotlin/baseSetup.kt diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 093331a..078ad14 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -11,8 +11,8 @@ anvil { } android { - buildFeatures { - androidResources = true + androidResources { + enable = true } } diff --git a/ffmpeg/build.gradle.kts b/ffmpeg/build.gradle.kts index 2b45c7b..a3bf105 100644 --- a/ffmpeg/build.gradle.kts +++ b/ffmpeg/build.gradle.kts @@ -1,8 +1,61 @@ +import java.net.URI +import java.io.FileOutputStream + plugins { id("voice.library") } +val ffmpegFileName = "ffmpeg-kit-full-gpl-6.0-2.LTS.aar" +// Use build directory instead of source libs directory +val ffmpegAarFile = layout.buildDirectory.file("downloaded-libs/$ffmpegFileName") +val ffmpegDownloadUrl = "https://artifactory.appodeal.com/appodeal-public/com/arthenica/ffmpeg-kit-full-gpl/6.0-2.LTS/ffmpeg-kit-full-gpl-6.0-2.LTS.aar" + +abstract class DownloadFfmpeg : DefaultTask() { + @get:Input + abstract val downloadUrl: Property + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun download() { + val url = downloadUrl.get() + val file = outputFile.get().asFile + + println("Downloading FFmpeg to: ${file.absolutePath}") + + file.parentFile.mkdirs() + if (file.exists()) file.delete() + + try { + URI(url).toURL().openStream().use { input -> + FileOutputStream(file).use { output -> + input.copyTo(output) + } + } + println("Download complete. Size: ${file.length()}") + } catch (e: Exception) { + throw GradleException("Download failed", e) + } + } +} + +val downloadTask = tasks.register("downloadFfmpeg") { + group = "download" + downloadUrl.set(ffmpegDownloadUrl) + outputFile.set(ffmpegAarFile) + outputs.upToDateWhen { outputFile.get().asFile.exists() } +} + +// Make sure preBuild waits for this +tasks.named("preBuild").configure { + dependsOn(downloadTask) +} + dependencies { - implementation(libs.ffmpeg) + // Use the task provider to ensure dependency is lazy and triggers the task + compileOnly(files(downloadTask)) + + api("com.arthenica:smart-exception-java:0.2.1") implementation(libs.androidxCore) } diff --git a/folderPicker/build.gradle.kts b/folderPicker/build.gradle.kts index c5311d5..1f83985 100644 --- a/folderPicker/build.gradle.kts +++ b/folderPicker/build.gradle.kts @@ -8,7 +8,11 @@ anvil { generateDaggerFactories.set(true) } -android.buildFeatures.androidResources = true +android { + androidResources { + enable = true + } +} dependencies { implementation(projects.common) diff --git a/gradle.properties b/gradle.properties index e76bd4e..d9f2156 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,4 +13,4 @@ org.gradle.parallel=true org.gradle.configuration-cache=true kotlin.daemon.useFallbackStrategy=false org.gradle.kotlin.dsl.allWarningsAsErrors=true -org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" +org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 44d0212..37ded33 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ materialDialog = "3.3.0" dagger = "2.55" kotlin = "2.1.10" media3 = "1.5.1" -agp = "8.8.0" +agp = "8.13.0" ktlint-core = "1.5.0" ktlint-gradlePlugin = "0.10.0" retrofit = "2.11.0" @@ -25,7 +25,6 @@ androidPluginForGradle = { module = "com.android.tools.build:gradle", version.re leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android-startup", version.ref = "leakcanary" } leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android-startup", version.ref = "leakcanary" } material = "com.google.android.material:material:1.12.0" -ffmpeg = "com.arthenica:ffmpeg-kit-audio:6.0-2" junit = "junit:junit:4.13.2" mockk = "io.mockk:mockk:1.13.16" paging-runtime = "androidx.paging:paging-runtime-ktx:3.3.5" @@ -44,6 +43,7 @@ documentFile = "androidx.documentfile:documentfile:1.0.1" koTest-assert = "io.kotest:kotest-assertions-core:5.9.1" datastore = "androidx.datastore:datastore-preferences:1.1.2" seismic = "com.squareup:seismic:1.0.3" +smart-exception-java = { module = "com.arthenica:smart-exception-java" } viewBinding = { module = "androidx.databinding:viewbinding", version.ref = "agp" } appStartup = "androidx.startup:startup-runtime:1.2.0" turbine = "app.cash.turbine:turbine:1.2.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d9fbee2..ed4c299 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/onboarding/build.gradle.kts b/onboarding/build.gradle.kts index b42c2a4..6140cf3 100644 --- a/onboarding/build.gradle.kts +++ b/onboarding/build.gradle.kts @@ -9,8 +9,8 @@ anvil { } android { - buildFeatures { - androidResources = true + androidResources { + enable = true } } diff --git a/playback/build.gradle.kts b/playback/build.gradle.kts index 3384498..5c48551 100644 --- a/playback/build.gradle.kts +++ b/playback/build.gradle.kts @@ -5,8 +5,11 @@ plugins { } android { - buildFeatures { - androidResources = true + androidResources { + enable = true + } + kotlinOptions { + freeCompilerArgs += "-Xextended-compiler-checks" } } @@ -20,6 +23,9 @@ dependencies { implementation(projects.data) implementation(projects.pref) + // Changed implementation to api because classes in this module expose types from sleepTimer in their public API (interfaces/supertypes) + api(projects.sleepTimer) + implementation(libs.androidxCore) implementation(libs.datastore) implementation(libs.coroutines.guava) @@ -33,6 +39,4 @@ dependencies { testImplementation(libs.bundles.testing.jvm) testImplementation(libs.media3.testUtils.core) testImplementation(libs.media3.testUtils.robolectric) - testImplementation(libs.media3.testUtils.core) - testImplementation(libs.media3.testUtils.robolectric) } diff --git a/playback/src/main/kotlin/voice/playback/AutoSleepTimerHandler.kt b/playback/src/main/kotlin/voice/playback/AutoSleepTimerHandler.kt new file mode 100644 index 0000000..9b9baa7 --- /dev/null +++ b/playback/src/main/kotlin/voice/playback/AutoSleepTimerHandler.kt @@ -0,0 +1,56 @@ +package voice.playback + +import dagger.Lazy +import voice.common.pref.PrefKeys +import voice.logging.core.Logger +import voice.pref.Pref +import voice.sleepTimer.SleepTimerApi +import java.time.LocalTime +import java.time.format.DateTimeParseException +import javax.inject.Inject +import javax.inject.Named + +class AutoSleepTimerHandler @Inject constructor( + private val sleepTimer: Lazy, + @Named(PrefKeys.AUTO_SLEEP_TIMER) + private val autoSleepTimerPref: Pref, + @Named(PrefKeys.AUTO_SLEEP_TIMER_START) + private val autoSleepTimerStartPref: Pref, + @Named(PrefKeys.AUTO_SLEEP_TIMER_END) + private val autoSleepTimerEndPref: Pref +) { + + fun onPlaying() { + activateAutoSleepTimerIfNeeded() + } + + private fun isCurrentTimeInRange(startTime: LocalTime, endTime: LocalTime): Boolean { + val currentTime = LocalTime.now() + return if (startTime <= endTime) { + currentTime.isAfter(startTime) && currentTime.isBefore(endTime) + } else { + currentTime.isAfter(startTime) || currentTime.isBefore(endTime) + } + } + + private fun activateAutoSleepTimerIfNeeded() { + if (autoSleepTimerPref.value && !sleepTimer.get().sleepTimerActive()) { + try { + val startTimeString = autoSleepTimerStartPref.value + val endTimeString = autoSleepTimerEndPref.value + if (startTimeString.isNotBlank() && endTimeString.isNotBlank()) { + val startTime = LocalTime.parse(startTimeString) + val endTime = LocalTime.parse(endTimeString) + if (isCurrentTimeInRange(startTime, endTime)) { + Logger.i("AutoSleepTimerHandler: Auto starting sleep timer.") + sleepTimer.get().setActive(true) + } + } else { + Logger.w("AutoSleepTimerHandler: Auto sleep timer start/end time preference is blank.") + } + } catch (e: DateTimeParseException) { + Logger.e(e, "AutoSleepTimerHandler: Error parsing auto sleep timer start/end time.") + } + } + } +} diff --git a/playback/src/main/kotlin/voice/playback/PlayerController.kt b/playback/src/main/kotlin/voice/playback/PlayerController.kt index f6e39cf..2b2f428 100644 --- a/playback/src/main/kotlin/voice/playback/PlayerController.kt +++ b/playback/src/main/kotlin/voice/playback/PlayerController.kt @@ -26,6 +26,7 @@ import voice.playback.session.MediaItemProvider import voice.playback.session.PlaybackService import voice.playback.session.sendCustomCommand import voice.playback.session.toMediaIdOrNull +import voice.sleepTimer.SleepTimerPlayerControl import javax.inject.Inject import kotlin.time.Duration @@ -36,7 +37,7 @@ class PlayerController private val currentBookId: DataStore, private val bookRepository: BookRepository, private val mediaItemProvider: MediaItemProvider, -) { +) : SleepTimerPlayerControl { private var _controller: Deferred = newControllerAsync() @@ -100,11 +101,11 @@ class PlayerController controller.sendCustomCommand(CustomCommand.ForceSeekToNext) } - fun play() = executeAfterPrepare { controller -> + override fun play() = executeAfterPrepare { controller -> controller.play() } - fun playPause() = executeAfterPrepare { controller -> + override fun playPause() = executeAfterPrepare { controller -> if (controller.isPlaying) { controller.pause() } else { @@ -136,7 +137,7 @@ class PlayerController } } - fun pauseWithRewind(rewind: Duration) = executeAfterPrepare { controller -> + override fun pauseWithRewind(rewind: Duration) = executeAfterPrepare { controller -> controller.pause() controller.seekTo((controller.currentPosition - rewind.inWholeMilliseconds.coerceAtLeast(0))) } @@ -149,7 +150,7 @@ class PlayerController controller.sendCustomCommand(CustomCommand.SetGain(gain)) } - fun setVolume(volume: Float) = executeAfterPrepare { + override fun setVolume(volume: Float) = executeAfterPrepare { require(volume in 0F..1F) it.volume = volume } @@ -173,7 +174,6 @@ class PlayerController } } - //PlayBook fun setRepeat(mode: Int) = executeAfterPrepare { controller -> controller.sendCustomCommand(CustomCommand.SetSetRepeat(mode)) updateBook { it.copy(repeatMode = mode) } diff --git a/playback/src/main/kotlin/voice/playback/di/PlaybackAppModule.kt b/playback/src/main/kotlin/voice/playback/di/PlaybackAppModule.kt new file mode 100644 index 0000000..7d4476a --- /dev/null +++ b/playback/src/main/kotlin/voice/playback/di/PlaybackAppModule.kt @@ -0,0 +1,26 @@ +package voice.playback.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import voice.common.AppScope +import voice.playback.PlayerController +import voice.playback.playstate.PlayStateManager +import voice.sleepTimer.SleepTimerPlayStateProvider +import voice.sleepTimer.SleepTimerPlayerControl + +@Suppress("unused") +@Module +@ContributesTo(AppScope::class) +abstract class PlaybackAppModule { + + @Binds + abstract fun bindSleepTimerPlayStateProvider( + playStateManager: PlayStateManager + ): SleepTimerPlayStateProvider + + @Binds + abstract fun bindSleepTimerPlayerControl( + playerController: PlayerController + ): SleepTimerPlayerControl +} diff --git a/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt b/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt index 89ad249..04117f1 100644 --- a/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt +++ b/playback/src/main/kotlin/voice/playback/di/PlaybackModule.kt @@ -31,12 +31,13 @@ import voice.playback.playstate.PlayStateDelegatingListener import voice.playback.playstate.PositionUpdater import voice.playback.session.LibrarySessionCallback import voice.playback.session.PlaybackService -import voice.playback.session.SleepTimer import voice.playback.session.SleepTimerCommandUpdater +import voice.sleepTimer.SleepTimerApi import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +@Suppress("unused") @Module @ContributesTo(PlaybackScope::class) object PlaybackModule { @@ -111,7 +112,7 @@ object PlaybackModule { callback: LibrarySessionCallback, mainActivityIntentProvider: MainActivityIntentProvider, scope: CoroutineScope, - sleepTimer: SleepTimer, + sleepTimerApi: SleepTimerApi, sleepTimerCommandUpdater: SleepTimerCommandUpdater, ): MediaLibraryService.MediaLibrarySession { return MediaLibraryService.MediaLibrarySession.Builder(service, player, callback) @@ -119,7 +120,7 @@ object PlaybackModule { .build() .also { session -> scope.launch { - sleepTimer.leftSleepTimeFlow + sleepTimerApi.leftSleepTimeFlow .map { it != Duration.ZERO } .distinctUntilChanged() .collect { sleepTimerActive -> diff --git a/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt b/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt index 8b670de..2e90bda 100644 --- a/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt +++ b/playback/src/main/kotlin/voice/playback/playstate/PlayStateDelegatingListener.kt @@ -1,10 +1,14 @@ package voice.playback.playstate import androidx.media3.common.Player +import voice.playback.AutoSleepTimerHandler import javax.inject.Inject class PlayStateDelegatingListener -@Inject constructor(private val playStateManager: PlayStateManager) : Player.Listener { +@Inject constructor( + private val playStateManager: PlayStateManager, + private val autoSleepTimerHandler: AutoSleepTimerHandler +) : Player.Listener { private lateinit var player: Player @@ -27,10 +31,17 @@ class PlayStateDelegatingListener private fun updatePlayState() { val playbackState = player.playbackState - playStateManager.playState = when { + val newState = when { playbackState == Player.STATE_ENDED || playbackState == Player.STATE_IDLE -> PlayStateManager.PlayState.Paused player.playWhenReady -> PlayStateManager.PlayState.Playing else -> PlayStateManager.PlayState.Paused } + + // Check if we just transitioned to Playing + if (newState == PlayStateManager.PlayState.Playing && playStateManager.playState != PlayStateManager.PlayState.Playing) { + autoSleepTimerHandler.onPlaying() + } + + playStateManager.playState = newState } } diff --git a/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt b/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt index c84103c..08c298f 100644 --- a/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt +++ b/playback/src/main/kotlin/voice/playback/playstate/PlayStateManager.kt @@ -2,13 +2,15 @@ package voice.playback.playstate import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import voice.sleepTimer.SleepTimerPlayStateProvider import javax.inject.Inject import javax.inject.Singleton @Singleton class PlayStateManager @Inject -constructor() { +constructor() : SleepTimerPlayStateProvider { private val _playState = MutableStateFlow(PlayState.Paused) @@ -21,6 +23,14 @@ constructor() { } get() = _playState.value + // Implementation for SleepTimerPlayStateProvider + override val isPlayingFlow: kotlinx.coroutines.flow.Flow + get() = flow.map { it == PlayState.Playing } + + override fun isCurrentlyPlaying(): Boolean { + return playState == PlayState.Playing + } + enum class PlayState { Playing, Paused, diff --git a/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt b/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt index e87fbf5..d0369f8 100644 --- a/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt +++ b/playback/src/main/kotlin/voice/playback/session/LibrarySessionCallback.kt @@ -31,6 +31,7 @@ import voice.logging.core.Logger import voice.playback.player.VoicePlayer import voice.playback.session.search.BookSearchHandler import voice.playback.session.search.BookSearchParser +import voice.sleepTimer.SleepTimerApi import javax.inject.Inject class LibrarySessionCallback @@ -43,7 +44,7 @@ class LibrarySessionCallback @CurrentBook private val currentBookId: DataStore, private val sleepTimerCommandUpdater: SleepTimerCommandUpdater, - private val sleepTimer: SleepTimer, + private val sleepTimer: SleepTimerApi, private val bookRepository: BookRepository, ) : MediaLibrarySession.Callback { diff --git a/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt b/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt index 562d35d..e69de29 100644 --- a/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt +++ b/playback/src/main/kotlin/voice/playback/session/SleepTimer.kt @@ -1,13 +0,0 @@ -package voice.playback.session - -import kotlinx.coroutines.flow.Flow -import voice.common.BookId -import kotlin.time.Duration - -interface SleepTimer { - val leftSleepTimeFlow: Flow - val sleepAtEocFlow: Flow - fun sleepTimerActive(): Boolean - fun setActive(enable: Boolean) - fun setEoc(enable: Boolean, bookId: BookId) -} diff --git a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt index 71bf483..64d2ea5 100644 --- a/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt +++ b/playbackScreen/src/main/kotlin/voice/playbackScreen/BookPlayViewModel.kt @@ -302,27 +302,9 @@ class BookPlayViewModel } } } - if (autoSleepTimerPref.value && !sleepTimer.sleepTimerActive()) { - val startTime = LocalTime.parse(autoSleepTimerStart.value) - val endTime = LocalTime.parse(autoSleepTimerEnd.value) - if (isCurrentTimeInRange(startTime, endTime)) { - sleepTimer.setActive() - } - } player.playPause() } - private fun isCurrentTimeInRange(startTime: LocalTime, endTime: LocalTime): Boolean { - val currentTime = LocalTime.now() - return if (startTime <= endTime) { - // Standard case, start and end on the same day - currentTime.isAfter(startTime) && currentTime.isBefore(endTime) - } else { - // Range wraps around midnight - currentTime.isAfter(startTime) || currentTime.isBefore(endTime) - } - } - fun rewind() { player.rewind() } diff --git a/plugins/build.gradle.kts b/plugins/build.gradle.kts deleted file mode 100644 index ccf8d46..0000000 --- a/plugins/build.gradle.kts +++ /dev/null @@ -1,37 +0,0 @@ -plugins { - `kotlin-dsl` -} - -dependencies { - implementation(libs.androidPluginForGradle) - implementation(libs.kotlin.pluginForGradle) - compileOnly(libs.kotlin.compilerEmbeddable) - implementation(libs.ktlint.gradlePlugin) -} - -gradlePlugin { - plugins { - create("library") { - id = "voice.library" - implementationClass = "LibraryPlugin" - } - create("app") { - id = "voice.app" - implementationClass = "AppPlugin" - } - create("compose") { - id = "voice.compose" - implementationClass = "ComposePlugin" - } - create("ktlint") { - id = "voice.ktlint" - implementationClass = "KtlintPlugin" - } - } -} - -kotlin { - jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(17)) - } -} diff --git a/plugins/settings.gradle.kts b/plugins/settings.gradle.kts deleted file mode 100644 index 940e1b7..0000000 --- a/plugins/settings.gradle.kts +++ /dev/null @@ -1,22 +0,0 @@ -@file:Suppress("UnstableApiUsage") - -enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") - -rootProject.name = "plugins" - -plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version ("0.9.0") -} - -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - gradlePluginPortal() - } - versionCatalogs { - create("libs") { - from(files("../gradle/libs.versions.toml")) - } - } -} diff --git a/plugins/src/main/kotlin/LibraryPlugin.kt b/plugins/src/main/kotlin/LibraryPlugin.kt deleted file mode 100644 index 4a9df2f..0000000 --- a/plugins/src/main/kotlin/LibraryPlugin.kt +++ /dev/null @@ -1,19 +0,0 @@ -import org.gradle.api.Plugin -import org.gradle.api.Project - -class LibraryPlugin : Plugin { - - override fun apply(target: Project) { - target.pluginManager.run { - apply("voice.ktlint") - apply("com.android.library") - apply("kotlin-android") - withPlugin("com.android.library") { - target.baseSetup() - target.tasks.register("voiceUnitTest") { - dependsOn("testDebugUnitTest") - } - } - } - } -} diff --git a/review/play/build.gradle.kts b/review/play/build.gradle.kts index 80e86be..7acad43 100644 --- a/review/play/build.gradle.kts +++ b/review/play/build.gradle.kts @@ -9,8 +9,8 @@ anvil { } android { - buildFeatures { - androidResources = true + androidResources { + enable = true } } diff --git a/scripts/build.gradle.kts b/scripts/build.gradle.kts index 350b60b..7cc80f6 100644 --- a/scripts/build.gradle.kts +++ b/scripts/build.gradle.kts @@ -1,5 +1,7 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension + plugins { - alias(libs.plugins.kotlin.jvm) + id("org.jetbrains.kotlin.jvm") id("voice.ktlint") application } @@ -12,7 +14,8 @@ dependencies { implementation(libs.clikt) } -kotlin { +// Use explicit configuration to avoid IDE accessor issues +configure { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) } diff --git a/settings.gradle.kts b/settings.gradle.kts index cd784e3..4c5d285 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,7 +9,8 @@ pluginManagement { gradlePluginPortal() mavenCentral() google() - includeBuild("plugins") + maven { url = uri("https://jitpack.io") } // Corrected uri() usage + // includeBuild("plugins") // Removed this line } } @@ -17,6 +18,8 @@ dependencyResolutionManagement { repositories { google() maven { setUrl("https://artifactory-external.vkpartner.ru/artifactory/maven") } + mavenCentral() + maven { url = uri("https://jitpack.io") } // Added for ffmpeg-kit exclusiveContent { forRepository { @@ -26,13 +29,11 @@ dependencyResolutionManagement { includeGroupByRegex("com.github.PaulWoitaschek.*") } } - - mavenCentral() } } plugins { - id("com.gradle.develocity") version "3.19.1" + id("com.gradle.develocity") version "4.2" id("org.gradle.toolchains.foojay-resolver-convention") version ("0.9.0") } diff --git a/sleepTimer/build.gradle.kts b/sleepTimer/build.gradle.kts index dc8db31..8759315 100644 --- a/sleepTimer/build.gradle.kts +++ b/sleepTimer/build.gradle.kts @@ -11,7 +11,6 @@ anvil { dependencies { implementation(projects.common) implementation(projects.strings) - implementation(projects.playback) implementation(projects.data) implementation(projects.pref) diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt index 7bae251..14f4a38 100644 --- a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt +++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimer.kt @@ -16,9 +16,6 @@ import voice.common.BookId import voice.common.pref.PrefKeys import voice.data.repo.BookRepository import voice.logging.core.Logger -import voice.playback.PlayerController -import voice.playback.playstate.PlayStateManager -import voice.playback.playstate.PlayStateManager.PlayState.Playing import voice.pref.Pref import javax.inject.Inject import javax.inject.Named @@ -27,19 +24,18 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -import voice.playback.session.SleepTimer as PlaybackSleepTimer @Singleton -@ContributesBinding(AppScope::class) +@ContributesBinding(AppScope::class, SleepTimerApi::class) class SleepTimer @Inject constructor( - private val playStateManager: PlayStateManager, + private val playStateProvider: SleepTimerPlayStateProvider, private val shakeDetector: ShakeDetector, @Named(PrefKeys.SLEEP_TIME) private val sleepTimePref: Pref, - private val playerController: PlayerController, + private val sleepTimerPlayerControl: SleepTimerPlayerControl, private val bookRepo: BookRepository, -) : PlaybackSleepTimer { +) : SleepTimerApi { private val scope = MainScope() private val fadeOutDuration = 10.seconds @@ -84,7 +80,7 @@ class SleepTimer fun setActive(sleepTime: Duration = sleepTimePref.value.minutes) { Logger.i("Starting sleepTimer. Pause in $sleepTime.") leftSleepTime = sleepTime - playerController.setVolume(1F) + sleepTimerPlayerControl.setVolume(1F) sleepJob?.cancel() sleepJob = scope.launch { startSleepTimerCountdown() @@ -93,7 +89,7 @@ class SleepTimer withTimeout(shakeToResetTime) { shakeDetector.detect() Logger.i("Shake detected. Reset sleep time") - playerController.play() + sleepTimerPlayerControl.play() setActive() } Logger.i("exiting") @@ -112,27 +108,44 @@ class SleepTimer withTimeout(shakeToResetTime) { shakeDetector.detect() Logger.i("Shake detected. Reset sleep time") - playerController.play() + sleepTimerPlayerControl.play() startEoc(bookId) } Logger.i("exiting") } - playerController.setVolume(1F) + sleepTimerPlayerControl.setVolume(1F) } private suspend fun startSleepTimerCountdown() { var interval = 500.milliseconds + var shakeDetectionJob: Job? = null + while (leftSleepTime > Duration.ZERO) { suspendUntilPlaying() - if (leftSleepTime < fadeOutDuration) { + if (leftSleepTime < fadeOutDuration && shakeDetectionJob == null) { + // Start shake detection when fade-out begins + shakeDetectionJob = scope.launch { + withTimeout(fadeOutDuration) { + shakeDetector.detect() + Logger.i("Shake detected during fade-out. Resetting timer.") + sleepJob?.cancel() // Cancel the main sleep job + sleepTimerPlayerControl.setVolume(1F) + setActive() // Restart the timer + } + } interval = 200.milliseconds + } + + if (leftSleepTime < fadeOutDuration) { updateVolumeForSleepTime() } + delay(interval) leftSleepTime = (leftSleepTime - interval).coerceAtLeast(Duration.ZERO) } - playerController.pauseWithRewind(fadeOutDuration) - playerController.setVolume(1F) + shakeDetectionJob?.cancel() // Cancel shake detection if timer finishes + sleepTimerPlayerControl.pauseWithRewind(fadeOutDuration) + sleepTimerPlayerControl.setVolume(1F) } private suspend fun startSleepEocCountdown(bookId: BookId) { @@ -151,7 +164,7 @@ class SleepTimer delay(timeLeft.coerceAtLeast(125).coerceAtMost(5000).milliseconds) } - playerController.playPause() + sleepTimerPlayerControl.playPause() } private fun updateVolumeForSleepTime() { @@ -162,14 +175,14 @@ class SleepTimer }.coerceIn(0F, 1F) val volume = 1 - FastOutSlowInInterpolator().getInterpolation(1 - percentageOfTimeLeft) - playerController.setVolume(volume) + sleepTimerPlayerControl.setVolume(volume) } private suspend fun suspendUntilPlaying() { - if (playStateManager.playState != Playing) { + if (!playStateProvider.isCurrentlyPlaying()) { Logger.i("Not playing. Wait for Playback to continue.") - playStateManager.flow - .filter { it == Playing } + playStateProvider.isPlayingFlow + .filter { it /* it is true when playing */ } .first() Logger.i("Playback continued.") } @@ -178,7 +191,7 @@ class SleepTimer private fun cancel() { sleepJob?.cancel() leftSleepTime = Duration.ZERO - playerController.setVolume(1F) + sleepTimerPlayerControl.setVolume(1F) sleepAtEoc = false } } diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerApi.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerApi.kt new file mode 100644 index 0000000..97df1b2 --- /dev/null +++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerApi.kt @@ -0,0 +1,13 @@ +package voice.sleepTimer + +import kotlinx.coroutines.flow.StateFlow +import voice.common.BookId +import kotlin.time.Duration + +interface SleepTimerApi { + val leftSleepTimeFlow: StateFlow + val sleepAtEocFlow: StateFlow + fun sleepTimerActive(): Boolean + fun setActive(enable: Boolean) + fun setEoc(enable: Boolean, bookId: BookId) +} diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerPlayStateProvider.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerPlayStateProvider.kt new file mode 100644 index 0000000..94c9ee6 --- /dev/null +++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerPlayStateProvider.kt @@ -0,0 +1,11 @@ +package voice.sleepTimer + +import kotlinx.coroutines.flow.Flow + +interface SleepTimerPlayStateProvider { + /** Emits the current playback state, true if playing, false otherwise. */ + val isPlayingFlow: Flow + + /** Gets the current playback state, true if playing, false otherwise. */ + fun isCurrentlyPlaying(): Boolean +} diff --git a/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerPlayerControl.kt b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerPlayerControl.kt new file mode 100644 index 0000000..52700ff --- /dev/null +++ b/sleepTimer/src/main/kotlin/voice/sleepTimer/SleepTimerPlayerControl.kt @@ -0,0 +1,10 @@ +package voice.sleepTimer + +import kotlin.time.Duration + +interface SleepTimerPlayerControl { + fun setVolume(volume: Float) + fun pauseWithRewind(rewind: Duration) + fun playPause() + fun play() +} diff --git a/strings/build.gradle.kts b/strings/build.gradle.kts index 48ed901..020ff3f 100644 --- a/strings/build.gradle.kts +++ b/strings/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - buildFeatures { - androidResources = true + androidResources { + enable = true } }