From 38888e902ae9ee28fe3728b307d48e14bcc67e72 Mon Sep 17 00:00:00 2001 From: Max Grakov Date: Sun, 4 May 2025 23:01:06 +0200 Subject: [PATCH] Feature/update dependencies (#202) --- app/.editorconfig | 88 +-- app/build.gradle.kts | 5 +- .../org/grakovne/lissen/LissenApplication.kt | 85 ++- .../AudiobookshelfChannelProvider.kt | 28 +- .../common/AudiobookshelfChannel.kt | 115 +-- .../common/UnknownAudiobookshelfChannel.kt | 51 +- .../common/api/ApiClientConfig.kt | 6 +- .../api/AudioBookshelfDataRepository.kt | 226 +++--- .../api/AudioBookshelfMediaRepository.kt | 119 +-- .../common/api/AudioBookshelfSyncService.kt | 9 +- .../common/api/AudiobookshelfAuthService.kt | 364 +++++----- .../common/api/RequestHeadersProvider.kt | 15 +- .../audiobookshelf/common/api/SafeApiCall.kt | 45 +- .../AudioBookshelfLibrarySyncService.kt | 43 +- .../AudioBookshelfPodcastSyncService.kt | 43 +- .../common/client/AudiobookshelfApiClient.kt | 151 ++-- .../client/AudiobookshelfMediaClient.kt | 11 +- .../converter/AuthMethodResponseConverter.kt | 20 +- .../ConnectionInfoResponseConverter.kt | 12 +- .../converter/LibraryPageResponseConverter.kt | 39 +- .../converter/LibraryResponseConverter.kt | 23 +- .../converter/LoginResponseConverter.kt | 12 +- .../PlaybackSessionResponseConverter.kt | 15 +- .../RecentListeningResponseConverter.kt | 33 +- .../common/model/MediaProgressResponse.kt | 12 +- .../common/model/auth/AuthMethodResponse.kt | 2 +- .../connection/ConnectionInfoResponse.kt | 10 +- .../model/metadata/AuthorItemsResponse.kt | 2 +- .../common/model/metadata/LibraryResponse.kt | 8 +- .../model/playback/PlaybackSessionResponse.kt | 4 +- .../model/playback/PlaybackStartRequest.kt | 16 +- .../model/playback/ProgressSyncRequest.kt | 4 +- .../model/user/CredentialsLoginRequest.kt | 4 +- .../common/model/user/LoggedUserResponse.kt | 10 +- .../model/user/PersonalizedFeedResponse.kt | 24 +- .../common/model/user/UserInfoResponse.kt | 4 +- .../AudiobookshelfOAuthCallbackActivity.kt | 114 +-- .../library/LibraryAudiobookshelfChannel.kt | 232 +++--- .../converter/BookResponseConverter.kt | 190 ++--- .../LibraryOrderingRequestConverter.kt | 27 +- .../converter/LibrarySearchItemsConverter.kt | 27 +- .../library/model/BookResponse.kt | 74 +- .../library/model/LibraryItemsResponse.kt | 20 +- .../library/model/LibrarySearchResponse.kt | 14 +- .../podcast/PodcastAudiobookshelfChannel.kt | 148 ++-- .../PodcastOrderingRequestConverter.kt | 27 +- .../converter/PodcastPageResponseConverter.kt | 39 +- .../converter/PodcastResponseConverter.kt | 208 +++--- .../converter/PodcastSearchItemsConverter.kt | 30 +- .../podcast/model/PodcastItemsResponse.kt | 16 +- .../podcast/model/PodcastResponse.kt | 44 +- .../podcast/model/PodcastSearchResponse.kt | 4 +- .../lissen/channel/common/ApiClient.kt | 69 +- .../lissen/channel/common/ApiError.kt | 41 +- .../lissen/channel/common/ApiResult.kt | 58 +- .../lissen/channel/common/AuthMethod.kt | 4 +- .../lissen/channel/common/BinaryApiClient.kt | 61 +- .../channel/common/ChannelAuthService.kt | 70 +- .../lissen/channel/common/ChannelCode.kt | 2 +- .../common/ChannelFilteringConfiguration.kt | 4 +- .../lissen/channel/common/ChannelModule.kt | 20 +- .../lissen/channel/common/ChannelProvider.kt | 7 +- .../lissen/channel/common/ConnectionInfo.kt | 6 +- .../lissen/channel/common/LibraryType.kt | 6 +- .../lissen/channel/common/MediaChannel.kt | 63 +- .../channel/common/OAuthContextCache.kt | 19 +- .../grakovne/lissen/channel/common/Pkce.kt | 35 +- .../lissen/channel/common/UserAgent.kt | 5 +- .../lissen/common/CertificateExtension.kt | 33 +- .../org/grakovne/lissen/common/ColorScheme.kt | 8 +- .../grakovne/lissen/common/HapticAction.kt | 8 +- .../org/grakovne/lissen/common/HttpClient.kt | 26 +- .../grakovne/lissen/common/ImageExtension.kt | 7 +- .../common/LibraryOrderingConfiguration.kt | 42 +- .../lissen/common/LibraryOrderingDirection.kt | 4 +- .../lissen/common/LibraryOrderingOption.kt | 8 +- .../lissen/common/NetworkQualityService.kt | 20 +- .../lissen/common/RunningComponent.kt | 3 +- .../lissen/content/LissenMediaProvider.kt | 405 +++++------ .../cache/CacheBookStorageProperties.kt | 36 +- .../lissen/content/cache/CacheState.kt | 4 +- .../cache/CalculateRequestedChapters.kt | 25 +- .../content/cache/ContentCachingExecutor.kt | 28 +- .../content/cache/ContentCachingManager.kt | 218 +++--- .../ContentCachingNotificationService.kt | 89 +-- .../content/cache/ContentCachingProgress.kt | 13 +- .../content/cache/ContentCachingService.kt | 195 +++-- .../lissen/content/cache/FindRelatedFiles.kt | 31 +- .../lissen/content/cache/LocalCacheModule.kt | 68 +- .../content/cache/LocalCacheRepository.kt | 130 ++-- .../lissen/content/cache/LocalCacheStorage.kt | 23 +- .../lissen/content/cache/Migrations.kt | 399 ++++++----- .../content/cache/api/CachedBookRepository.kt | 142 ++-- .../cache/api/CachedLibraryRepository.kt | 12 +- .../content/cache/api/FetchRequestBuilder.kt | 80 ++- .../content/cache/api/SearchRequestBuilder.kt | 83 ++- .../converter/CachedBookEntityConverter.kt | 34 +- .../CachedBookEntityDetailedConverter.kt | 76 +- .../CachedBookEntityRecentConverter.kt | 18 +- .../converter/CachedLibraryEntityConverter.kt | 12 +- .../lissen/content/cache/dao/CachedBookDao.kt | 224 +++--- .../content/cache/dao/CachedLibraryDao.kt | 46 +- .../content/cache/entity/CachedBookEntity.kt | 163 +++-- .../cache/entity/CachedLibraryEntity.kt | 10 +- .../cache/entity/PlaybackProgressEntity.kt | 6 +- .../java/org/grakovne/lissen/domain/Book.kt | 12 +- .../org/grakovne/lissen/domain/CacheStatus.kt | 11 +- .../lissen/domain/ContentCachingTask.kt | 6 +- .../grakovne/lissen/domain/DetailedItem.kt | 66 +- .../grakovne/lissen/domain/DownloadOption.kt | 6 +- .../org/grakovne/lissen/domain/Library.kt | 6 +- .../org/grakovne/lissen/domain/PagedItems.kt | 4 +- .../lissen/domain/PlaybackProgress.kt | 4 +- .../grakovne/lissen/domain/PlaybackSession.kt | 4 +- .../org/grakovne/lissen/domain/RecentBook.kt | 12 +- .../lissen/domain/RewindOnPauseTime.kt | 19 +- .../org/grakovne/lissen/domain/SeekTime.kt | 17 +- .../grakovne/lissen/domain/SeekTimeOption.kt | 10 +- .../org/grakovne/lissen/domain/TimerOption.kt | 5 +- .../org/grakovne/lissen/domain/UserAccount.kt | 6 +- .../domain/connection/ServerRequestHeader.kt | 41 +- .../preferences/LissenSharedPreferences.kt | 402 +++++------ .../grakovne/lissen/playback/MediaModule.kt | 234 +++--- .../lissen/playback/MediaRepository.kt | 604 ++++++++-------- .../playback/service/CalculateChapterIndex.kt | 19 +- .../service/CalculateChapterPosition.kt | 21 +- .../playback/service/MimeTypeProvider.kt | 28 +- .../service/PlaybackNotificationModule.kt | 7 +- .../service/PlaybackNotificationService.kt | 113 +-- .../playback/service/PlaybackService.kt | 518 ++++++------- .../service/PlaybackSynchronizationService.kt | 192 ++--- .../shortcuts/ContinuePlaybackShortcut.kt | 68 +- .../lissen/shortcuts/ShortcutsModule.kt | 7 +- .../grakovne/lissen/ui/PlaybackSpeedSlider.kt | 317 ++++---- .../lissen/ui/activity/AppActivity.kt | 62 +- .../ui/components/AsyncShimmeringImage.kt | 77 +- .../lissen/ui/components/BookCoverFetcher.kt | 81 ++- .../ui/components/ImageLoaderEntryPoint.kt | 2 +- .../lissen/ui/extensions/AsyncExtensions.kt | 21 +- .../lissen/ui/extensions/TimeExtensions.kt | 22 +- .../org/grakovne/lissen/ui/icons/Search.kt | 90 +-- .../org/grakovne/lissen/ui/icons/TimerPlay.kt | 128 ++-- .../lissen/ui/icons/loader/Loader10.kt | 108 +-- .../lissen/ui/icons/loader/Loader20.kt | 104 +-- .../lissen/ui/icons/loader/Loader40.kt | 104 +-- .../lissen/ui/icons/loader/Loader60.kt | 100 +-- .../lissen/ui/icons/loader/Loader80.kt | 96 +-- .../lissen/ui/icons/loader/Loader90.kt | 96 +-- .../lissen/ui/navigation/AppLaunchAction.kt | 4 +- .../lissen/ui/navigation/AppNavHost.kt | 296 ++++---- .../ui/navigation/AppNavigationService.kt | 75 +- .../common/RequestNotificationPermissions.kt | 32 +- .../ui/screens/library/LibraryScreen.kt | 678 +++++++++--------- .../PreferredLibrarySettingComposable.kt | 38 +- .../library/composables/BookComposable.kt | 151 ++-- .../composables/DefaultActionComposable.kt | 191 ++--- .../LibrarySearchActionComposable.kt | 134 ++-- .../composables/LibrarySwitchComposable.kt | 21 +- .../composables/MiniPlayerComposable.kt | 291 ++++---- .../composables/RecentBooksComposable.kt | 285 ++++---- .../fallback/LibraryFallbackComposable.kt | 121 ++-- .../LibraryPlaceholderComposable.kt | 92 +-- .../RecentBooksPlaceholderComposable.kt | 118 +-- .../paging/LibraryDefaultPagingSource.kt | 76 +- .../paging/LibrarySearchPagingSource.kt | 42 +- .../lissen/ui/screens/login/LoginScreen.kt | 435 +++++------ .../player/ChapterSearchActionComposable.kt | 114 +-- .../lissen/ui/screens/player/PlayerScreen.kt | 442 ++++++------ .../player/composable/DownloadsComposable.kt | 241 ++++--- .../composable/MediaDetailComposable.kt | 270 +++---- .../composable/NavigationBarComposable.kt | 367 +++++----- .../composable/PlaybackSpeedComposable.kt | 146 ++-- .../composable/PlayingQueueComposable.kt | 312 ++++---- .../composable/PlaylistItemComposable.kt | 110 +-- .../player/composable/TimerComposable.kt | 167 ++--- .../composable/TrackControlComposable.kt | 298 ++++---- .../composable/TrackDetailsComposable.kt | 180 ++--- .../common/ProvideNowPlayingTitle.kt | 10 +- .../PlayingQueueFallbackComposable.kt | 40 +- .../PlayingQueuePlaceholderComposable.kt | 70 +- .../TrackControlPlaceholderComposable.kt | 222 +++--- .../TrackDetailsPlaceholderComposable.kt | 115 +-- .../ui/screens/settings/SettingsScreen.kt | 125 ++-- .../advanced/CustomHeaderComposable.kt | 92 +-- .../advanced/CustomHeadersSettingsScreen.kt | 192 ++--- .../settings/advanced/SeekSettingsScreen.kt | 279 +++---- .../composable/AdditionalComposable.kt | 69 +- .../AdvancedSettingsItemComposable.kt | 69 +- .../ColorSchemeSettingsComposable.kt | 101 +-- .../settings/composable/CommonSettingsItem.kt | 6 +- .../CommonSettingsItemComposable.kt | 116 +-- .../composable/GitHubLinkComposable.kt | 47 +- .../LibraryOrderingSettingsComposable.kt | 162 +++-- .../composable/ServerSettingsComposable.kt | 157 ++-- .../settings/composable/SettingsToggleItem.kt | 72 +- .../org/grakovne/lissen/ui/theme/Theme.kt | 71 +- .../lissen/viewmodel/CachingModelView.kt | 68 +- .../lissen/viewmodel/LibraryViewModel.kt | 138 ++-- .../lissen/viewmodel/LoginViewModel.kt | 162 +++-- .../lissen/viewmodel/PlayerViewModel.kt | 64 +- .../lissen/viewmodel/SettingsViewModel.kt | 134 ++-- .../grakovne/lissen/widget/PlayerWidget.kt | 495 ++++++------- .../lissen/widget/PlayerWidgetModule.kt | 7 +- .../lissen/widget/PlayerWidgetReceiver.kt | 3 +- .../lissen/widget/PlayerWidgetStateService.kt | 159 ++-- .../lissen/widget/WidgetControlButton.kt | 41 +- .../lissen/widget/WidgetPlaybackController.kt | 51 +- .../WidgetPlaybackControllerEntryPoint.kt | 2 +- .../widget/WidgetPreferencesEntryPoint.kt | 2 +- build.gradle.kts | 2 +- gradle/libs.versions.toml | 5 +- 211 files changed, 9767 insertions(+), 9229 deletions(-) diff --git a/app/.editorconfig b/app/.editorconfig index f431bdd5..f6486d2a 100644 --- a/app/.editorconfig +++ b/app/.editorconfig @@ -1,84 +1,12 @@ root = true -[{*.kts,*.kt}] -ij_kotlin_align_in_columns_case_branch = false -ij_kotlin_align_multiline_binary_operation = false -ij_kotlin_align_multiline_extends_list = false -ij_kotlin_align_multiline_method_parentheses = false -ij_kotlin_align_multiline_parameters = true -ij_kotlin_align_multiline_parameters_in_calls = false -ij_kotlin_assignment_wrap = off -ij_kotlin_blank_lines_after_class_header = 1 -ij_kotlin_blank_lines_around_block_when_branches = 0 -ij_kotlin_block_comment_at_first_column = true -ij_kotlin_call_parameters_new_line_after_left_paren = false -ij_kotlin_call_parameters_right_paren_on_new_line = false -ij_kotlin_call_parameters_wrap = off -ij_kotlin_catch_on_new_line = false -ij_kotlin_class_annotation_wrap = split_into_lines -ij_kotlin_continuation_indent_for_chained_calls = true -ij_kotlin_continuation_indent_for_expression_bodies = true -ij_kotlin_continuation_indent_in_argument_lists = true -ij_kotlin_continuation_indent_in_elvis = true -ij_kotlin_continuation_indent_in_if_conditions = true -ij_kotlin_continuation_indent_in_parameter_lists = true -ij_kotlin_continuation_indent_in_supertype_lists = true -ij_kotlin_else_on_new_line = false -ij_kotlin_enum_constants_wrap = off -ij_kotlin_extends_list_wrap = off -ij_kotlin_field_annotation_wrap = split_into_lines -ij_kotlin_finally_on_new_line = false -ij_kotlin_if_rparen_on_new_line = false -ij_kotlin_import_nested_classes = false -ij_kotlin_insert_whitespaces_in_simple_one_line_method = true -ij_kotlin_keep_blank_lines_before_right_brace = 2 -ij_kotlin_keep_blank_lines_in_code = 2 -ij_kotlin_keep_blank_lines_in_declarations = 2 -ij_kotlin_keep_first_column_comment = true -ij_kotlin_keep_indents_on_empty_lines = false -ij_kotlin_keep_line_breaks = true -ij_kotlin_lbrace_on_next_line = false -ij_kotlin_line_comment_add_space = false -ij_kotlin_line_comment_at_first_column = true -ij_kotlin_method_annotation_wrap = split_into_lines -ij_kotlin_method_call_chain_wrap = off -ij_kotlin_method_parameters_new_line_after_left_paren = false -ij_kotlin_method_parameters_right_paren_on_new_line = false -ij_kotlin_method_parameters_wrap = off -ij_kotlin_name_count_to_use_star_import = 100 -ij_kotlin_name_count_to_use_star_import_for_members = 100 -ij_kotlin_parameter_annotation_wrap = off -ij_kotlin_space_after_comma = true -ij_kotlin_space_after_extend_colon = true -ij_kotlin_space_after_type_colon = true -ij_kotlin_space_before_catch_parentheses = true -ij_kotlin_space_before_comma = false -ij_kotlin_space_before_extend_colon = true -ij_kotlin_space_before_for_parentheses = true -ij_kotlin_space_before_if_parentheses = true -ij_kotlin_space_before_lambda_arrow = true -ij_kotlin_space_before_type_colon = false -ij_kotlin_space_before_when_parentheses = true -ij_kotlin_space_before_while_parentheses = true -ij_kotlin_spaces_around_additive_operators = true -ij_kotlin_spaces_around_assignment_operators = true -ij_kotlin_spaces_around_equality_operators = true -ij_kotlin_spaces_around_function_type_arrow = true -ij_kotlin_spaces_around_logical_operators = true -ij_kotlin_spaces_around_multiplicative_operators = true -ij_kotlin_spaces_around_range = false -ij_kotlin_spaces_around_relational_operators = true -ij_kotlin_spaces_around_unary_operator = false -ij_kotlin_spaces_around_when_arrow = true -ij_kotlin_variable_annotation_wrap = off -ij_kotlin_while_on_new_line = false -ij_kotlin_wrap_elvis_expressions = 1 -ij_kotlin_wrap_expression_body_functions = 0 -ij_kotlin_wrap_first_method_in_call_chain = false +[*.{kt,kts}] +indent_style = space +indent_size = 2 +max_line_length = 140 +insert_final_newline = true +ktlint_standard_function-naming = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_backing-property-naming = disabled -ij_xml_block_comment_at_first_column = true -ij_xml_keep_indents_on_empty_lines = false -ij_xml_line_comment_at_first_column = true - -ij_properties_align_group_field_declarations = false \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5326076d..8ad96ef1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,14 +6,17 @@ plugins { alias(libs.plugins.compose.compiler) id("com.google.dagger.hilt.android") - id("org.jmailen.kotlinter") version "3.15.0" + id("org.jmailen.kotlinter") version "5.0.2" id("com.google.devtools.ksp") } kotlinter { reporters = arrayOf("checkstyle", "plain") + ignoreFormatFailures = false + ignoreLintFailures = false } + tasks.lintKotlinMain { dependsOn(tasks.formatKotlinMain) } diff --git a/app/src/main/java/org/grakovne/lissen/LissenApplication.kt b/app/src/main/java/org/grakovne/lissen/LissenApplication.kt index 445649fe..fc82da96 100644 --- a/app/src/main/java/org/grakovne/lissen/LissenApplication.kt +++ b/app/src/main/java/org/grakovne/lissen/LissenApplication.kt @@ -14,51 +14,50 @@ import javax.inject.Inject @HiltAndroidApp class LissenApplication : Application() { + @Inject + lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> - @Inject - lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + initCrashReporting() + } - override fun attachBaseContext(base: Context) { - super.attachBaseContext(base) - initCrashReporting() + override fun onCreate() { + super.onCreate() + appContext = applicationContext + runningComponents.forEach { it.onCreate() } + } + + private fun initCrashReporting() { + initAcra { + buildConfigClass = BuildConfig::class.java + reportFormat = StringFormat.JSON + + httpSender { + uri = "https://acrarium.grakovne.org/report" + basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN + basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD + httpMethod = HttpSender.Method.POST + dropReportsOnTimeout = false + } + + toast { + text = getString(R.string.app_crach_toast) + } + + reportContent = + listOf( + ReportField.APP_VERSION_NAME, + ReportField.APP_VERSION_CODE, + ReportField.ANDROID_VERSION, + ReportField.PHONE_MODEL, + ReportField.STACK_TRACE, + ) } + } - override fun onCreate() { - super.onCreate() - appContext = applicationContext - runningComponents.forEach { it.onCreate() } - } - - private fun initCrashReporting() { - initAcra { - buildConfigClass = BuildConfig::class.java - reportFormat = StringFormat.JSON - - httpSender { - uri = "https://acrarium.grakovne.org/report" - basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN - basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD - httpMethod = HttpSender.Method.POST - dropReportsOnTimeout = false - } - - toast { - text = getString(R.string.app_crach_toast) - } - - reportContent = listOf( - ReportField.APP_VERSION_NAME, - ReportField.APP_VERSION_CODE, - ReportField.ANDROID_VERSION, - ReportField.PHONE_MODEL, - ReportField.STACK_TRACE, - ) - } - } - - companion object { - - lateinit var appContext: Context - private set - } + companion object { + lateinit var appContext: Context + private set + } } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannelProvider.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannelProvider.kt index 8080d5de..63998feb 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannelProvider.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannelProvider.kt @@ -14,28 +14,30 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AudiobookshelfChannelProvider @Inject constructor( +class AudiobookshelfChannelProvider + @Inject + constructor( private val podcastAudiobookshelfChannel: PodcastAudiobookshelfChannel, private val libraryAudiobookshelfChannel: LibraryAudiobookshelfChannel, private val unknownAudiobookshelfChannel: UnknownAudiobookshelfChannel, private val audiobookshelfAuthService: AudiobookshelfAuthService, private val sharedPreferences: LissenSharedPreferences, -) : ChannelProvider { - + ) : ChannelProvider { override fun provideMediaChannel(): MediaChannel { - val libraryType = sharedPreferences - .getPreferredLibrary() - ?.type - ?: LibraryType.UNKNOWN + val libraryType = + sharedPreferences + .getPreferredLibrary() + ?.type + ?: LibraryType.UNKNOWN - return when (libraryType) { - LibraryType.LIBRARY -> libraryAudiobookshelfChannel - LibraryType.PODCAST -> podcastAudiobookshelfChannel - LibraryType.UNKNOWN -> unknownAudiobookshelfChannel - } + return when (libraryType) { + LibraryType.LIBRARY -> libraryAudiobookshelfChannel + LibraryType.PODCAST -> podcastAudiobookshelfChannel + LibraryType.UNKNOWN -> unknownAudiobookshelfChannel + } } override fun provideChannelAuth(): ChannelAuthService = audiobookshelfAuthService override fun getChannelCode(): ChannelCode = ChannelCode.AUDIOBOOKSHELF -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt index 0da7959f..3f71bc05 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/AudiobookshelfChannel.kt @@ -20,70 +20,71 @@ import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import java.io.InputStream abstract class AudiobookshelfChannel( - protected val dataRepository: AudioBookshelfDataRepository, - protected val sessionResponseConverter: PlaybackSessionResponseConverter, - protected val preferences: LissenSharedPreferences, - private val syncService: AudioBookshelfSyncService, - private val libraryResponseConverter: LibraryResponseConverter, - private val mediaRepository: AudioBookshelfMediaRepository, - private val recentBookResponseConverter: RecentListeningResponseConverter, - private val connectionInfoResponseConverter: ConnectionInfoResponseConverter, + protected val dataRepository: AudioBookshelfDataRepository, + protected val sessionResponseConverter: PlaybackSessionResponseConverter, + protected val preferences: LissenSharedPreferences, + private val syncService: AudioBookshelfSyncService, + private val libraryResponseConverter: LibraryResponseConverter, + private val mediaRepository: AudioBookshelfMediaRepository, + private val recentBookResponseConverter: RecentListeningResponseConverter, + private val connectionInfoResponseConverter: ConnectionInfoResponseConverter, ) : MediaChannel { + override fun provideFileUri( + libraryItemId: String, + fileId: String, + ): Uri { + val host = preferences.getHost() ?: error("Host is null") - override fun provideFileUri( - libraryItemId: String, - fileId: String, - ): Uri { - val host = preferences.getHost() ?: error("Host is null") + return host + .toUri() + .buildUpon() + .appendPath("api") + .appendPath("items") + .appendPath(libraryItemId) + .appendPath("file") + .appendPath(fileId) + .appendQueryParameter("token", preferences.getToken()) + .build() + } - return host.toUri() - .buildUpon() - .appendPath("api") - .appendPath("items") - .appendPath(libraryItemId) - .appendPath("file") - .appendPath(fileId) - .appendQueryParameter("token", preferences.getToken()) - .build() - } + override suspend fun syncProgress( + sessionId: String, + progress: PlaybackProgress, + ): ApiResult = syncService.syncProgress(sessionId, progress) - override suspend fun syncProgress( - sessionId: String, - progress: PlaybackProgress, - ): ApiResult = syncService.syncProgress(sessionId, progress) + override suspend fun fetchBookCover(bookId: String): ApiResult = mediaRepository.fetchBookCover(bookId) - override suspend fun fetchBookCover( - bookId: String, - ): ApiResult = mediaRepository.fetchBookCover(bookId) + override suspend fun fetchLibraries(): ApiResult> = + dataRepository + .fetchLibraries() + .map { libraryResponseConverter.apply(it) } - override suspend fun fetchLibraries(): ApiResult> = dataRepository - .fetchLibraries() - .map { libraryResponseConverter.apply(it) } + override suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult> { + val progress: Map> = + dataRepository + .fetchUserInfoResponse() + .fold( + onSuccess = { + it + .user + .mediaProgress + ?.groupBy { item -> item.libraryItemId } + ?.map { (item, value) -> item to value.maxBy { progress -> progress.lastUpdate } } + ?.associate { (item, progress) -> item to (progress.lastUpdate to progress.progress) } + ?: emptyMap() + }, + onFailure = { emptyMap() }, + ) - override suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult> { - val progress: Map> = dataRepository - .fetchUserInfoResponse() - .fold( - onSuccess = { - it - .user - .mediaProgress - ?.groupBy { item -> item.libraryItemId } - ?.map { (item, value) -> item to value.maxBy { progress -> progress.lastUpdate } } - ?.associate { (item, progress) -> item to (progress.lastUpdate to progress.progress) } - ?: emptyMap() - }, - onFailure = { emptyMap() }, - ) + return dataRepository + .fetchPersonalizedFeed(libraryId) + .map { recentBookResponseConverter.apply(it, progress) } + } - return dataRepository - .fetchPersonalizedFeed(libraryId) - .map { recentBookResponseConverter.apply(it, progress) } - } + override suspend fun fetchConnectionInfo(): ApiResult = + dataRepository + .fetchConnectionInfo() + .map { connectionInfoResponseConverter.apply(it) } - override suspend fun fetchConnectionInfo(): ApiResult = dataRepository - .fetchConnectionInfo() - .map { connectionInfoResponseConverter.apply(it) } - - protected fun getClientName() = "Lissen App ${BuildConfig.VERSION_NAME}" + protected fun getClientName() = "Lissen App ${BuildConfig.VERSION_NAME}" } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/UnknownAudiobookshelfChannel.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/UnknownAudiobookshelfChannel.kt index 265dfec7..4c5dd892 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/UnknownAudiobookshelfChannel.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/UnknownAudiobookshelfChannel.kt @@ -19,7 +19,9 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class UnknownAudiobookshelfChannel @Inject constructor( +class UnknownAudiobookshelfChannel + @Inject + constructor( dataRepository: AudioBookshelfDataRepository, mediaRepository: AudioBookshelfMediaRepository, recentListeningResponseConverter: RecentListeningResponseConverter, @@ -28,39 +30,36 @@ class UnknownAudiobookshelfChannel @Inject constructor( sessionResponseConverter: PlaybackSessionResponseConverter, libraryResponseConverter: LibraryResponseConverter, connectionInfoResponseConverter: ConnectionInfoResponseConverter, -) : AudiobookshelfChannel( - dataRepository = dataRepository, - mediaRepository = mediaRepository, - recentBookResponseConverter = recentListeningResponseConverter, - sessionResponseConverter = sessionResponseConverter, - preferences = preferences, - syncService = syncService, - libraryResponseConverter = libraryResponseConverter, - connectionInfoResponseConverter = connectionInfoResponseConverter, -) { - + ) : AudiobookshelfChannel( + dataRepository = dataRepository, + mediaRepository = mediaRepository, + recentBookResponseConverter = recentListeningResponseConverter, + sessionResponseConverter = sessionResponseConverter, + preferences = preferences, + syncService = syncService, + libraryResponseConverter = libraryResponseConverter, + connectionInfoResponseConverter = connectionInfoResponseConverter, + ) { override fun getLibraryType(): LibraryType = LibraryType.UNKNOWN override suspend fun fetchBooks( - libraryId: String, - pageSize: Int, - pageNumber: Int, + libraryId: String, + pageSize: Int, + pageNumber: Int, ): ApiResult> = ApiResult.Error(ApiError.UnsupportedError) override suspend fun searchBooks( - libraryId: String, - query: String, - limit: Int, + libraryId: String, + query: String, + limit: Int, ): ApiResult> = ApiResult.Error(ApiError.UnsupportedError) override suspend fun startPlayback( - bookId: String, - episodeId: String, - supportedMimeTypes: List, - deviceId: String, + bookId: String, + episodeId: String, + supportedMimeTypes: List, + deviceId: String, ): ApiResult = ApiResult.Error(ApiError.UnsupportedError) - override suspend fun fetchBook( - bookId: String, - ): ApiResult = ApiResult.Error(ApiError.UnsupportedError) -} + override suspend fun fetchBook(bookId: String): ApiResult = ApiResult.Error(ApiError.UnsupportedError) + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/ApiClientConfig.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/ApiClientConfig.kt index e7b82d32..bf638fdd 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/ApiClientConfig.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/ApiClientConfig.kt @@ -5,7 +5,7 @@ import org.grakovne.lissen.domain.connection.ServerRequestHeader @Keep data class ApiClientConfig( - val host: String?, - val token: String?, - val customHeaders: List?, + val host: String?, + val token: String?, + val customHeaders: List?, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfDataRepository.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfDataRepository.kt index 95b33e12..da9fb2ae 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfDataRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfDataRepository.kt @@ -23,168 +23,174 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AudioBookshelfDataRepository @Inject constructor( +class AudioBookshelfDataRepository + @Inject + constructor( private val preferences: LissenSharedPreferences, private val requestHeadersProvider: RequestHeadersProvider, -) { - + ) { private var configCache: ApiClientConfig? = null private var clientCache: AudiobookshelfApiClient? = null - suspend fun fetchLibraries(): ApiResult = - safeApiCall { getClientInstance().fetchLibraries() } + suspend fun fetchLibraries(): ApiResult = safeApiCall { getClientInstance().fetchLibraries() } - suspend fun fetchAuthorItems( - authorId: String, - ): ApiResult = safeApiCall { + suspend fun fetchAuthorItems(authorId: String): ApiResult = + safeApiCall { getClientInstance() - .fetchAuthorLibraryItems( - authorId = authorId, - ) - } + .fetchAuthorLibraryItems( + authorId = authorId, + ) + } suspend fun searchPodcasts( - libraryId: String, - query: String, - limit: Int, - ): ApiResult = safeApiCall { + libraryId: String, + query: String, + limit: Int, + ): ApiResult = + safeApiCall { getClientInstance() - .searchPodcasts( - libraryId = libraryId, - request = query, - limit = limit, - ) - } + .searchPodcasts( + libraryId = libraryId, + request = query, + limit = limit, + ) + } suspend fun searchBooks( - libraryId: String, - query: String, - limit: Int, - ): ApiResult = safeApiCall { + libraryId: String, + query: String, + limit: Int, + ): ApiResult = + safeApiCall { getClientInstance() - .searchLibraryItems( - libraryId = libraryId, - request = query, - limit = limit, - ) - } + .searchLibraryItems( + libraryId = libraryId, + request = query, + limit = limit, + ) + } suspend fun fetchLibraryItems( - libraryId: String, - pageSize: Int, - pageNumber: Int, - sort: String, - direction: String, + libraryId: String, + pageSize: Int, + pageNumber: Int, + sort: String, + direction: String, ): ApiResult = - safeApiCall { - getClientInstance() - .fetchLibraryItems( - libraryId = libraryId, - pageSize = pageSize, - pageNumber = pageNumber, - sort = sort, - desc = direction, - ) - } + safeApiCall { + getClientInstance() + .fetchLibraryItems( + libraryId = libraryId, + pageSize = pageSize, + pageNumber = pageNumber, + sort = sort, + desc = direction, + ) + } suspend fun fetchPodcastItems( - libraryId: String, - pageSize: Int, - pageNumber: Int, - sort: String, - direction: String, + libraryId: String, + pageSize: Int, + pageNumber: Int, + sort: String, + direction: String, ): ApiResult = - safeApiCall { - getClientInstance() - .fetchPodcastItems( - libraryId = libraryId, - pageSize = pageSize, - pageNumber = pageNumber, - sort = sort, - desc = direction, - ) - } + safeApiCall { + getClientInstance() + .fetchPodcastItems( + libraryId = libraryId, + pageSize = pageSize, + pageNumber = pageNumber, + sort = sort, + desc = direction, + ) + } suspend fun fetchBook(itemId: String): ApiResult = - safeApiCall { getClientInstance().fetchLibraryItem(itemId) } + safeApiCall { + getClientInstance().fetchLibraryItem(itemId) + } suspend fun fetchPodcastItem(itemId: String): ApiResult = - safeApiCall { getClientInstance().fetchPodcastEpisode(itemId) } + safeApiCall { getClientInstance().fetchPodcastEpisode(itemId) } suspend fun fetchConnectionInfo(): ApiResult = - safeApiCall { getClientInstance().fetchConnectionInfo() } + safeApiCall { + getClientInstance().fetchConnectionInfo() + } suspend fun fetchPersonalizedFeed(libraryId: String): ApiResult> = - safeApiCall { getClientInstance().fetchPersonalizedFeed(libraryId) } + safeApiCall { getClientInstance().fetchPersonalizedFeed(libraryId) } suspend fun fetchLibraryItemProgress(itemId: String): ApiResult = - safeApiCall { getClientInstance().fetchLibraryItemProgress(itemId) } + safeApiCall { getClientInstance().fetchLibraryItemProgress(itemId) } - suspend fun fetchUserInfoResponse(): ApiResult = - safeApiCall { getClientInstance().fetchUserInfo() } + suspend fun fetchUserInfoResponse(): ApiResult = safeApiCall { getClientInstance().fetchUserInfo() } suspend fun startPlayback( - itemId: String, - request: PlaybackStartRequest, - ): ApiResult = - safeApiCall { getClientInstance().startLibraryPlayback(itemId, request) } + itemId: String, + request: PlaybackStartRequest, + ): ApiResult = safeApiCall { getClientInstance().startLibraryPlayback(itemId, request) } suspend fun startPodcastPlayback( - itemId: String, - episodeId: String, - request: PlaybackStartRequest, + itemId: String, + episodeId: String, + request: PlaybackStartRequest, ): ApiResult = - safeApiCall { getClientInstance().startPodcastPlayback(itemId, episodeId, request) } + safeApiCall { + getClientInstance().startPodcastPlayback(itemId, episodeId, request) + } suspend fun publishLibraryItemProgress( - itemId: String, - progress: ProgressSyncRequest, - ): ApiResult = - safeApiCall { getClientInstance().publishLibraryItemProgress(itemId, progress) } + itemId: String, + progress: ProgressSyncRequest, + ): ApiResult = safeApiCall { getClientInstance().publishLibraryItemProgress(itemId, progress) } private fun getClientInstance(): AudiobookshelfApiClient { - val host = preferences.getHost() - val token = preferences.getToken() + val host = preferences.getHost() + val token = preferences.getToken() - val cache = ApiClientConfig( - host = host, - token = token, - customHeaders = requestHeadersProvider.fetchRequestHeaders(), + val cache = + ApiClientConfig( + host = host, + token = token, + customHeaders = requestHeadersProvider.fetchRequestHeaders(), ) - val currentClientCache = clientCache + val currentClientCache = clientCache - return when (currentClientCache == null || cache != configCache) { - true -> { - val instance = createClientInstance() - configCache = cache - clientCache = instance - instance - } - - else -> currentClientCache + return when (currentClientCache == null || cache != configCache) { + true -> { + val instance = createClientInstance() + configCache = cache + clientCache = instance + instance } + + else -> currentClientCache + } } private fun createClientInstance(): AudiobookshelfApiClient { - val host = preferences.getHost() - val token = preferences.getToken() + val host = preferences.getHost() + val token = preferences.getToken() - if (host.isNullOrBlank() || token.isNullOrBlank()) { - throw IllegalStateException("Host or token is missing") - } + if (host.isNullOrBlank() || token.isNullOrBlank()) { + throw IllegalStateException("Host or token is missing") + } - return apiClient(host, token) - .retrofit - .create(AudiobookshelfApiClient::class.java) + return apiClient(host, token) + .retrofit + .create(AudiobookshelfApiClient::class.java) } private fun apiClient( - host: String, - token: String?, - ): ApiClient = ApiClient( + host: String, + token: String?, + ): ApiClient = + ApiClient( host = host, token = token, requestHeaders = requestHeadersProvider.fetchRequestHeaders(), - ) -} + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfMediaRepository.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfMediaRepository.kt index f1121c73..fe1b8463 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfMediaRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfMediaRepository.kt @@ -14,93 +14,96 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AudioBookshelfMediaRepository @Inject constructor( +class AudioBookshelfMediaRepository + @Inject + constructor( private val preferences: LissenSharedPreferences, private val requestHeadersProvider: RequestHeadersProvider, -) { - + ) { private var configCache: ApiClientConfig? = null private var clientCache: AudiobookshelfMediaClient? = null suspend fun fetchBookCover(itemId: String): ApiResult = - safeCall { getClientInstance().getItemCover(itemId) } + safeCall { + getClientInstance().getItemCover(itemId) + } - private suspend fun safeCall( - apiCall: suspend () -> Response, - ): ApiResult { - return try { - val response = apiCall.invoke() + private suspend fun safeCall(apiCall: suspend () -> Response): ApiResult { + return try { + val response = apiCall.invoke() - return when (response.code()) { - 200 -> when (val body = response.body()) { - null -> ApiResult.Error(ApiError.InternalError) - else -> ApiResult.Success(body.byteStream()) - } - - 400 -> ApiResult.Error(ApiError.InternalError) - 401 -> ApiResult.Error(ApiError.Unauthorized) - 403 -> ApiResult.Error(ApiError.Unauthorized) - 404 -> ApiResult.Error(ApiError.InternalError) - 500 -> ApiResult.Error(ApiError.InternalError) - else -> ApiResult.Error(ApiError.InternalError) + return when (response.code()) { + 200 -> + when (val body = response.body()) { + null -> ApiResult.Error(ApiError.InternalError) + else -> ApiResult.Success(body.byteStream()) } - } catch (e: IOException) { - Log.e(TAG, "Unable to make network api call $apiCall due to: $e") - ApiResult.Error(ApiError.NetworkError) - } catch (e: Exception) { - Log.e(TAG, "Unable to make network api call $apiCall due to: $e") - ApiResult.Error(ApiError.InternalError) + + 400 -> ApiResult.Error(ApiError.InternalError) + 401 -> ApiResult.Error(ApiError.Unauthorized) + 403 -> ApiResult.Error(ApiError.Unauthorized) + 404 -> ApiResult.Error(ApiError.InternalError) + 500 -> ApiResult.Error(ApiError.InternalError) + else -> ApiResult.Error(ApiError.InternalError) } + } catch (e: IOException) { + Log.e(TAG, "Unable to make network api call $apiCall due to: $e") + ApiResult.Error(ApiError.NetworkError) + } catch (e: Exception) { + Log.e(TAG, "Unable to make network api call $apiCall due to: $e") + ApiResult.Error(ApiError.InternalError) + } } private fun getClientInstance(): AudiobookshelfMediaClient { - val host = preferences.getHost() - val token = preferences.getToken() + val host = preferences.getHost() + val token = preferences.getToken() - val cache = ApiClientConfig( - host = host, - token = token, - customHeaders = requestHeadersProvider.fetchRequestHeaders(), + val cache = + ApiClientConfig( + host = host, + token = token, + customHeaders = requestHeadersProvider.fetchRequestHeaders(), ) - val currentClientCache = clientCache + val currentClientCache = clientCache - return when (currentClientCache == null || cache != configCache) { - true -> { - val instance = createClientInstance() - configCache = cache - clientCache = instance - instance - } - - else -> currentClientCache + return when (currentClientCache == null || cache != configCache) { + true -> { + val instance = createClientInstance() + configCache = cache + clientCache = instance + instance } + + else -> currentClientCache + } } private fun createClientInstance(): AudiobookshelfMediaClient { - val host = preferences.getHost() - val token = preferences.getToken() + val host = preferences.getHost() + val token = preferences.getToken() - if (host.isNullOrBlank() || token.isNullOrBlank()) { - throw IllegalStateException("Host or token is missing") - } + if (host.isNullOrBlank() || token.isNullOrBlank()) { + throw IllegalStateException("Host or token is missing") + } - return apiClient(host, token) - .retrofit - .create(AudiobookshelfMediaClient::class.java) + return apiClient(host, token) + .retrofit + .create(AudiobookshelfMediaClient::class.java) } private fun apiClient( - host: String, - token: String, - ): BinaryApiClient = BinaryApiClient( + host: String, + token: String, + ): BinaryApiClient = + BinaryApiClient( host = host, token = token, requestHeaders = requestHeadersProvider.fetchRequestHeaders(), - ) + ) companion object { - - private const val TAG: String = "AudioBookshelfMediaRepository" + private const val TAG: String = "AudioBookshelfMediaRepository" } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfSyncService.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfSyncService.kt index 838ac824..6b034fb4 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfSyncService.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfSyncService.kt @@ -4,9 +4,8 @@ import org.grakovne.lissen.channel.common.ApiResult import org.grakovne.lissen.domain.PlaybackProgress interface AudioBookshelfSyncService { - - suspend fun syncProgress( - itemId: String, - progress: PlaybackProgress, - ): ApiResult + suspend fun syncProgress( + itemId: String, + progress: PlaybackProgress, + ): ApiResult } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt index 0b276be8..df18b241 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudiobookshelfAuthService.kt @@ -39,224 +39,260 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AudiobookshelfAuthService @Inject constructor( +class AudiobookshelfAuthService + @Inject + constructor( @ApplicationContext private val context: Context, private val loginResponseConverter: LoginResponseConverter, private val requestHeadersProvider: RequestHeadersProvider, private val preferences: LissenSharedPreferences, private val contextCache: OAuthContextCache, private val authMethodResponseConverter: AuthMethodResponseConverter, -) : ChannelAuthService(preferences) { - - private val client = createOkHttpClient() + ) : ChannelAuthService(preferences) { + private val client = + createOkHttpClient() .newBuilder() .followRedirects(false) .build() override suspend fun authorize( - host: String, - username: String, - password: String, - onSuccess: suspend (UserAccount) -> Unit, + host: String, + username: String, + password: String, + onSuccess: suspend (UserAccount) -> Unit, ): ApiResult { - if (host.isBlank() || !urlPattern.matches(host)) { - return ApiResult.Error(ApiError.InvalidCredentialsHost) - } + if (host.isBlank() || !urlPattern.matches(host)) { + return ApiResult.Error(ApiError.InvalidCredentialsHost) + } - lateinit var apiService: AudiobookshelfApiClient + lateinit var apiService: AudiobookshelfApiClient - try { - val apiClient = ApiClient( - host = host, - requestHeaders = requestHeadersProvider.fetchRequestHeaders(), - ) + try { + val apiClient = + ApiClient( + host = host, + requestHeaders = requestHeadersProvider.fetchRequestHeaders(), + ) - apiService = apiClient.retrofit.create(AudiobookshelfApiClient::class.java) - } catch (e: Exception) { - return ApiResult.Error(ApiError.InternalError) - } + apiService = apiClient.retrofit.create(AudiobookshelfApiClient::class.java) + } catch (e: Exception) { + return ApiResult.Error(ApiError.InternalError) + } - val response: ApiResult = - safeApiCall { apiService.login(CredentialsLoginRequest(username, password)) } + val response: ApiResult = + safeApiCall { apiService.login(CredentialsLoginRequest(username, password)) } - return response - .foldAsync( - onSuccess = { - loginResponseConverter - .apply(it) - .also { onSuccess(it) } - .let { ApiResult.Success(it) } - }, - onFailure = { ApiResult.Error(it.code) }, - ) + return response + .foldAsync( + onSuccess = { + loginResponseConverter + .apply(it) + .also { onSuccess(it) } + .let { ApiResult.Success(it) } + }, + onFailure = { ApiResult.Error(it.code) }, + ) } override suspend fun fetchAuthMethods(host: String): ApiResult> { - return withContext(Dispatchers.IO) { - try { - val url = host - .toUri() - .buildUpon() - .appendEncodedPath("status") - .build() + return withContext(Dispatchers.IO) { + try { + val url = + host + .toUri() + .buildUpon() + .appendEncodedPath("status") + .build() - val client = createOkHttpClient() - val request = Request.Builder().url(url.toString()).get().build() - val response = client.newCall(request).execute() + val client = createOkHttpClient() + val request = + Request + .Builder() + .url(url.toString()) + .get() + .build() + val response = client.newCall(request).execute() - if (!response.isSuccessful) { - return@withContext ApiResult.Success(emptyList()) - } + if (!response.isSuccessful) { + return@withContext ApiResult.Success(emptyList()) + } - val body = response.body?.string() - ?: return@withContext ApiResult.Success(emptyList()) + val body = + response.body?.string() + ?: return@withContext ApiResult.Success(emptyList()) - val gson = Gson() - val authMethod = gson.fromJson(body, AuthMethodResponse::class.java) + val gson = Gson() + val authMethod = gson.fromJson(body, AuthMethodResponse::class.java) - val converted = authMethodResponseConverter.apply(authMethod) - ApiResult.Success(converted) - } catch (e: Exception) { - ApiResult.Success(emptyList()) - } + val converted = authMethodResponseConverter.apply(authMethod) + ApiResult.Success(converted) + } catch (e: Exception) { + ApiResult.Success(emptyList()) } + } } override suspend fun startOAuth( - host: String, - onSuccess: () -> Unit, - onFailure: (ApiError) -> Unit, + host: String, + onSuccess: () -> Unit, + onFailure: (ApiError) -> Unit, ) { - Log.d(TAG, "Starting OAuth flow for $host") + Log.d(TAG, "Starting OAuth flow for $host") - preferences.saveHost(host) + preferences.saveHost(host) - val pkce = randomPkce() - contextCache.storePkce(pkce) + val pkce = randomPkce() + contextCache.storePkce(pkce) - val url = host - .toUri() - .buildUpon() - .appendEncodedPath("auth/openid/") - .appendQueryParameter("code_challenge", pkce.challenge) - .appendQueryParameter("code_challenge_method", "S256") - .appendQueryParameter("redirect_uri", "$AuthScheme://$AuthHost") - .appendQueryParameter("client_id", AuthClient) - .appendQueryParameter("response_type", "code") - .appendQueryParameter("state", pkce.state) - .build() + val url = + host + .toUri() + .buildUpon() + .appendEncodedPath("auth/openid/") + .appendQueryParameter("code_challenge", pkce.challenge) + .appendQueryParameter("code_challenge_method", "S256") + .appendQueryParameter("redirect_uri", "$AuthScheme://$AuthHost") + .appendQueryParameter("client_id", AuthClient) + .appendQueryParameter("response_type", "code") + .appendQueryParameter("state", pkce.state) + .build() - val request = Request - .Builder() - .url(url.toString()) - .get() - .build() + val request = + Request + .Builder() + .url(url.toString()) + .get() + .build() - client - .newCall(request).enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.e(TAG, "Failed OAuth flow due to: $e") - onFailure(examineError(e.message ?: "")) - } + client + .newCall(request) + .enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + Log.e(TAG, "Failed OAuth flow due to: $e") + onFailure(examineError(e.message ?: "")) + } - override fun onResponse(call: Call, response: Response) { - Log.d(TAG, "Got Redirect from ABS") + override fun onResponse( + call: Call, + response: Response, + ) { + Log.d(TAG, "Got Redirect from ABS") - if (response.code != 302) { - onFailure(examineError(response.body?.string() ?: "")) - return - } + if (response.code != 302) { + onFailure(examineError(response.body?.string() ?: "")) + return + } - val location = response - .header("Location") - ?: kotlin.run { - onFailure(examineError("invalid_redirect")) - return - } + val location = + response + .header("Location") + ?: kotlin.run { + onFailure(examineError("invalid_redirect")) + return + } - try { - val cookieHeaders: List = response.headers("Set-Cookie") - contextCache.storeCookies(cookieHeaders) + try { + val cookieHeaders: List = response.headers("Set-Cookie") + contextCache.storeCookies(cookieHeaders) - onSuccess() - forwardAuthRequest(context, location) - } catch (ex: Exception) { - onFailure(examineError(ex.message ?: "")) - } - } - }) + onSuccess() + forwardAuthRequest(context, location) + } catch (ex: Exception) { + onFailure(examineError(ex.message ?: "")) + } + } + }, + ) } - fun forwardAuthRequest(context: Context, url: String) { - val customTabsIntent = CustomTabsIntent.Builder().build() - customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_TASK) - customTabsIntent.launchUrl(context, url.toUri()) + fun forwardAuthRequest( + context: Context, + url: String, + ) { + val customTabsIntent = CustomTabsIntent.Builder().build() + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_TASK) + customTabsIntent.launchUrl(context, url.toUri()) } override suspend fun exchangeToken( - host: String, - code: String, - onSuccess: suspend (UserAccount) -> Unit, - onFailure: (String) -> Unit, + host: String, + code: String, + onSuccess: suspend (UserAccount) -> Unit, + onFailure: (String) -> Unit, ) { - val pkce = contextCache.readPkce() - val cookie = contextCache.readCookies() + val pkce = contextCache.readPkce() + val cookie = contextCache.readCookies() - contextCache.clearPkce() - contextCache.clearCookies() + contextCache.clearPkce() + contextCache.clearCookies() - val callbackUrl = host - .toUri() - .buildUpon() - .appendEncodedPath("auth/openid/callback") - .appendQueryParameter("state", pkce.state) - .appendQueryParameter("code", code) - .appendQueryParameter("code_verifier", pkce.verifier) - .build() + val callbackUrl = + host + .toUri() + .buildUpon() + .appendEncodedPath("auth/openid/callback") + .appendQueryParameter("state", pkce.state) + .appendQueryParameter("code", code) + .appendQueryParameter("code_verifier", pkce.verifier) + .build() - val client = createOkHttpClient() + val client = createOkHttpClient() - val request = Request - .Builder() - .url(callbackUrl.toString()) - .addHeader("Cookie", cookie) - .build() + val request = + Request + .Builder() + .url(callbackUrl.toString()) + .addHeader("Cookie", cookie) + .build() - client - .newCall(request) - .enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.e(TAG, "Callback request failed: $e") - onFailure(e.message ?: "") + client + .newCall(request) + .enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + Log.e(TAG, "Callback request failed: $e") + onFailure(e.message ?: "") + } + + override fun onResponse( + call: Call, + response: Response, + ) { + val raw = + response + .body + ?.string() + ?: run { + onFailure(response.body?.string() ?: "") + return + } + + val user = + try { + Gson() + .fromJson(raw, LoggedUserResponse::class.java) + .let { loginResponseConverter.apply(it) } + } catch (ex: Exception) { + Log.e(TAG, "Unable to get User data from response: $ex") + onFailure(ex.message ?: "") + return } - override fun onResponse(call: Call, response: Response) { - val raw = response - .body - ?.string() - ?: run { - onFailure(response.body?.string() ?: "") - return - } - - val user = try { - Gson() - .fromJson(raw, LoggedUserResponse::class.java) - .let { loginResponseConverter.apply(it) } - } catch (ex: Exception) { - Log.e(TAG, "Unable to get User data from response: $ex") - onFailure(ex.message ?: "") - return - } - - CoroutineScope(Dispatchers.IO).launch { onSuccess(user) } - } - }) + CoroutineScope(Dispatchers.IO).launch { onSuccess(user) } + } + }, + ) } private companion object { - - private val TAG = "AudiobookshelfAuthService" - val urlPattern = Regex("^(http|https)://.*\$") + private val TAG = "AudiobookshelfAuthService" + val urlPattern = Regex("^(http|https)://.*\$") } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/RequestHeadersProvider.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/RequestHeadersProvider.kt index 6f1a5a71..aa1e67be 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/RequestHeadersProvider.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/RequestHeadersProvider.kt @@ -7,14 +7,15 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class RequestHeadersProvider @Inject constructor( +class RequestHeadersProvider + @Inject + constructor( private val preferences: LissenSharedPreferences, -) { - + ) { fun fetchRequestHeaders(): List { - val usersHeaders = preferences.getCustomHeaders() + val usersHeaders = preferences.getCustomHeaders() - val userAgent = ServerRequestHeader("User-Agent", USER_AGENT) - return usersHeaders + userAgent + val userAgent = ServerRequestHeader("User-Agent", USER_AGENT) + return usersHeaders + userAgent } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/SafeApiCall.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/SafeApiCall.kt index 4fc3537d..eeea8300 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/SafeApiCall.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/SafeApiCall.kt @@ -8,30 +8,29 @@ import java.io.IOException private const val TAG: String = "safeApiCall" -suspend fun safeApiCall( - apiCall: suspend () -> Response, -): ApiResult { - return try { - val response = apiCall.invoke() +suspend fun safeApiCall(apiCall: suspend () -> Response): ApiResult { + return try { + val response = apiCall.invoke() - return when (response.code()) { - 200 -> when (val body = response.body()) { - null -> ApiResult.Error(ApiError.InternalError) - else -> ApiResult.Success(body) - } - - 400 -> ApiResult.Error(ApiError.InternalError) - 401 -> ApiResult.Error(ApiError.Unauthorized) - 403 -> ApiResult.Error(ApiError.Unauthorized) - 404 -> ApiResult.Error(ApiError.InternalError) - 500 -> ApiResult.Error(ApiError.InternalError) - else -> ApiResult.Error(ApiError.InternalError) + return when (response.code()) { + 200 -> + when (val body = response.body()) { + null -> ApiResult.Error(ApiError.InternalError) + else -> ApiResult.Success(body) } - } catch (e: IOException) { - Log.e(TAG, "Unable to make network api call $apiCall due to: $e") - ApiResult.Error(ApiError.NetworkError) - } catch (e: Exception) { - Log.e(TAG, "Unable to make network api call $apiCall due to: $e") - ApiResult.Error(ApiError.InternalError) + + 400 -> ApiResult.Error(ApiError.InternalError) + 401 -> ApiResult.Error(ApiError.Unauthorized) + 403 -> ApiResult.Error(ApiError.Unauthorized) + 404 -> ApiResult.Error(ApiError.InternalError) + 500 -> ApiResult.Error(ApiError.InternalError) + else -> ApiResult.Error(ApiError.InternalError) } + } catch (e: IOException) { + Log.e(TAG, "Unable to make network api call $apiCall due to: $e") + ApiResult.Error(ApiError.NetworkError) + } catch (e: Exception) { + Log.e(TAG, "Unable to make network api call $apiCall due to: $e") + ApiResult.Error(ApiError.InternalError) + } } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/library/AudioBookshelfLibrarySyncService.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/library/AudioBookshelfLibrarySyncService.kt index e34d6aa1..19699da0 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/library/AudioBookshelfLibrarySyncService.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/library/AudioBookshelfLibrarySyncService.kt @@ -9,33 +9,36 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AudioBookshelfLibrarySyncService @Inject constructor( +class AudioBookshelfLibrarySyncService + @Inject + constructor( private val dataRepository: AudioBookshelfDataRepository, -) : AudioBookshelfSyncService { - + ) : AudioBookshelfSyncService { private var previousItemId: String? = null private var previousTrackedTime: Double = 0.0 override suspend fun syncProgress( - itemId: String, - progress: PlaybackProgress, + itemId: String, + progress: PlaybackProgress, ): ApiResult { - val trackedTime = previousTrackedTime - .takeIf { itemId == previousItemId } - ?.let { progress.currentTotalTime - previousTrackedTime } - ?.toInt() - ?: 0 + val trackedTime = + previousTrackedTime + .takeIf { itemId == previousItemId } + ?.let { progress.currentTotalTime - previousTrackedTime } + ?.toInt() + ?: 0 - val request = ProgressSyncRequest( - currentTime = progress.currentTotalTime, - timeListened = trackedTime, + val request = + ProgressSyncRequest( + currentTime = progress.currentTotalTime, + timeListened = trackedTime, ) - return dataRepository - .publishLibraryItemProgress(itemId, request) - .also { - previousTrackedTime = progress.currentTotalTime - previousItemId = itemId - } + return dataRepository + .publishLibraryItemProgress(itemId, request) + .also { + previousTrackedTime = progress.currentTotalTime + previousItemId = itemId + } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/podcast/AudioBookshelfPodcastSyncService.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/podcast/AudioBookshelfPodcastSyncService.kt index ee266a89..3d16db74 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/podcast/AudioBookshelfPodcastSyncService.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/podcast/AudioBookshelfPodcastSyncService.kt @@ -9,33 +9,36 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AudioBookshelfPodcastSyncService @Inject constructor( +class AudioBookshelfPodcastSyncService + @Inject + constructor( private val dataRepository: AudioBookshelfDataRepository, -) : AudioBookshelfSyncService { - + ) : AudioBookshelfSyncService { private var previousItemId: String? = null private var previousTrackedTime: Double = 0.0 override suspend fun syncProgress( - itemId: String, - progress: PlaybackProgress, + itemId: String, + progress: PlaybackProgress, ): ApiResult { - val trackedTime = previousTrackedTime - .takeIf { itemId == previousItemId } - ?.let { progress.currentChapterTime - previousTrackedTime } - ?.toInt() - ?: 0 + val trackedTime = + previousTrackedTime + .takeIf { itemId == previousItemId } + ?.let { progress.currentChapterTime - previousTrackedTime } + ?.toInt() + ?: 0 - val request = ProgressSyncRequest( - currentTime = progress.currentChapterTime, - timeListened = trackedTime, + val request = + ProgressSyncRequest( + currentTime = progress.currentChapterTime, + timeListened = trackedTime, ) - return dataRepository - .publishLibraryItemProgress(itemId, request) - .also { - previousTrackedTime = progress.currentChapterTime - previousItemId = itemId - } + return dataRepository + .publishLibraryItemProgress(itemId, request) + .also { + previousTrackedTime = progress.currentChapterTime + previousItemId = itemId + } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfApiClient.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfApiClient.kt index 80b70e3a..0c0d5ed7 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfApiClient.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfApiClient.kt @@ -25,94 +25,95 @@ import retrofit2.http.Path import retrofit2.http.Query interface AudiobookshelfApiClient { + @GET("/api/libraries") + suspend fun fetchLibraries(): Response - @GET("/api/libraries") - suspend fun fetchLibraries(): Response + @GET("/api/libraries/{libraryId}/personalized") + suspend fun fetchPersonalizedFeed( + @Path("libraryId") libraryId: String, + ): Response> - @GET("/api/libraries/{libraryId}/personalized") - suspend fun fetchPersonalizedFeed( - @Path("libraryId") libraryId: String, - ): Response> + @GET("/api/me/progress/{itemId}") + suspend fun fetchLibraryItemProgress( + @Path("itemId") itemId: String, + ): Response - @GET("/api/me/progress/{itemId}") - suspend fun fetchLibraryItemProgress( - @Path("itemId") itemId: String, - ): Response + @POST("/api/authorize") + suspend fun fetchConnectionInfo(): Response - @POST("/api/authorize") - suspend fun fetchConnectionInfo(): Response + @POST("/api/authorize") + suspend fun fetchUserInfo(): Response - @POST("/api/authorize") - suspend fun fetchUserInfo(): Response + @GET("api/libraries/{libraryId}/items") + suspend fun fetchLibraryItems( + @Path("libraryId") libraryId: String, + @Query("limit") pageSize: Int, + @Query("page") pageNumber: Int, + @Query("sort") sort: String, + @Query("desc") desc: String, + @Query("minified") minified: String = "1", + ): Response - @GET("api/libraries/{libraryId}/items") - suspend fun fetchLibraryItems( - @Path("libraryId") libraryId: String, - @Query("limit") pageSize: Int, - @Query("page") pageNumber: Int, - @Query("sort") sort: String, - @Query("desc") desc: String, - @Query("minified") minified: String = "1", - ): Response + @GET("api/libraries/{libraryId}/items") + suspend fun fetchPodcastItems( + @Path("libraryId") libraryId: String, + @Query("limit") pageSize: Int, + @Query("page") pageNumber: Int, + @Query("sort") sort: String, + @Query("desc") desc: String, + @Query("minified") minified: String = "1", + ): Response - @GET("api/libraries/{libraryId}/items") - suspend fun fetchPodcastItems( - @Path("libraryId") libraryId: String, - @Query("limit") pageSize: Int, - @Query("page") pageNumber: Int, - @Query("sort") sort: String, - @Query("desc") desc: String, - @Query("minified") minified: String = "1", - ): Response + @GET("api/libraries/{libraryId}/search") + suspend fun searchLibraryItems( + @Path("libraryId") libraryId: String, + @Query("q") request: String, + @Query("limit") limit: Int, + ): Response - @GET("api/libraries/{libraryId}/search") - suspend fun searchLibraryItems( - @Path("libraryId") libraryId: String, - @Query("q") request: String, - @Query("limit") limit: Int, - ): Response + @GET("api/libraries/{libraryId}/search") + suspend fun searchPodcasts( + @Path("libraryId") libraryId: String, + @Query("q") request: String, + @Query("limit") limit: Int, + ): Response - @GET("api/libraries/{libraryId}/search") - suspend fun searchPodcasts( - @Path("libraryId") libraryId: String, - @Query("q") request: String, - @Query("limit") limit: Int, - ): Response + @GET("/api/items/{itemId}") + suspend fun fetchLibraryItem( + @Path("itemId") itemId: String, + ): Response - @GET("/api/items/{itemId}") - suspend fun fetchLibraryItem( - @Path("itemId") itemId: String, - ): Response + @GET("/api/items/{itemId}") + suspend fun fetchPodcastEpisode( + @Path("itemId") itemId: String, + ): Response - @GET("/api/items/{itemId}") - suspend fun fetchPodcastEpisode( - @Path("itemId") itemId: String, - ): Response + @GET("/api/authors/{authorId}?include=items") + suspend fun fetchAuthorLibraryItems( + @Path("authorId") authorId: String, + ): Response - @GET("/api/authors/{authorId}?include=items") - suspend fun fetchAuthorLibraryItems( - @Path("authorId") authorId: String, - ): Response + @POST("/api/session/{itemId}/sync") + suspend fun publishLibraryItemProgress( + @Path("itemId") itemId: String, + @Body syncProgressRequest: ProgressSyncRequest, + ): Response - @POST("/api/session/{itemId}/sync") - suspend fun publishLibraryItemProgress( - @Path("itemId") itemId: String, - @Body syncProgressRequest: ProgressSyncRequest, - ): Response + @POST("/api/items/{itemId}/play/{episodeId}") + suspend fun startPodcastPlayback( + @Path("itemId") itemId: String, + @Path("episodeId") episodeId: String, + @Body syncProgressRequest: PlaybackStartRequest, + ): Response - @POST("/api/items/{itemId}/play/{episodeId}") - suspend fun startPodcastPlayback( - @Path("itemId") itemId: String, - @Path("episodeId") episodeId: String, - @Body syncProgressRequest: PlaybackStartRequest, - ): Response + @POST("/api/items/{itemId}/play") + suspend fun startLibraryPlayback( + @Path("itemId") itemId: String, + @Body syncProgressRequest: PlaybackStartRequest, + ): Response - @POST("/api/items/{itemId}/play") - suspend fun startLibraryPlayback( - @Path("itemId") itemId: String, - @Body syncProgressRequest: PlaybackStartRequest, - ): Response - - @POST("login") - suspend fun login(@Body request: CredentialsLoginRequest): Response + @POST("login") + suspend fun login( + @Body request: CredentialsLoginRequest, + ): Response } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfMediaClient.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfMediaClient.kt index 6c9966ec..23f46979 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfMediaClient.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfMediaClient.kt @@ -7,10 +7,9 @@ import retrofit2.http.Path import retrofit2.http.Streaming interface AudiobookshelfMediaClient { - - @GET("/api/items/{itemId}/cover?raw=1") - @Streaming - suspend fun getItemCover( - @Path("itemId") itemId: String, - ): Response + @GET("/api/items/{itemId}/cover?raw=1") + @Streaming + suspend fun getItemCover( + @Path("itemId") itemId: String, + ): Response } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/AuthMethodResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/AuthMethodResponseConverter.kt index a21d8e6c..f313da5c 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/AuthMethodResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/AuthMethodResponseConverter.kt @@ -6,15 +6,17 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class AuthMethodResponseConverter @Inject constructor() { - - fun apply(response: AuthMethodResponse): List = response +class AuthMethodResponseConverter + @Inject + constructor() { + fun apply(response: AuthMethodResponse): List = + response .authMethods .mapNotNull { - when (it) { - "local" -> AuthMethod.CREDENTIALS - "openid" -> AuthMethod.O_AUTH - else -> null - } + when (it) { + "local" -> AuthMethod.CREDENTIALS + "openid" -> AuthMethod.O_AUTH + else -> null + } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/ConnectionInfoResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/ConnectionInfoResponseConverter.kt index 0baa740c..52dd0e0d 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/ConnectionInfoResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/ConnectionInfoResponseConverter.kt @@ -6,11 +6,13 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ConnectionInfoResponseConverter @Inject constructor() { - - fun apply(response: ConnectionInfoResponse): ConnectionInfo = ConnectionInfo( +class ConnectionInfoResponseConverter + @Inject + constructor() { + fun apply(response: ConnectionInfoResponse): ConnectionInfo = + ConnectionInfo( username = response.user.username, serverVersion = response.serverSettings?.version, buildNumber = response.serverSettings?.buildNumber, - ) -} + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt index 8b1e3420..ace5de71 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt @@ -7,26 +7,27 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LibraryPageResponseConverter @Inject constructor() { - - fun apply(response: LibraryItemsResponse): PagedItems = response +class LibraryPageResponseConverter + @Inject + constructor() { + fun apply(response: LibraryItemsResponse): PagedItems = + response .results .mapNotNull { - val title = it.media.metadata.title ?: return@mapNotNull null + val title = it.media.metadata.title ?: return@mapNotNull null - Book( - id = it.id, - title = title, - series = it.media.metadata.seriesName, - subtitle = it.media.metadata.subtitle, - author = it.media.metadata.authorName, - duration = it.media.duration.toInt(), - ) + Book( + id = it.id, + title = title, + series = it.media.metadata.seriesName, + subtitle = it.media.metadata.subtitle, + author = it.media.metadata.authorName, + duration = it.media.duration.toInt(), + ) + }.let { + PagedItems( + items = it, + currentPage = response.page, + ) } - .let { - PagedItems( - items = it, - currentPage = response.page, - ) - } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryResponseConverter.kt index 1eca25df..f1898fae 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryResponseConverter.kt @@ -7,20 +7,23 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LibraryResponseConverter @Inject constructor() { - - fun apply(response: LibraryResponse): List = response +class LibraryResponseConverter + @Inject + constructor() { + fun apply(response: LibraryResponse): List = + response .libraries .map { - it - .mediaType - .toLibraryType() - .let { type -> Library(it.id, it.name, type) } + it + .mediaType + .toLibraryType() + .let { type -> Library(it.id, it.name, type) } } - private fun String.toLibraryType() = when (this) { + private fun String.toLibraryType() = + when (this) { "podcast" -> LibraryType.PODCAST "book" -> LibraryType.LIBRARY else -> LibraryType.UNKNOWN - } -} + } + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LoginResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LoginResponseConverter.kt index 67d08d24..e91943c2 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LoginResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LoginResponseConverter.kt @@ -6,11 +6,13 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LoginResponseConverter @Inject constructor() { - - fun apply(response: LoggedUserResponse): UserAccount = UserAccount( +class LoginResponseConverter + @Inject + constructor() { + fun apply(response: LoggedUserResponse): UserAccount = + UserAccount( token = response.user.token, username = response.user.username, preferredLibraryId = response.userDefaultLibraryId, - ) -} + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/PlaybackSessionResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/PlaybackSessionResponseConverter.kt index a37aea4c..dafaab64 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/PlaybackSessionResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/PlaybackSessionResponseConverter.kt @@ -6,11 +6,12 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PlaybackSessionResponseConverter @Inject constructor() { - +class PlaybackSessionResponseConverter + @Inject + constructor() { fun apply(response: PlaybackSessionResponse): PlaybackSession = - PlaybackSession( - sessionId = response.id, - bookId = response.libraryItemId, - ) -} + PlaybackSession( + sessionId = response.id, + bookId = response.libraryItemId, + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/RecentListeningResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/RecentListeningResponseConverter.kt index a0f099a2..1a5208fb 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/RecentListeningResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/RecentListeningResponseConverter.kt @@ -6,28 +6,29 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class RecentListeningResponseConverter @Inject constructor() { - +class RecentListeningResponseConverter + @Inject + constructor() { fun apply( - response: List, - progress: Map>, - ): List = response + response: List, + progress: Map>, + ): List = + response .find { it.labelStringKey == LABEL_CONTINUE_LISTENING } ?.entities ?.distinctBy { it.id } ?.map { - RecentBook( - id = it.id, - title = it.media.metadata.title, - subtitle = it.media.metadata.subtitle, - author = it.media.metadata.authorName, - listenedPercentage = progress[it.id]?.second?.let { it * 100 }?.toInt(), - listenedLastUpdate = progress[it.id]?.first, - ) + RecentBook( + id = it.id, + title = it.media.metadata.title, + subtitle = it.media.metadata.subtitle, + author = it.media.metadata.authorName, + listenedPercentage = progress[it.id]?.second?.let { it * 100 }?.toInt(), + listenedLastUpdate = progress[it.id]?.first, + ) } ?: emptyList() companion object { - - private const val LABEL_CONTINUE_LISTENING = "LabelContinueListening" + private const val LABEL_CONTINUE_LISTENING = "LabelContinueListening" } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/MediaProgressResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/MediaProgressResponse.kt index 99c2bc11..73a4c7bf 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/MediaProgressResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/MediaProgressResponse.kt @@ -4,10 +4,10 @@ import androidx.annotation.Keep @Keep data class MediaProgressResponse( - val libraryItemId: String, - val episodeId: String?, - val currentTime: Double, - val isFinished: Boolean, - val lastUpdate: Long, - val progress: Double, + val libraryItemId: String, + val episodeId: String?, + val currentTime: Double, + val isFinished: Boolean, + val lastUpdate: Long, + val progress: Double, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/auth/AuthMethodResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/auth/AuthMethodResponse.kt index dbb3dfe0..22c78249 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/auth/AuthMethodResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/auth/AuthMethodResponse.kt @@ -4,5 +4,5 @@ import androidx.annotation.Keep @Keep data class AuthMethodResponse( - val authMethods: List = emptyList(), + val authMethods: List = emptyList(), ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/connection/ConnectionInfoResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/connection/ConnectionInfoResponse.kt index 67c63fdf..95809d19 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/connection/ConnectionInfoResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/connection/ConnectionInfoResponse.kt @@ -4,17 +4,17 @@ import androidx.annotation.Keep @Keep data class ConnectionInfoResponse( - val user: ConnectionInfoUserResponse, - val serverSettings: ConnectionInfoServerResponse?, + val user: ConnectionInfoUserResponse, + val serverSettings: ConnectionInfoServerResponse?, ) @Keep data class ConnectionInfoUserResponse( - val username: String, + val username: String, ) @Keep data class ConnectionInfoServerResponse( - val version: String?, - val buildNumber: String?, + val version: String?, + val buildNumber: String?, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/AuthorItemsResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/AuthorItemsResponse.kt index 28a0814d..7509e7d3 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/AuthorItemsResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/AuthorItemsResponse.kt @@ -5,5 +5,5 @@ import org.grakovne.lissen.channel.audiobookshelf.library.model.LibraryItem @Keep data class AuthorItemsResponse( - val libraryItems: List, + val libraryItems: List, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/LibraryResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/LibraryResponse.kt index cdd177e4..ea72278c 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/LibraryResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/LibraryResponse.kt @@ -4,12 +4,12 @@ import androidx.annotation.Keep @Keep data class LibraryResponse( - val libraries: List, + val libraries: List, ) @Keep data class LibraryItemResponse( - val id: String, - val name: String, - val mediaType: String, + val id: String, + val name: String, + val mediaType: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackSessionResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackSessionResponse.kt index a3732395..ddfbf1bc 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackSessionResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackSessionResponse.kt @@ -4,6 +4,6 @@ import androidx.annotation.Keep @Keep data class PlaybackSessionResponse( - val id: String, - val libraryItemId: String, + val id: String, + val libraryItemId: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackStartRequest.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackStartRequest.kt index 0d5f8a40..9f5296f5 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackStartRequest.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackStartRequest.kt @@ -4,16 +4,16 @@ import androidx.annotation.Keep @Keep data class PlaybackStartRequest( - val deviceInfo: DeviceInfo, - val supportedMimeTypes: List, - val mediaPlayer: String, - val forceTranscode: Boolean, - val forceDirectPlay: Boolean, + val deviceInfo: DeviceInfo, + val supportedMimeTypes: List, + val mediaPlayer: String, + val forceTranscode: Boolean, + val forceDirectPlay: Boolean, ) @Keep data class DeviceInfo( - val clientName: String, - val deviceId: String, - val deviceName: String, + val clientName: String, + val deviceId: String, + val deviceName: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/ProgressSyncRequest.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/ProgressSyncRequest.kt index 07192c2b..22bcfd1f 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/ProgressSyncRequest.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/ProgressSyncRequest.kt @@ -4,6 +4,6 @@ import androidx.annotation.Keep @Keep data class ProgressSyncRequest( - val timeListened: Int, - val currentTime: Double, + val timeListened: Int, + val currentTime: Double, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/CredentialsLoginRequest.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/CredentialsLoginRequest.kt index 777a3a13..069619b0 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/CredentialsLoginRequest.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/CredentialsLoginRequest.kt @@ -4,6 +4,6 @@ import androidx.annotation.Keep @Keep data class CredentialsLoginRequest( - val username: String, - val password: String, + val username: String, + val password: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/LoggedUserResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/LoggedUserResponse.kt index c2a2e82e..6b37028c 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/LoggedUserResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/LoggedUserResponse.kt @@ -4,13 +4,13 @@ import androidx.annotation.Keep @Keep data class LoggedUserResponse( - val user: User, - val userDefaultLibraryId: String?, + val user: User, + val userDefaultLibraryId: String?, ) @Keep data class User( - val id: String, - val token: String, - val username: String = "username", + val id: String, + val token: String, + val username: String = "username", ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/PersonalizedFeedResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/PersonalizedFeedResponse.kt index 62b48d85..bc4444ce 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/PersonalizedFeedResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/PersonalizedFeedResponse.kt @@ -4,28 +4,28 @@ import androidx.annotation.Keep @Keep data class PersonalizedFeedResponse( - val id: String, - val labelStringKey: String, - val entities: List, + val id: String, + val labelStringKey: String, + val entities: List, ) @Keep data class PersonalizedFeedItemResponse( - val id: String, - val libraryId: String, - val media: PersonalizedFeedItemMediaResponse, - val updateAt: Long, + val id: String, + val libraryId: String, + val media: PersonalizedFeedItemMediaResponse, + val updateAt: Long, ) @Keep data class PersonalizedFeedItemMediaResponse( - val id: String, - val metadata: PersonalizedFeedItemMetadataResponse, + val id: String, + val metadata: PersonalizedFeedItemMetadataResponse, ) @Keep data class PersonalizedFeedItemMetadataResponse( - val title: String, - val subtitle: String?, - val authorName: String, + val title: String, + val subtitle: String?, + val authorName: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/UserInfoResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/UserInfoResponse.kt index 311e09dc..b1018116 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/UserInfoResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/UserInfoResponse.kt @@ -5,10 +5,10 @@ import org.grakovne.lissen.channel.audiobookshelf.common.model.MediaProgressResp @Keep data class UserInfoResponse( - val user: UserResponse, + val user: UserResponse, ) @Keep data class UserResponse( - val mediaProgress: List?, + val mediaProgress: List?, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt index dbbbb14c..21125152 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt @@ -20,74 +20,74 @@ import javax.inject.Inject @AndroidEntryPoint class AudiobookshelfOAuthCallbackActivity : ComponentActivity() { + @Inject + lateinit var contextCache: OAuthContextCache - @Inject - lateinit var contextCache: OAuthContextCache + @Inject + lateinit var authService: AudiobookshelfAuthService - @Inject - lateinit var authService: AudiobookshelfAuthService + @Inject + lateinit var mediaProvider: LissenMediaProvider - @Inject - lateinit var mediaProvider: LissenMediaProvider + @Inject + lateinit var preferences: LissenSharedPreferences - @Inject - lateinit var preferences: LissenSharedPreferences + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val data = intent?.data - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val data = intent?.data - - if (null == data) { - finish() - return - } - - if (intent?.action == Intent.ACTION_VIEW && data.scheme == AuthScheme) { - val code = data.getQueryParameter("code") ?: "" - Log.d(TAG, "Got Exchange code from ABS") - - lifecycleScope.launch { - authService.exchangeToken( - host = preferences.getHost() ?: kotlin.run { - onLoginFailed("invalid_host") - return@launch - }, - code = code, - onSuccess = { onLogged(it) }, - onFailure = { onLoginFailed(it) }, - ) - } - } + if (null == data) { + finish() + return } - private suspend fun onLogged(userAccount: UserAccount) { - mediaProvider.onPostLogin( - host = preferences.getHost() ?: return, - account = userAccount, + if (intent?.action == Intent.ACTION_VIEW && data.scheme == AuthScheme) { + val code = data.getQueryParameter("code") ?: "" + Log.d(TAG, "Got Exchange code from ABS") + + lifecycleScope.launch { + authService.exchangeToken( + host = + preferences.getHost() ?: kotlin.run { + onLoginFailed("invalid_host") + return@launch + }, + code = code, + onSuccess = { onLogged(it) }, + onFailure = { onLoginFailed(it) }, ) - - val intent = Intent(this, AppActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_CLEAR_TASK - } - - startActivity(intent) - finish() + } } + } - private fun onLoginFailed(reason: String) { - runOnUiThread { - authService - .examineError(reason) - .makeText(this) - .let { Toast.makeText(this, it, LENGTH_SHORT).show() } + private suspend fun onLogged(userAccount: UserAccount) { + mediaProvider.onPostLogin( + host = preferences.getHost() ?: return, + account = userAccount, + ) - finish() - } + val intent = + Intent(this, AppActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + startActivity(intent) + finish() + } + + private fun onLoginFailed(reason: String) { + runOnUiThread { + authService + .examineError(reason) + .makeText(this) + .let { Toast.makeText(this, it, LENGTH_SHORT).show() } + + finish() } + } - companion object { - - private const val TAG = "AudiobookshelfOAuthCallbackActivity" - } + companion object { + private const val TAG = "AudiobookshelfOAuthCallbackActivity" + } } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt index 42dbb45a..6b16bb65 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/LibraryAudiobookshelfChannel.kt @@ -30,7 +30,9 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LibraryAudiobookshelfChannel @Inject constructor( +class LibraryAudiobookshelfChannel + @Inject + constructor( dataRepository: AudioBookshelfDataRepository, mediaRepository: AudioBookshelfMediaRepository, recentListeningResponseConverter: RecentListeningResponseConverter, @@ -43,156 +45,158 @@ class LibraryAudiobookshelfChannel @Inject constructor( private val libraryPageResponseConverter: LibraryPageResponseConverter, private val bookResponseConverter: BookResponseConverter, private val librarySearchItemsConverter: LibrarySearchItemsConverter, -) : AudiobookshelfChannel( - dataRepository = dataRepository, - mediaRepository = mediaRepository, - recentBookResponseConverter = recentListeningResponseConverter, - sessionResponseConverter = sessionResponseConverter, - preferences = preferences, - syncService = syncService, - libraryResponseConverter = libraryResponseConverter, - connectionInfoResponseConverter = connectionInfoResponseConverter, -) { - + ) : AudiobookshelfChannel( + dataRepository = dataRepository, + mediaRepository = mediaRepository, + recentBookResponseConverter = recentListeningResponseConverter, + sessionResponseConverter = sessionResponseConverter, + preferences = preferences, + syncService = syncService, + libraryResponseConverter = libraryResponseConverter, + connectionInfoResponseConverter = connectionInfoResponseConverter, + ) { override fun getLibraryType() = LibraryType.LIBRARY override suspend fun fetchBooks( - libraryId: String, - pageSize: Int, - pageNumber: Int, + libraryId: String, + pageSize: Int, + pageNumber: Int, ): ApiResult> { - val (option, direction) = libraryOrderingRequestConverter.apply(preferences.getLibraryOrdering()) + val (option, direction) = libraryOrderingRequestConverter.apply(preferences.getLibraryOrdering()) - return dataRepository - .fetchLibraryItems( - libraryId = libraryId, - pageSize = pageSize, - pageNumber = pageNumber, - sort = option, - direction = direction, - ) - .map { libraryPageResponseConverter.apply(it) } + return dataRepository + .fetchLibraryItems( + libraryId = libraryId, + pageSize = pageSize, + pageNumber = pageNumber, + sort = option, + direction = direction, + ).map { libraryPageResponseConverter.apply(it) } } override suspend fun searchBooks( - libraryId: String, - query: String, - limit: Int, - ): ApiResult> = coroutineScope { + libraryId: String, + query: String, + limit: Int, + ): ApiResult> = + coroutineScope { val searchResult = dataRepository.searchBooks(libraryId, query, limit) - val byTitle = async { + val byTitle = + async { searchResult - .map { it.book } - .map { it.map { response -> response.libraryItem } } - .map { librarySearchItemsConverter.apply(it) } - } + .map { it.book } + .map { it.map { response -> response.libraryItem } } + .map { librarySearchItemsConverter.apply(it) } + } - val byAuthor = async { + val byAuthor = + async { searchResult - .map { it.authors } - .map { authors -> authors.map { it.id } } - .map { ids -> ids.map { id -> async { dataRepository.fetchAuthorItems(id) } } } - .map { it.awaitAll() } - .map { result -> - result - .flatMap { authorResponse -> - authorResponse - .fold( - onSuccess = { it.libraryItems }, - onFailure = { emptyList() }, - ) - } - } - .map { librarySearchItemsConverter.apply(it) } - } + .map { it.authors } + .map { authors -> authors.map { it.id } } + .map { ids -> ids.map { id -> async { dataRepository.fetchAuthorItems(id) } } } + .map { it.awaitAll() } + .map { result -> + result + .flatMap { authorResponse -> + authorResponse + .fold( + onSuccess = { it.libraryItems }, + onFailure = { emptyList() }, + ) + } + }.map { librarySearchItemsConverter.apply(it) } + } - val bySeries: Deferred>> = async { + val bySeries: Deferred>> = + async { searchResult - .map { result -> result.series } - .map { result -> result.flatMap { it.books } } - .map { result -> result.mapNotNull { it.media.metadata.title } } - .map { result -> result.map { async { dataRepository.searchBooks(libraryId, it, limit) } } } - .map { result -> result.awaitAll() } - .map { result -> - result.flatMap { - it.fold( - onSuccess = { items -> items.book }, - onFailure = { emptyList() }, - ) - } + .map { result -> result.series } + .map { result -> result.flatMap { it.books } } + .map { result -> result.mapNotNull { it.media.metadata.title } } + .map { result -> result.map { async { dataRepository.searchBooks(libraryId, it, limit) } } } + .map { result -> result.awaitAll() } + .map { result -> + result.flatMap { + it.fold( + onSuccess = { items -> items.book }, + onFailure = { emptyList() }, + ) } - .map { result -> result.map { it.libraryItem } } - .map { result -> result.let { librarySearchItemsConverter.apply(it) } } - } + }.map { result -> result.map { it.libraryItem } } + .map { result -> result.let { librarySearchItemsConverter.apply(it) } } + } mergeBooks(byTitle, byAuthor, bySeries) - } + } - private suspend fun mergeBooks( - vararg queries: Deferred>>, - ): ApiResult> = coroutineScope { + private suspend fun mergeBooks(vararg queries: Deferred>>): ApiResult> = + coroutineScope { val results: List>> = awaitAll(*queries) - val merged: ApiResult> = results + val merged: ApiResult> = + results .fold>, ApiResult>>(Success(emptyList())) { acc, res -> - when { - acc is ApiResult.Error -> acc - res is ApiResult.Error -> res - else -> { - val combined = (acc as Success).data + (res as Success).data - Success(combined) - } + when { + acc is ApiResult.Error -> acc + res is ApiResult.Error -> res + else -> { + val combined = (acc as Success).data + (res as Success).data + Success(combined) } + } } merged.map { list -> - list - .distinctBy { it.id } - .sortedWith(compareBy({ it.series }, { it.author }, { it.title })) + list + .distinctBy { it.id } + .sortedWith(compareBy({ it.series }, { it.author }, { it.title })) } - } + } override suspend fun startPlayback( - bookId: String, - episodeId: String, - supportedMimeTypes: List, - deviceId: String, + bookId: String, + episodeId: String, + supportedMimeTypes: List, + deviceId: String, ): ApiResult { - val request = PlaybackStartRequest( - supportedMimeTypes = supportedMimeTypes, - deviceInfo = DeviceInfo( - clientName = getClientName(), - deviceId = deviceId, - deviceName = getClientName(), + val request = + PlaybackStartRequest( + supportedMimeTypes = supportedMimeTypes, + deviceInfo = + DeviceInfo( + clientName = getClientName(), + deviceId = deviceId, + deviceName = getClientName(), ), - forceTranscode = false, - forceDirectPlay = false, - mediaPlayer = getClientName(), + forceTranscode = false, + forceDirectPlay = false, + mediaPlayer = getClientName(), ) - return dataRepository - .startPlayback( - itemId = bookId, - request = request, - ) - .map { sessionResponseConverter.apply(it) } + return dataRepository + .startPlayback( + itemId = bookId, + request = request, + ).map { sessionResponseConverter.apply(it) } } - override suspend fun fetchBook(bookId: String): ApiResult = coroutineScope { + override suspend fun fetchBook(bookId: String): ApiResult = + coroutineScope { val book = async { dataRepository.fetchBook(bookId) } val bookProgress = async { dataRepository.fetchLibraryItemProgress(bookId) } book.await().foldAsync( - onSuccess = { item -> - bookProgress - .await() - .fold( - onSuccess = { Success(bookResponseConverter.apply(item, it)) }, - onFailure = { Success(bookResponseConverter.apply(item, null)) }, - ) - }, - onFailure = { ApiResult.Error(it.code) }, + onSuccess = { item -> + bookProgress + .await() + .fold( + onSuccess = { Success(bookResponseConverter.apply(item, it)) }, + onFailure = { Success(bookResponseConverter.apply(item, null)) }, + ) + }, + onFailure = { ApiResult.Error(it.code) }, ) - } -} + } + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt index 44b6f54f..3672cd3c 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/BookResponseConverter.kt @@ -12,99 +12,109 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class BookResponseConverter @Inject constructor() { - +class BookResponseConverter + @Inject + constructor() { fun apply( - item: BookResponse, - progressResponse: MediaProgressResponse? = null, + item: BookResponse, + progressResponse: MediaProgressResponse? = null, ): DetailedItem { - val maybeChapters = item + val maybeChapters = + item + .media + .chapters + ?.takeIf { it.isNotEmpty() } + ?.map { + PlayingChapter( + start = it.start, + end = it.end, + title = it.title, + available = true, + id = it.id, + duration = it.end - it.start, + podcastEpisodeState = null, + ) + } + + val filesAsChapters: () -> List = { + item + .media + .audioFiles + ?.sortedBy { it.index } + ?.fold(0.0 to mutableListOf()) { (accDuration, chapters), file -> + chapters.add( + PlayingChapter( + available = true, + start = accDuration, + end = accDuration + file.duration, + title = + file.metaTags?.tagTitle + ?: file.metadata.filename.removeSuffix(file.metadata.ext), + duration = file.duration, + id = file.ino, + podcastEpisodeState = null, + ), + ) + accDuration + file.duration to chapters + }?.second + ?: emptyList() + } + + return DetailedItem( + id = item.id, + title = item.media.metadata.title, + subtitle = item.media.metadata.subtitle, + author = + item.media.metadata.authors + ?.joinToString(", ", transform = LibraryAuthorResponse::name), + narrator = + item.media.metadata.narrators + ?.joinToString(separator = ", "), + files = + item .media - .chapters - ?.takeIf { it.isNotEmpty() } + .audioFiles + ?.sortedBy { it.index } ?.map { - PlayingChapter( - start = it.start, - end = it.end, - title = it.title, - available = true, - id = it.id, - duration = it.end - it.start, - podcastEpisodeState = null, - ) + BookFile( + id = it.ino, + name = + it.metaTags + ?.tagTitle + ?: (it.metadata.filename.removeSuffix(it.metadata.ext)), + duration = it.duration, + mimeType = it.mimeType, + ) } - - val filesAsChapters: () -> List = { - item - .media - .audioFiles - ?.sortedBy { it.index } - ?.fold(0.0 to mutableListOf()) { (accDuration, chapters), file -> - chapters.add( - PlayingChapter( - available = true, - start = accDuration, - end = accDuration + file.duration, - title = file.metaTags?.tagTitle - ?: file.metadata.filename.removeSuffix(file.metadata.ext), - duration = file.duration, - id = file.ino, - podcastEpisodeState = null, - ), - ) - accDuration + file.duration to chapters - } - ?.second - ?: emptyList() - } - - return DetailedItem( - id = item.id, - title = item.media.metadata.title, - subtitle = item.media.metadata.subtitle, - author = item.media.metadata.authors?.joinToString(", ", transform = LibraryAuthorResponse::name), - narrator = item.media.metadata.narrators?.joinToString(separator = ", "), - files = item - .media - .audioFiles - ?.sortedBy { it.index } - ?.map { - BookFile( - id = it.ino, - name = it.metaTags - ?.tagTitle - ?: (it.metadata.filename.removeSuffix(it.metadata.ext)), - duration = it.duration, - mimeType = it.mimeType, - ) - } - ?: emptyList(), - chapters = maybeChapters ?: filesAsChapters(), - libraryId = item.libraryId, - localProvided = false, - year = item.media.metadata.publishedYear, - abstract = item.media.metadata.description, - publisher = item.media.metadata.publisher, - series = item - .media - .metadata - .series - ?.map { - BookSeries( - name = it.name, - serialNumber = it.sequence, - ) - } ?: emptyList(), - createdAt = item.addedAt, - updatedAt = item.ctimeMs, - progress = progressResponse - ?.let { - MediaProgress( - currentTime = it.currentTime, - isFinished = it.isFinished, - lastUpdate = it.lastUpdate, - ) - }, - ) + ?: emptyList(), + chapters = maybeChapters ?: filesAsChapters(), + libraryId = item.libraryId, + localProvided = false, + year = item.media.metadata.publishedYear, + abstract = item.media.metadata.description, + publisher = item.media.metadata.publisher, + series = + item + .media + .metadata + .series + ?.map { + BookSeries( + name = it.name, + serialNumber = it.sequence, + ) + } ?: emptyList(), + createdAt = item.addedAt, + updatedAt = item.ctimeMs, + progress = + progressResponse + ?.let { + MediaProgress( + currentTime = it.currentTime, + isFinished = it.isFinished, + lastUpdate = it.lastUpdate, + ) + }, + ) } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibraryOrderingRequestConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibraryOrderingRequestConverter.kt index 74d1c4a6..df9858b9 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibraryOrderingRequestConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibraryOrderingRequestConverter.kt @@ -7,21 +7,24 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LibraryOrderingRequestConverter @Inject constructor() { - +class LibraryOrderingRequestConverter + @Inject + constructor() { fun apply(configuration: LibraryOrderingConfiguration): Pair { - val option = when (configuration.option) { - LibraryOrderingOption.TITLE -> "media.metadata.title" - LibraryOrderingOption.AUTHOR -> "media.metadata.authorName" - LibraryOrderingOption.CREATED_AT -> "addedAt" - LibraryOrderingOption.UPDATED_AT -> "mtimeMs" + val option = + when (configuration.option) { + LibraryOrderingOption.TITLE -> "media.metadata.title" + LibraryOrderingOption.AUTHOR -> "media.metadata.authorName" + LibraryOrderingOption.CREATED_AT -> "addedAt" + LibraryOrderingOption.UPDATED_AT -> "mtimeMs" } - val direction = when (configuration.direction) { - LibraryOrderingDirection.ASCENDING -> "0" - LibraryOrderingDirection.DESCENDING -> "1" + val direction = + when (configuration.direction) { + LibraryOrderingDirection.ASCENDING -> "0" + LibraryOrderingDirection.DESCENDING -> "1" } - return option to direction + return option to direction } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt index f0f3fc74..5f387a96 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt @@ -6,18 +6,21 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LibrarySearchItemsConverter @Inject constructor() { - fun apply(response: List) = response +class LibrarySearchItemsConverter + @Inject + constructor() { + fun apply(response: List) = + response .mapNotNull { - val title = it.media.metadata.title ?: return@mapNotNull null + val title = it.media.metadata.title ?: return@mapNotNull null - Book( - id = it.id, - title = title, - series = it.media.metadata.seriesName, - subtitle = it.media.metadata.subtitle, - author = it.media.metadata.authorName, - duration = it.media.duration.toInt(), - ) + Book( + id = it.id, + title = title, + series = it.media.metadata.seriesName, + subtitle = it.media.metadata.subtitle, + author = it.media.metadata.authorName, + duration = it.media.duration.toInt(), + ) } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt index 06c7117b..8b911847 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt @@ -4,73 +4,73 @@ import androidx.annotation.Keep @Keep data class BookResponse( - val id: String, - val ino: String, - val libraryId: String, - val media: BookMedia, - val addedAt: Long, - val ctimeMs: Long, + val id: String, + val ino: String, + val libraryId: String, + val media: BookMedia, + val addedAt: Long, + val ctimeMs: Long, ) @Keep data class BookMedia( - val metadata: LibraryMetadataResponse, - val audioFiles: List?, - val chapters: List?, + val metadata: LibraryMetadataResponse, + val audioFiles: List?, + val chapters: List?, ) @Keep data class LibraryMetadataResponse( - val title: String, - val subtitle: String?, - val authors: List?, - val narrators: List?, - val series: List?, - val description: String?, - val publisher: String?, - val publishedYear: String?, + val title: String, + val subtitle: String?, + val authors: List?, + val narrators: List?, + val series: List?, + val description: String?, + val publisher: String?, + val publishedYear: String?, ) @Keep data class LibrarySeriesResponse( - val id: String, - val name: String, - val sequence: String?, + val id: String, + val name: String, + val sequence: String?, ) @Keep data class LibraryAuthorResponse( - val id: String, - val name: String, + val id: String, + val name: String, ) @Keep data class BookAudioFileResponse( - val index: Int, - val ino: String, - val duration: Double, - val metadata: AudioFileMetadata, - val metaTags: AudioFileTag?, - val mimeType: String, + val index: Int, + val ino: String, + val duration: Double, + val metadata: AudioFileMetadata, + val metaTags: AudioFileTag?, + val mimeType: String, ) @Keep data class AudioFileMetadata( - val filename: String, - val ext: String, - val size: Long, + val filename: String, + val ext: String, + val size: Long, ) @Keep data class AudioFileTag( - val tagAlbum: String, - val tagTitle: String, + val tagAlbum: String, + val tagTitle: String, ) @Keep data class LibraryChapterResponse( - val start: Double, - val end: Double, - val title: String, - val id: String, + val start: Double, + val end: Double, + val title: String, + val id: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt index 5b68e94f..9ca7532d 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt @@ -4,26 +4,26 @@ import androidx.annotation.Keep @Keep data class LibraryItemsResponse( - val results: List, - val page: Int, + val results: List, + val page: Int, ) @Keep data class LibraryItem( - val id: String, - val media: Media, + val id: String, + val media: Media, ) @Keep data class Media( - val duration: Double, - val metadata: LibraryMetadata, + val duration: Double, + val metadata: LibraryMetadata, ) @Keep data class LibraryMetadata( - val title: String?, - val subtitle: String?, - val seriesName: String?, - val authorName: String?, + val title: String?, + val subtitle: String?, + val seriesName: String?, + val authorName: String?, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibrarySearchResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibrarySearchResponse.kt index 6b1fe657..053a5ca1 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibrarySearchResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibrarySearchResponse.kt @@ -4,23 +4,23 @@ import androidx.annotation.Keep @Keep data class LibrarySearchResponse( - val book: List, - val authors: List, - val series: List, + val book: List, + val authors: List, + val series: List, ) @Keep data class LibrarySearchItemResponse( - val libraryItem: LibraryItem, + val libraryItem: LibraryItem, ) @Keep data class LibrarySearchAuthorResponse( - val id: String, - val name: String, + val id: String, + val name: String, ) @Keep data class LibrarySearchSeriesResponse( - val books: List, + val books: List, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt index e4fef2d2..fd3802c9 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/PodcastAudiobookshelfChannel.kt @@ -27,7 +27,9 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PodcastAudiobookshelfChannel @Inject constructor( +class PodcastAudiobookshelfChannel + @Inject + constructor( dataRepository: AudioBookshelfDataRepository, mediaRepository: AudioBookshelfMediaRepository, recentListeningResponseConverter: RecentListeningResponseConverter, @@ -40,102 +42,106 @@ class PodcastAudiobookshelfChannel @Inject constructor( private val podcastPageResponseConverter: PodcastPageResponseConverter, private val podcastResponseConverter: PodcastResponseConverter, private val podcastSearchItemsConverter: PodcastSearchItemsConverter, -) : AudiobookshelfChannel( - dataRepository = dataRepository, - mediaRepository = mediaRepository, - recentBookResponseConverter = recentListeningResponseConverter, - sessionResponseConverter = sessionResponseConverter, - preferences = preferences, - syncService = syncService, - libraryResponseConverter = libraryResponseConverter, - connectionInfoResponseConverter = connectionInfoResponseConverter, -) { - + ) : AudiobookshelfChannel( + dataRepository = dataRepository, + mediaRepository = mediaRepository, + recentBookResponseConverter = recentListeningResponseConverter, + sessionResponseConverter = sessionResponseConverter, + preferences = preferences, + syncService = syncService, + libraryResponseConverter = libraryResponseConverter, + connectionInfoResponseConverter = connectionInfoResponseConverter, + ) { override fun getLibraryType() = LibraryType.PODCAST override suspend fun fetchBooks( - libraryId: String, - pageSize: Int, - pageNumber: Int, + libraryId: String, + pageSize: Int, + pageNumber: Int, ): ApiResult> { - val (option, direction) = podcastOrderingRequestConverter.apply(preferences.getLibraryOrdering()) + val (option, direction) = podcastOrderingRequestConverter.apply(preferences.getLibraryOrdering()) - return dataRepository - .fetchPodcastItems( - libraryId = libraryId, - pageSize = pageSize, - pageNumber = pageNumber, - sort = option, - direction = direction, - ) - .map { podcastPageResponseConverter.apply(it) } + return dataRepository + .fetchPodcastItems( + libraryId = libraryId, + pageSize = pageSize, + pageNumber = pageNumber, + sort = option, + direction = direction, + ).map { podcastPageResponseConverter.apply(it) } } override suspend fun searchBooks( - libraryId: String, - query: String, - limit: Int, - ): ApiResult> = coroutineScope { - val byTitle = async { + libraryId: String, + query: String, + limit: Int, + ): ApiResult> = + coroutineScope { + val byTitle = + async { dataRepository - .searchPodcasts(libraryId, query, limit) - .map { it.podcast } - .map { it.map { response -> response.libraryItem } } - .map { podcastSearchItemsConverter.apply(it) } - } + .searchPodcasts(libraryId, query, limit) + .map { it.podcast } + .map { it.map { response -> response.libraryItem } } + .map { podcastSearchItemsConverter.apply(it) } + } byTitle.await() - } + } override suspend fun startPlayback( - bookId: String, - episodeId: String, - supportedMimeTypes: List, - deviceId: String, + bookId: String, + episodeId: String, + supportedMimeTypes: List, + deviceId: String, ): ApiResult { - val request = PlaybackStartRequest( - supportedMimeTypes = supportedMimeTypes, - deviceInfo = DeviceInfo( - clientName = getClientName(), - deviceId = deviceId, - deviceName = getClientName(), + val request = + PlaybackStartRequest( + supportedMimeTypes = supportedMimeTypes, + deviceInfo = + DeviceInfo( + clientName = getClientName(), + deviceId = deviceId, + deviceName = getClientName(), ), - forceTranscode = false, - forceDirectPlay = false, - mediaPlayer = getClientName(), + forceTranscode = false, + forceDirectPlay = false, + mediaPlayer = getClientName(), ) - return dataRepository - .startPodcastPlayback( - itemId = bookId, - episodeId = episodeId, - request = request, - ) - .map { sessionResponseConverter.apply(it) } + return dataRepository + .startPodcastPlayback( + itemId = bookId, + episodeId = episodeId, + request = request, + ).map { sessionResponseConverter.apply(it) } } - override suspend fun fetchBook(bookId: String): ApiResult = coroutineScope { - val mediaProgress = async { - val progress = dataRepository + override suspend fun fetchBook(bookId: String): ApiResult = + coroutineScope { + val mediaProgress = + async { + val progress = + dataRepository .fetchUserInfoResponse() .fold( - onSuccess = { it.user.mediaProgress ?: emptyList() }, - onFailure = { emptyList() }, + onSuccess = { it.user.mediaProgress ?: emptyList() }, + onFailure = { emptyList() }, ) if (progress.isEmpty()) { - return@async null + return@async null } progress - .filter { it.libraryItemId == bookId } - .filterNot { it.episodeId == null } - .sortedByDescending { it.lastUpdate } - .distinctBy { it.episodeId } - } + .filter { it.libraryItemId == bookId } + .filterNot { it.episodeId == null } + .sortedByDescending { it.lastUpdate } + .distinctBy { it.episodeId } + } async { dataRepository.fetchPodcastItem(bookId) } - .await() - .map { podcastResponseConverter.apply(it, mediaProgress.await() ?: emptyList()) } - } -} + .await() + .map { podcastResponseConverter.apply(it, mediaProgress.await() ?: emptyList()) } + } + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastOrderingRequestConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastOrderingRequestConverter.kt index d1f8878c..bbb336bf 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastOrderingRequestConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastOrderingRequestConverter.kt @@ -7,21 +7,24 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PodcastOrderingRequestConverter @Inject constructor() { - +class PodcastOrderingRequestConverter + @Inject + constructor() { fun apply(configuration: LibraryOrderingConfiguration): Pair { - val option = when (configuration.option) { - LibraryOrderingOption.TITLE -> "media.metadata.title" - LibraryOrderingOption.AUTHOR -> "media.metadata.author" - LibraryOrderingOption.CREATED_AT -> "addedAt" - LibraryOrderingOption.UPDATED_AT -> "mtimeMs" + val option = + when (configuration.option) { + LibraryOrderingOption.TITLE -> "media.metadata.title" + LibraryOrderingOption.AUTHOR -> "media.metadata.author" + LibraryOrderingOption.CREATED_AT -> "addedAt" + LibraryOrderingOption.UPDATED_AT -> "mtimeMs" } - val direction = when (configuration.direction) { - LibraryOrderingDirection.ASCENDING -> "0" - LibraryOrderingDirection.DESCENDING -> "1" + val direction = + when (configuration.direction) { + LibraryOrderingDirection.ASCENDING -> "0" + LibraryOrderingDirection.DESCENDING -> "1" } - return option to direction + return option to direction } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt index 2b2277e6..2e96febf 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt @@ -7,26 +7,27 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PodcastPageResponseConverter @Inject constructor() { - - fun apply(response: PodcastItemsResponse): PagedItems = response +class PodcastPageResponseConverter + @Inject + constructor() { + fun apply(response: PodcastItemsResponse): PagedItems = + response .results .mapNotNull { - val title = it.media.metadata.title ?: return@mapNotNull null + val title = it.media.metadata.title ?: return@mapNotNull null - Book( - id = it.id, - title = title, - subtitle = null, - series = null, - author = it.media.metadata.author, - duration = it.media.duration.toInt(), - ) + Book( + id = it.id, + title = title, + subtitle = null, + series = null, + author = it.media.metadata.author, + duration = it.media.duration.toInt(), + ) + }.let { + PagedItems( + items = it, + currentPage = response.page, + ) } - .let { - PagedItems( - items = it, - currentPage = response.page, - ) - } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt index 6d036c72..6bfe39ed 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastResponseConverter.kt @@ -14,119 +14,121 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PodcastResponseConverter @Inject constructor() { - +class PodcastResponseConverter + @Inject + constructor() { fun apply( - item: PodcastResponse, - progressResponses: List = emptyList(), + item: PodcastResponse, + progressResponses: List = emptyList(), ): DetailedItem { - val orderedEpisodes = item - .media - .episodes - ?.orderEpisode() + val orderedEpisodes = + item + .media + .episodes + ?.orderEpisode() - val totalCurrentTime = progressResponses - .maxByOrNull { it.lastUpdate } - ?.let { progress -> - orderedEpisodes - ?.takeWhile { it.id != progress.episodeId } - ?.sumOf { it.audioFile.duration } - ?.plus(progress.currentTime) - } - - val latestEpisodeMediaProgress = progressResponses - .maxByOrNull { it.lastUpdate } - ?.let { - MediaProgress( - currentTime = totalCurrentTime ?: 0.0, - isFinished = it.isFinished, - lastUpdate = it.lastUpdate, - ) - } - - val filesAsChapters: List = + val totalCurrentTime = + progressResponses + .maxByOrNull { it.lastUpdate } + ?.let { progress -> orderedEpisodes - ?.fold(0.0 to mutableListOf()) { (accDuration, chapters), episode -> - chapters.add( - PlayingChapter( - start = accDuration, - end = accDuration + episode.audioFile.duration, - title = episode.title, - duration = episode.audioFile.duration, - id = episode.id, - available = true, - podcastEpisodeState = progressResponses - .find { it.episodeId == episode.id } - ?.let { hasFinished(it) }, - ), - ) - accDuration + episode.audioFile.duration to chapters - } - ?.second - ?: emptyList() + ?.takeWhile { it.id != progress.episodeId } + ?.sumOf { it.audioFile.duration } + ?.plus(progress.currentTime) + } - return DetailedItem( - id = item.id, - title = item.media.metadata.title, - subtitle = null, - libraryId = item.libraryId, - author = item.media.metadata.author, - narrator = null, - localProvided = false, - files = orderedEpisodes - ?.map { - BookFile( - id = it.audioFile.ino, - name = it.title, - duration = it.audioFile.duration, - mimeType = it.audioFile.mimeType, - ) - } - ?: emptyList(), - chapters = filesAsChapters, - progress = latestEpisodeMediaProgress, - year = null, // we have no "Year" for the ongoing media - abstract = item.media.metadata.description, - publisher = item.media.metadata.publisher, - series = emptyList(), // there is no series for podcast - createdAt = item.addedAt, - updatedAt = item.ctimeMs, - ) + val latestEpisodeMediaProgress = + progressResponses + .maxByOrNull { it.lastUpdate } + ?.let { + MediaProgress( + currentTime = totalCurrentTime ?: 0.0, + isFinished = it.isFinished, + lastUpdate = it.lastUpdate, + ) + } + + val filesAsChapters: List = + orderedEpisodes + ?.fold(0.0 to mutableListOf()) { (accDuration, chapters), episode -> + chapters.add( + PlayingChapter( + start = accDuration, + end = accDuration + episode.audioFile.duration, + title = episode.title, + duration = episode.audioFile.duration, + id = episode.id, + available = true, + podcastEpisodeState = + progressResponses + .find { it.episodeId == episode.id } + ?.let { hasFinished(it) }, + ), + ) + accDuration + episode.audioFile.duration to chapters + }?.second + ?: emptyList() + + return DetailedItem( + id = item.id, + title = item.media.metadata.title, + subtitle = null, + libraryId = item.libraryId, + author = item.media.metadata.author, + narrator = null, + localProvided = false, + files = + orderedEpisodes + ?.map { + BookFile( + id = it.audioFile.ino, + name = it.title, + duration = it.audioFile.duration, + mimeType = it.audioFile.mimeType, + ) + } + ?: emptyList(), + chapters = filesAsChapters, + progress = latestEpisodeMediaProgress, + year = null, // we have no "Year" for the ongoing media + abstract = item.media.metadata.description, + publisher = item.media.metadata.publisher, + series = emptyList(), // there is no series for podcast + createdAt = item.addedAt, + updatedAt = item.ctimeMs, + ) } - private fun hasFinished(progress: MediaProgressResponse): BookChapterState? { - return when (progress.isFinished || progress.progress > FINISHED_PROGRESS_THRESHOLD) { - true -> BookChapterState.FINISHED - false -> null - } - } + private fun hasFinished(progress: MediaProgressResponse): BookChapterState? = + when (progress.isFinished || progress.progress > FINISHED_PROGRESS_THRESHOLD) { + true -> BookChapterState.FINISHED + false -> null + } companion object { + private const val FINISHED_PROGRESS_THRESHOLD = 0.9 + private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) - private const val FINISHED_PROGRESS_THRESHOLD = 0.9 - private val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) - - private fun List.orderEpisode() = - this.sortedWith( - compareBy { item -> - try { - item.pubDate?.let { dateFormat.parse(it)?.time } - } catch (e: Exception) { - null - } - } - .thenBy { it.season.safeToInt() } - .thenBy { it.episode.safeToInt() }, - ) - - private fun String?.safeToInt(): Int? { - val maybeNumber = this?.takeIf { it.isNotBlank() } - - return try { - maybeNumber?.toInt() - } catch (ex: Exception) { - null + private fun List.orderEpisode() = + this.sortedWith( + compareBy { item -> + try { + item.pubDate?.let { dateFormat.parse(it)?.time } + } catch (e: Exception) { + null } + }.thenBy { it.season.safeToInt() } + .thenBy { it.episode.safeToInt() }, + ) + + private fun String?.safeToInt(): Int? { + val maybeNumber = this?.takeIf { it.isNotBlank() } + + return try { + maybeNumber?.toInt() + } catch (ex: Exception) { + null } + } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt index ea4c0bb2..5b2640c4 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt @@ -6,20 +6,22 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PodcastSearchItemsConverter @Inject constructor() { +class PodcastSearchItemsConverter + @Inject + constructor() { fun apply(response: List): List { - return response - .mapNotNull { - val title = it.media.metadata.title ?: return@mapNotNull null + return response + .mapNotNull { + val title = it.media.metadata.title ?: return@mapNotNull null - Book( - id = it.id, - title = title, - subtitle = null, - series = null, - author = it.media.metadata.author, - duration = it.media.duration.toInt(), - ) - } + Book( + id = it.id, + title = title, + subtitle = null, + series = null, + author = it.media.metadata.author, + duration = it.media.duration.toInt(), + ) + } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastItemsResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastItemsResponse.kt index 91465c86..e0e3d067 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastItemsResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastItemsResponse.kt @@ -4,24 +4,24 @@ import androidx.annotation.Keep @Keep data class PodcastItemsResponse( - val results: List, - val page: Int, + val results: List, + val page: Int, ) @Keep data class PodcastItem( - val id: String, - val media: PodcastItemMedia, + val id: String, + val media: PodcastItemMedia, ) @Keep data class PodcastItemMedia( - val duration: Double, - val metadata: PodcastMetadata, + val duration: Double, + val metadata: PodcastMetadata, ) @Keep data class PodcastMetadata( - val title: String?, - val author: String?, + val title: String?, + val author: String?, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt index d1644757..1528d072 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt @@ -4,42 +4,42 @@ import androidx.annotation.Keep @Keep data class PodcastResponse( - val id: String, - val ino: String, - val libraryId: String, - val media: PodcastMedia, - val addedAt: Long, - val ctimeMs: Long, + val id: String, + val ino: String, + val libraryId: String, + val media: PodcastMedia, + val addedAt: Long, + val ctimeMs: Long, ) @Keep data class PodcastMedia( - val metadata: PodcastMediaMetadataResponse, - val episodes: List?, + val metadata: PodcastMediaMetadataResponse, + val episodes: List?, ) @Keep data class PodcastMediaMetadataResponse( - val title: String, - val author: String?, - val description: String?, - val publisher: String?, + val title: String, + val author: String?, + val description: String?, + val publisher: String?, ) @Keep data class PodcastEpisodeResponse( - val id: String, - val season: String?, - val episode: String?, - val pubDate: String?, - val title: String, - val audioFile: PodcastAudioFileResponse, + val id: String, + val season: String?, + val episode: String?, + val pubDate: String?, + val title: String, + val audioFile: PodcastAudioFileResponse, ) @Keep data class PodcastAudioFileResponse( - val index: Int, - val ino: String, - val duration: Double, - val mimeType: String, + val index: Int, + val ino: String, + val duration: Double, + val mimeType: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastSearchResponse.kt b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastSearchResponse.kt index 20d191b4..49ac6b3e 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastSearchResponse.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastSearchResponse.kt @@ -4,10 +4,10 @@ import androidx.annotation.Keep @Keep data class PodcastSearchResponse( - val podcast: List, + val podcast: List, ) @Keep data class PodcastSearchItemResponse( - val libraryItem: PodcastItem, + val libraryItem: PodcastItem, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt index a21a37be..a1256846 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt @@ -11,43 +11,42 @@ import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit class ApiClient( - host: String, - requestHeaders: List?, - token: String? = null, + host: String, + requestHeaders: List?, + token: String? = null, ) { + private val httpClient = + OkHttpClient + .Builder() + .withTrustedCertificates() + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.NONE + }, + ).addInterceptor { chain: Interceptor.Chain -> + val original: Request = chain.request() + val requestBuilder: Request.Builder = original.newBuilder() - private val httpClient = OkHttpClient - .Builder() - .withTrustedCertificates() - .addInterceptor( - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.NONE - }, - ) - .addInterceptor { chain: Interceptor.Chain -> - val original: Request = chain.request() - val requestBuilder: Request.Builder = original.newBuilder() - - if (token != null) { - requestBuilder.header("Authorization", "Bearer $token") - } - - requestHeaders - ?.filter { it.name.isNotEmpty() } - ?.filter { it.value.isNotEmpty() } - ?.forEach { requestBuilder.header(it.name, it.value) } - - val request: Request = requestBuilder.build() - chain.proceed(request) + if (token != null) { + requestBuilder.header("Authorization", "Bearer $token") } - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(90, TimeUnit.SECONDS) - .build() - val retrofit: Retrofit = - Retrofit.Builder() - .baseUrl(host) - .client(httpClient) - .addConverterFactory(GsonConverterFactory.create()) - .build() + requestHeaders + ?.filter { it.name.isNotEmpty() } + ?.filter { it.value.isNotEmpty() } + ?.forEach { requestBuilder.header(it.name, it.value) } + + val request: Request = requestBuilder.build() + chain.proceed(request) + }.connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) + .build() + + val retrofit: Retrofit = + Retrofit + .Builder() + .baseUrl(host) + .client(httpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ApiError.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ApiError.kt index cd7d62c4..ef036ece 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ApiError.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ApiError.kt @@ -6,19 +6,29 @@ import org.grakovne.lissen.channel.audiobookshelf.common.oauth.AuthHost import org.grakovne.lissen.channel.audiobookshelf.common.oauth.AuthScheme sealed class ApiError { - data object Unauthorized : ApiError() - data object NetworkError : ApiError() - data object InvalidCredentialsHost : ApiError() - data object MissingCredentialsHost : ApiError() - data object MissingCredentialsUsername : ApiError() - data object MissingCredentialsPassword : ApiError() - data object InternalError : ApiError() - data object InvalidRedirectUri : ApiError() - data object OAuthFlowFailed : ApiError() - data object UnsupportedError : ApiError() + data object Unauthorized : ApiError() + + data object NetworkError : ApiError() + + data object InvalidCredentialsHost : ApiError() + + data object MissingCredentialsHost : ApiError() + + data object MissingCredentialsUsername : ApiError() + + data object MissingCredentialsPassword : ApiError() + + data object InternalError : ApiError() + + data object InvalidRedirectUri : ApiError() + + data object OAuthFlowFailed : ApiError() + + data object UnsupportedError : ApiError() } -fun ApiError.makeText(context: Context) = when (this) { +fun ApiError.makeText(context: Context) = + when (this) { ApiError.InternalError -> context.getString(R.string.login_error_host_is_down) ApiError.MissingCredentialsHost -> context.getString(R.string.login_error_host_url_is_missing) ApiError.MissingCredentialsPassword -> context.getString(R.string.login_error_username_is_missing) @@ -26,7 +36,12 @@ fun ApiError.makeText(context: Context) = when (this) { ApiError.Unauthorized -> context.getString(R.string.login_error_credentials_are_invalid) ApiError.InvalidCredentialsHost -> context.getString(R.string.login_error_host_url_shall_be_https_or_http) ApiError.NetworkError -> context.getString(R.string.login_error_connection_error) - ApiError.InvalidRedirectUri -> context.getString(R.string.login_error_lissen_auth_scheme_must_be_whitelisted, AuthScheme, AuthHost) + ApiError.InvalidRedirectUri -> + context.getString( + R.string.login_error_lissen_auth_scheme_must_be_whitelisted, + AuthScheme, + AuthHost, + ) ApiError.UnsupportedError -> context.getString(R.string.login_error_connection_error) ApiError.OAuthFlowFailed -> context.getString(R.string.login_error_lissen_auth_failed) -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ApiResult.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ApiResult.kt index d62d8a3e..9383dc8c 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ApiResult.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ApiResult.kt @@ -4,40 +4,42 @@ import androidx.annotation.Keep @Keep sealed class ApiResult { - data class Success(val data: T) : ApiResult() - data class Error(val code: ApiError, val message: String? = null) : ApiResult() + data class Success( + val data: T, + ) : ApiResult() - fun fold( - onSuccess: (T) -> R, - onFailure: (Error) -> R, - ): R { - return when (this) { - is Success -> onSuccess(this.data) - is Error -> onFailure(this) - } + data class Error( + val code: ApiError, + val message: String? = null, + ) : ApiResult() + + fun fold( + onSuccess: (T) -> R, + onFailure: (Error) -> R, + ): R = + when (this) { + is Success -> onSuccess(this.data) + is Error -> onFailure(this) } - suspend fun foldAsync( - onSuccess: suspend (T) -> R, - onFailure: suspend (Error) -> R, - ): R { - return when (this) { - is Success -> onSuccess(this.data) - is Error -> onFailure(this) - } + suspend fun foldAsync( + onSuccess: suspend (T) -> R, + onFailure: suspend (Error) -> R, + ): R = + when (this) { + is Success -> onSuccess(this.data) + is Error -> onFailure(this) } - suspend fun map(transform: suspend (T) -> R): ApiResult { - return when (this) { - is Success -> Success(transform(this.data)) - is Error -> Error(this.code, this.message) - } + suspend fun map(transform: suspend (T) -> R): ApiResult = + when (this) { + is Success -> Success(transform(this.data)) + is Error -> Error(this.code, this.message) } - suspend fun flatMap(transform: suspend (T) -> ApiResult): ApiResult { - return when (this) { - is Success -> transform(this.data) - is Error -> Error(this.code, this.message) - } + suspend fun flatMap(transform: suspend (T) -> ApiResult): ApiResult = + when (this) { + is Success -> transform(this.data) + is Error -> Error(this.code, this.message) } } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/AuthMethod.kt b/app/src/main/java/org/grakovne/lissen/channel/common/AuthMethod.kt index 78f2ff2b..90c0efe8 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/AuthMethod.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/AuthMethod.kt @@ -1,6 +1,6 @@ package org.grakovne.lissen.channel.common enum class AuthMethod { - CREDENTIALS, - O_AUTH, + CREDENTIALS, + O_AUTH, } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt b/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt index 3752f83b..06a5d232 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt @@ -9,38 +9,39 @@ import retrofit2.Retrofit import java.util.concurrent.TimeUnit class BinaryApiClient( - host: String, - requestHeaders: List?, - token: String, + host: String, + requestHeaders: List?, + token: String, ) { + private val httpClient = + OkHttpClient + .Builder() + .withTrustedCertificates() + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.NONE + }, + ).addInterceptor { chain: Interceptor.Chain -> + val request = + chain + .request() + .newBuilder() + .header("Authorization", "Bearer $token") - private val httpClient = OkHttpClient - .Builder() - .withTrustedCertificates() - .addInterceptor( - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.NONE - }, - ) - .addInterceptor { chain: Interceptor.Chain -> - val request = chain - .request() - .newBuilder() - .header("Authorization", "Bearer $token") + requestHeaders + ?.filter { it.name.isNotEmpty() } + ?.filter { it.value.isNotEmpty() } + ?.forEach { request.header(it.name, it.value) } - requestHeaders - ?.filter { it.name.isNotEmpty() } - ?.filter { it.value.isNotEmpty() } - ?.forEach { request.header(it.name, it.value) } + chain.proceed(request.build()) + }.connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) + .build() - chain.proceed(request.build()) - } - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(90, TimeUnit.SECONDS) - .build() - - val retrofit: Retrofit = Retrofit.Builder() - .baseUrl(host) - .client(httpClient) - .build() + val retrofit: Retrofit = + Retrofit + .Builder() + .baseUrl(host) + .client(httpClient) + .build() } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelAuthService.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelAuthService.kt index 5476dba0..c0afe7a0 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelAuthService.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelAuthService.kt @@ -4,48 +4,44 @@ import org.grakovne.lissen.domain.UserAccount import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences abstract class ChannelAuthService( - private val preferences: LissenSharedPreferences, + private val preferences: LissenSharedPreferences, ) { + abstract suspend fun authorize( + host: String, + username: String, + password: String, + onSuccess: suspend (UserAccount) -> Unit, + ): ApiResult - abstract suspend fun authorize( - host: String, - username: String, - password: String, - onSuccess: suspend (UserAccount) -> Unit, - ): ApiResult + abstract suspend fun startOAuth( + host: String, + onSuccess: () -> Unit, + onFailure: (ApiError) -> Unit, + ) - abstract suspend fun startOAuth( - host: String, - onSuccess: () -> Unit, - onFailure: (ApiError) -> Unit, - ) + abstract suspend fun exchangeToken( + host: String, + code: String, + onSuccess: suspend (UserAccount) -> Unit, + onFailure: (String) -> Unit, + ) - abstract suspend fun exchangeToken( - host: String, - code: String, - onSuccess: suspend (UserAccount) -> Unit, - onFailure: (String) -> Unit, - ) + abstract suspend fun fetchAuthMethods(host: String): ApiResult> - abstract suspend fun fetchAuthMethods( - host: String, - ): ApiResult> + fun persistCredentials( + host: String, + username: String, + token: String, + ) { + preferences.saveHost(host) + preferences.saveUsername(username) + preferences.saveToken(token) + } - fun persistCredentials( - host: String, - username: String, - token: String, - ) { - preferences.saveHost(host) - preferences.saveUsername(username) - preferences.saveToken(token) - } - - fun examineError(raw: String): ApiError { - return when { - raw.contains("Invalid redirect_uri") -> ApiError.InvalidRedirectUri - raw.contains("invalid_host") -> ApiError.MissingCredentialsHost - else -> ApiError.OAuthFlowFailed - } + fun examineError(raw: String): ApiError = + when { + raw.contains("Invalid redirect_uri") -> ApiError.InvalidRedirectUri + raw.contains("invalid_host") -> ApiError.MissingCredentialsHost + else -> ApiError.OAuthFlowFailed } } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelCode.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelCode.kt index 7a77e213..35fc2e82 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelCode.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelCode.kt @@ -1,5 +1,5 @@ package org.grakovne.lissen.channel.common enum class ChannelCode { - AUDIOBOOKSHELF, + AUDIOBOOKSHELF, } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelFilteringConfiguration.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelFilteringConfiguration.kt index 0b15b224..5572ea12 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelFilteringConfiguration.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelFilteringConfiguration.kt @@ -6,6 +6,6 @@ import org.grakovne.lissen.common.LibraryOrderingOption @Keep data class ChannelFilteringConfiguration( - val orderingOptions: List, - val defaultOrdering: LibraryOrderingConfiguration, + val orderingOptions: List, + val defaultOrdering: LibraryOrderingConfiguration, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelModule.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelModule.kt index 21ca86d6..89a32fe8 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelModule.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelModule.kt @@ -12,15 +12,13 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ChannelModule { - - @OptIn(UnstableApi::class) - @Provides - @Singleton - fun getChannelProviders( - audiobookshelfChannelProvider: AudiobookshelfChannelProvider, - ): Map { - return mapOf( - audiobookshelfChannelProvider.getChannelCode() to audiobookshelfChannelProvider, - ) - } + @OptIn(UnstableApi::class) + @Provides + @Singleton + fun getChannelProviders( + audiobookshelfChannelProvider: AudiobookshelfChannelProvider, + ): Map = + mapOf( + audiobookshelfChannelProvider.getChannelCode() to audiobookshelfChannelProvider, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelProvider.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelProvider.kt index e1f1f22d..446c42f7 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ChannelProvider.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ChannelProvider.kt @@ -1,10 +1,9 @@ package org.grakovne.lissen.channel.common interface ChannelProvider { + fun provideMediaChannel(): MediaChannel - fun provideMediaChannel(): MediaChannel + fun provideChannelAuth(): ChannelAuthService - fun provideChannelAuth(): ChannelAuthService - - fun getChannelCode(): ChannelCode + fun getChannelCode(): ChannelCode } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/ConnectionInfo.kt b/app/src/main/java/org/grakovne/lissen/channel/common/ConnectionInfo.kt index 3d8de2a5..037b968b 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/ConnectionInfo.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/ConnectionInfo.kt @@ -4,7 +4,7 @@ import androidx.annotation.Keep @Keep data class ConnectionInfo( - val username: String, - val serverVersion: String?, - val buildNumber: String?, + val username: String, + val serverVersion: String?, + val buildNumber: String?, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/LibraryType.kt b/app/src/main/java/org/grakovne/lissen/channel/common/LibraryType.kt index 4ea705eb..c1901ccf 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/LibraryType.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/LibraryType.kt @@ -1,7 +1,7 @@ package org.grakovne.lissen.channel.common enum class LibraryType { - LIBRARY, - PODCAST, - UNKNOWN, + LIBRARY, + PODCAST, + UNKNOWN, } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt b/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt index 7a84e083..06e817ce 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt @@ -11,47 +11,44 @@ import org.grakovne.lissen.domain.RecentBook import java.io.InputStream interface MediaChannel { + fun getLibraryType(): LibraryType - fun getLibraryType(): LibraryType + fun provideFileUri( + libraryItemId: String, + fileId: String, + ): Uri - fun provideFileUri( - libraryItemId: String, - fileId: String, - ): Uri + suspend fun syncProgress( + sessionId: String, + progress: PlaybackProgress, + ): ApiResult - suspend fun syncProgress( - sessionId: String, - progress: PlaybackProgress, - ): ApiResult + suspend fun fetchBookCover(bookId: String): ApiResult - suspend fun fetchBookCover( - bookId: String, - ): ApiResult + suspend fun fetchBooks( + libraryId: String, + pageSize: Int, + pageNumber: Int, + ): ApiResult> - suspend fun fetchBooks( - libraryId: String, - pageSize: Int, - pageNumber: Int, - ): ApiResult> + suspend fun searchBooks( + libraryId: String, + query: String, + limit: Int, + ): ApiResult> - suspend fun searchBooks( - libraryId: String, - query: String, - limit: Int, - ): ApiResult> + suspend fun fetchLibraries(): ApiResult> - suspend fun fetchLibraries(): ApiResult> + suspend fun startPlayback( + bookId: String, + episodeId: String, + supportedMimeTypes: List, + deviceId: String, + ): ApiResult - suspend fun startPlayback( - bookId: String, - episodeId: String, - supportedMimeTypes: List, - deviceId: String, - ): ApiResult + suspend fun fetchConnectionInfo(): ApiResult - suspend fun fetchConnectionInfo(): ApiResult + suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult> - suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult> - - suspend fun fetchBook(bookId: String): ApiResult + suspend fun fetchBook(bookId: String): ApiResult } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/OAuthContextCache.kt b/app/src/main/java/org/grakovne/lissen/channel/common/OAuthContextCache.kt index 4471f7cb..5faa6ea7 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/OAuthContextCache.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/OAuthContextCache.kt @@ -4,30 +4,31 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class OAuthContextCache @Inject constructor() { - +class OAuthContextCache + @Inject + constructor() { private var pkce: Pkce = clearPkce() private var cookies: String = clearCookies() fun storePkce(pkce: Pkce) { - this.pkce = pkce + this.pkce = pkce } fun readPkce() = pkce fun clearPkce(): Pkce { - pkce = Pkce("", "", "") - return pkce + pkce = Pkce("", "", "") + return pkce } fun storeCookies(cookies: List) { - this.cookies = cookies.joinToString("; ") { it.substringBefore(";") } + this.cookies = cookies.joinToString("; ") { it.substringBefore(";") } } fun readCookies() = cookies fun clearCookies(): String { - cookies = "" - return cookies + cookies = "" + return cookies } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/Pkce.kt b/app/src/main/java/org/grakovne/lissen/channel/common/Pkce.kt index 6b59a58f..43842c5c 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/Pkce.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/Pkce.kt @@ -6,37 +6,38 @@ import java.security.MessageDigest import java.util.Base64 fun randomPkce(): Pkce { - val verifier = generateRandomHexString(42) - val challenge = base64UrlEncode(sha256(verifier)) - val state = generateRandomHexString(42) + val verifier = generateRandomHexString(42) + val challenge = base64UrlEncode(sha256(verifier)) + val state = generateRandomHexString(42) - return Pkce( - verifier = verifier, - challenge = challenge, - state = state, - ) + return Pkce( + verifier = verifier, + challenge = challenge, + state = state, + ) } private fun generateRandomHexString(byteCount: Int = 32): String { - val array = ByteArray(byteCount) - java.security.SecureRandom().nextBytes(array) + val array = ByteArray(byteCount) + java.security.SecureRandom().nextBytes(array) - return array.joinToString("") { "%02x".format(it) } + return array.joinToString("") { "%02x".format(it) } } private fun sha256(input: String): ByteArray { - val digest = MessageDigest.getInstance("SHA-256") - return digest.digest(input.toByteArray(StandardCharsets.US_ASCII)) + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest(input.toByteArray(StandardCharsets.US_ASCII)) } -private fun base64UrlEncode(bytes: ByteArray) = Base64 +private fun base64UrlEncode(bytes: ByteArray) = + Base64 .getUrlEncoder() .withoutPadding() .encodeToString(bytes) @Keep data class Pkce( - val verifier: String, - val challenge: String, - val state: String, + val verifier: String, + val challenge: String, + val state: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt b/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt index 1056e674..92dfa309 100644 --- a/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt +++ b/app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt @@ -2,4 +2,7 @@ package org.grakovne.lissen.channel.common import android.os.Build -val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.106 Mobile Safari/537.36" +val USER_AGENT = + "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; K) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/130.0.6723.106 Mobile Safari/537.36" diff --git a/app/src/main/java/org/grakovne/lissen/common/CertificateExtension.kt b/app/src/main/java/org/grakovne/lissen/common/CertificateExtension.kt index 4dfa974f..6a91d1e2 100644 --- a/app/src/main/java/org/grakovne/lissen/common/CertificateExtension.kt +++ b/app/src/main/java/org/grakovne/lissen/common/CertificateExtension.kt @@ -8,27 +8,26 @@ import javax.net.ssl.TrustManagerFactory.getInstance import javax.net.ssl.X509TrustManager private val systemTrustManager: X509TrustManager by lazy { - val keyStore = KeyStore.getInstance("AndroidCAStore") - keyStore.load(null) + val keyStore = KeyStore.getInstance("AndroidCAStore") + keyStore.load(null) - val trustManagerFactory = getInstance(TrustManagerFactory.getDefaultAlgorithm()) - trustManagerFactory.init(keyStore) + val trustManagerFactory = getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) - trustManagerFactory - .trustManagers - .first { it is X509TrustManager } as X509TrustManager + trustManagerFactory + .trustManagers + .first { it is X509TrustManager } as X509TrustManager } private val systemSSLContext: SSLContext by lazy { - SSLContext.getInstance("TLS").apply { - init(null, arrayOf(systemTrustManager), null) - } + SSLContext.getInstance("TLS").apply { + init(null, arrayOf(systemTrustManager), null) + } } -fun OkHttpClient.Builder.withTrustedCertificates(): OkHttpClient.Builder { - return try { - sslSocketFactory(systemSSLContext.socketFactory, systemTrustManager) - } catch (ex: Exception) { - this - } -} +fun OkHttpClient.Builder.withTrustedCertificates(): OkHttpClient.Builder = + try { + sslSocketFactory(systemSSLContext.socketFactory, systemTrustManager) + } catch (ex: Exception) { + this + } diff --git a/app/src/main/java/org/grakovne/lissen/common/ColorScheme.kt b/app/src/main/java/org/grakovne/lissen/common/ColorScheme.kt index 0cd35506..a2ca2e7d 100644 --- a/app/src/main/java/org/grakovne/lissen/common/ColorScheme.kt +++ b/app/src/main/java/org/grakovne/lissen/common/ColorScheme.kt @@ -1,9 +1,9 @@ package org.grakovne.lissen.common enum class ColorScheme { - FOLLOW_SYSTEM, + FOLLOW_SYSTEM, - LIGHT, - DARK, - BLACK, + LIGHT, + DARK, + BLACK, } diff --git a/app/src/main/java/org/grakovne/lissen/common/HapticAction.kt b/app/src/main/java/org/grakovne/lissen/common/HapticAction.kt index 474f8687..eb4bfaba 100644 --- a/app/src/main/java/org/grakovne/lissen/common/HapticAction.kt +++ b/app/src/main/java/org/grakovne/lissen/common/HapticAction.kt @@ -4,9 +4,9 @@ import android.view.HapticFeedbackConstants import android.view.View fun hapticAction( - view: View, - action: () -> Unit, + view: View, + action: () -> Unit, ) { - action() - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + action() + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) } diff --git a/app/src/main/java/org/grakovne/lissen/common/HttpClient.kt b/app/src/main/java/org/grakovne/lissen/common/HttpClient.kt index 9525d715..c0334a5d 100644 --- a/app/src/main/java/org/grakovne/lissen/common/HttpClient.kt +++ b/app/src/main/java/org/grakovne/lissen/common/HttpClient.kt @@ -4,17 +4,15 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import java.util.concurrent.TimeUnit -fun createOkHttpClient(): OkHttpClient { - return OkHttpClient - .Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(90, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .withTrustedCertificates() - .addInterceptor( - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.NONE - }, - ) - .build() -} +fun createOkHttpClient(): OkHttpClient = + OkHttpClient + .Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(90, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .withTrustedCertificates() + .addInterceptor( + HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.NONE + }, + ).build() diff --git a/app/src/main/java/org/grakovne/lissen/common/ImageExtension.kt b/app/src/main/java/org/grakovne/lissen/common/ImageExtension.kt index 25dc74e1..dc156f50 100644 --- a/app/src/main/java/org/grakovne/lissen/common/ImageExtension.kt +++ b/app/src/main/java/org/grakovne/lissen/common/ImageExtension.kt @@ -4,11 +4,12 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Base64 -fun String.fromBase64(): Bitmap? = try { +fun String.fromBase64(): Bitmap? = + try { val bytes = Base64.decode(this, Base64.DEFAULT) BitmapFactory.decodeByteArray(bytes, 0, bytes.size) -} catch (ex: Exception) { + } catch (ex: Exception) { null -} + } fun ByteArray.toBase64(): String = Base64.encodeToString(this, Base64.DEFAULT) diff --git a/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingConfiguration.kt b/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingConfiguration.kt index 411da865..4d9db8da 100644 --- a/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingConfiguration.kt +++ b/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingConfiguration.kt @@ -5,27 +5,27 @@ import androidx.compose.runtime.saveable.Saver @Keep data class LibraryOrderingConfiguration( - val option: LibraryOrderingOption, - val direction: LibraryOrderingDirection, + val option: LibraryOrderingOption, + val direction: LibraryOrderingDirection, ) { + companion object { + val default = + LibraryOrderingConfiguration( + option = LibraryOrderingOption.TITLE, + direction = LibraryOrderingDirection.ASCENDING, + ) - companion object { - - val default = LibraryOrderingConfiguration( - option = LibraryOrderingOption.TITLE, - direction = LibraryOrderingDirection.ASCENDING, - ) - - val saver: Saver = Saver( - save = { - listOf(it.option.name, it.direction.name) - }, - restore = { - LibraryOrderingConfiguration( - option = LibraryOrderingOption.valueOf(it[0]), - direction = LibraryOrderingDirection.valueOf(it[1]), - ) - }, - ) - } + val saver: Saver = + Saver( + save = { + listOf(it.option.name, it.direction.name) + }, + restore = { + LibraryOrderingConfiguration( + option = LibraryOrderingOption.valueOf(it[0]), + direction = LibraryOrderingDirection.valueOf(it[1]), + ) + }, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingDirection.kt b/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingDirection.kt index d585816d..a92495e3 100644 --- a/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingDirection.kt +++ b/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingDirection.kt @@ -1,6 +1,6 @@ package org.grakovne.lissen.common enum class LibraryOrderingDirection { - ASCENDING, - DESCENDING, + ASCENDING, + DESCENDING, } diff --git a/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingOption.kt b/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingOption.kt index 269efabd..a5a3e198 100644 --- a/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingOption.kt +++ b/app/src/main/java/org/grakovne/lissen/common/LibraryOrderingOption.kt @@ -1,8 +1,8 @@ package org.grakovne.lissen.common enum class LibraryOrderingOption { - TITLE, - AUTHOR, - UPDATED_AT, - CREATED_AT, + TITLE, + AUTHOR, + UPDATED_AT, + CREATED_AT, } diff --git a/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt b/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt index 4c9fb505..188aadb6 100644 --- a/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt +++ b/app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt @@ -9,19 +9,21 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class NetworkQualityService @Inject constructor( +class NetworkQualityService + @Inject + constructor( @ApplicationContext private val context: Context, -) { - + ) { private val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager fun isNetworkAvailable(): Boolean { - val network = connectivityManager.activeNetwork ?: return false + val network = connectivityManager.activeNetwork ?: return false - val networkCapabilities = connectivityManager - .getNetworkCapabilities(network) - ?: return false + val networkCapabilities = + connectivityManager + .getNetworkCapabilities(network) + ?: return false - return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/common/RunningComponent.kt b/app/src/main/java/org/grakovne/lissen/common/RunningComponent.kt index 36a19643..487097d7 100644 --- a/app/src/main/java/org/grakovne/lissen/common/RunningComponent.kt +++ b/app/src/main/java/org/grakovne/lissen/common/RunningComponent.kt @@ -1,6 +1,5 @@ package org.grakovne.lissen.common interface RunningComponent { - - fun onCreate() + fun onCreate() } diff --git a/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt b/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt index 62cc8db8..14b192ca 100644 --- a/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt +++ b/app/src/main/java/org/grakovne/lissen/content/LissenMediaProvider.kt @@ -24,287 +24,288 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LissenMediaProvider @Inject constructor( +class LissenMediaProvider + @Inject + constructor( private val preferences: LissenSharedPreferences, private val channels: Map, private val localCacheRepository: LocalCacheRepository, -) { - + ) { fun provideFileUri( - libraryItemId: String, - chapterId: String, + libraryItemId: String, + chapterId: String, ): ApiResult { - Log.d(TAG, "Fetching File $libraryItemId and $chapterId URI") + Log.d(TAG, "Fetching File $libraryItemId and $chapterId URI") - return when (preferences.isForceCache()) { - true -> - localCacheRepository - .provideFileUri(libraryItemId, chapterId) - ?.let { ApiResult.Success(it) } - ?: ApiResult.Error(ApiError.InternalError) + return when (preferences.isForceCache()) { + true -> + localCacheRepository + .provideFileUri(libraryItemId, chapterId) + ?.let { ApiResult.Success(it) } + ?: ApiResult.Error(ApiError.InternalError) - false -> - localCacheRepository - .provideFileUri(libraryItemId, chapterId) - ?.let { ApiResult.Success(it) } - ?: providePreferredChannel() - .provideFileUri(libraryItemId, chapterId) - .let { ApiResult.Success(it) } - } + false -> + localCacheRepository + .provideFileUri(libraryItemId, chapterId) + ?.let { ApiResult.Success(it) } + ?: providePreferredChannel() + .provideFileUri(libraryItemId, chapterId) + .let { ApiResult.Success(it) } + } } suspend fun syncProgress( - sessionId: String, - bookId: String, - progress: PlaybackProgress, + sessionId: String, + bookId: String, + progress: PlaybackProgress, ): ApiResult { - Log.d(TAG, "Syncing Progress for $bookId. $progress") + Log.d(TAG, "Syncing Progress for $bookId. $progress") - providePreferredChannel().syncProgress(sessionId, progress) - localCacheRepository.syncProgress(bookId, progress) + providePreferredChannel().syncProgress(sessionId, progress) + localCacheRepository.syncProgress(bookId, progress) - return ApiResult.Success(Unit) + return ApiResult.Success(Unit) } - suspend fun fetchBookCover( - bookId: String, - ): ApiResult { - Log.d(TAG, "Fetching Cover stream for $bookId") + suspend fun fetchBookCover(bookId: String): ApiResult { + Log.d(TAG, "Fetching Cover stream for $bookId") - return when (preferences.isForceCache()) { - true -> localCacheRepository.fetchBookCover(bookId) - false -> providePreferredChannel().fetchBookCover(bookId) - } + return when (preferences.isForceCache()) { + true -> localCacheRepository.fetchBookCover(bookId) + false -> providePreferredChannel().fetchBookCover(bookId) + } } suspend fun searchBooks( - libraryId: String, - query: String, - limit: Int, + libraryId: String, + query: String, + limit: Int, ): ApiResult> { - Log.d(TAG, "Searching books with query $query of library: $libraryId") + Log.d(TAG, "Searching books with query $query of library: $libraryId") - return when (preferences.isForceCache()) { - true -> localCacheRepository.searchBooks(query) - false -> providePreferredChannel() - .searchBooks( - libraryId = libraryId, - query = query, - limit = limit, - ) - } + return when (preferences.isForceCache()) { + true -> localCacheRepository.searchBooks(query) + false -> + providePreferredChannel() + .searchBooks( + libraryId = libraryId, + query = query, + limit = limit, + ) + } } suspend fun fetchBooks( - libraryId: String, - pageSize: Int, - pageNumber: Int, + libraryId: String, + pageSize: Int, + pageNumber: Int, ): ApiResult> { - Log.d(TAG, "Fetching page $pageNumber of library: $libraryId") + Log.d(TAG, "Fetching page $pageNumber of library: $libraryId") - return when (preferences.isForceCache()) { - true -> localCacheRepository.fetchBooks(pageSize, pageNumber) - false -> providePreferredChannel().fetchBooks(libraryId, pageSize, pageNumber) - } + return when (preferences.isForceCache()) { + true -> localCacheRepository.fetchBooks(pageSize, pageNumber) + false -> providePreferredChannel().fetchBooks(libraryId, pageSize, pageNumber) + } } suspend fun fetchLibraries(): ApiResult> { - Log.d(TAG, "Fetching List of libraries") + Log.d(TAG, "Fetching List of libraries") - return when (preferences.isForceCache()) { - true -> localCacheRepository.fetchLibraries() - false -> providePreferredChannel() - .fetchLibraries() - .also { - it.foldAsync( - onSuccess = { libraries -> localCacheRepository.updateLibraries(libraries) }, - onFailure = {}, - ) - } - } + return when (preferences.isForceCache()) { + true -> localCacheRepository.fetchLibraries() + false -> + providePreferredChannel() + .fetchLibraries() + .also { + it.foldAsync( + onSuccess = { libraries -> localCacheRepository.updateLibraries(libraries) }, + onFailure = {}, + ) + } + } } suspend fun startPlayback( - bookId: String, - chapterId: String, - supportedMimeTypes: List, - deviceId: String, + bookId: String, + chapterId: String, + supportedMimeTypes: List, + deviceId: String, ): ApiResult { - Log.d(TAG, "Starting Playback for $bookId. $supportedMimeTypes are supported") + Log.d(TAG, "Starting Playback for $bookId. $supportedMimeTypes are supported") - return providePreferredChannel().startPlayback( - bookId = bookId, - episodeId = chapterId, - supportedMimeTypes = supportedMimeTypes, - deviceId = deviceId, - ) + return providePreferredChannel().startPlayback( + bookId = bookId, + episodeId = chapterId, + supportedMimeTypes = supportedMimeTypes, + deviceId = deviceId, + ) } - suspend fun fetchRecentListenedBooks( - libraryId: String, - ): ApiResult> { - Log.d(TAG, "Fetching Recent books of library $libraryId") + suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult> { + Log.d(TAG, "Fetching Recent books of library $libraryId") - return when (preferences.isForceCache()) { - true -> localCacheRepository.fetchRecentListenedBooks() - false -> providePreferredChannel() - .fetchRecentListenedBooks(libraryId) - .map { items -> syncFromLocalProgress(items) } - } + return when (preferences.isForceCache()) { + true -> localCacheRepository.fetchRecentListenedBooks() + false -> + providePreferredChannel() + .fetchRecentListenedBooks(libraryId) + .map { items -> syncFromLocalProgress(items) } + } } - suspend fun fetchBook( - bookId: String, - ): ApiResult { - Log.d(TAG, "Fetching Detailed book info for $bookId") + suspend fun fetchBook(bookId: String): ApiResult { + Log.d(TAG, "Fetching Detailed book info for $bookId") - return when (preferences.isForceCache()) { - true -> - localCacheRepository - .fetchBook(bookId) - ?.let { ApiResult.Success(it) } - ?: ApiResult.Error(ApiError.InternalError) + return when (preferences.isForceCache()) { + true -> + localCacheRepository + .fetchBook(bookId) + ?.let { ApiResult.Success(it) } + ?: ApiResult.Error(ApiError.InternalError) - false -> providePreferredChannel() - .fetchBook(bookId) - .map { syncFromLocalProgress(it) } - } + false -> + providePreferredChannel() + .fetchBook(bookId) + .map { syncFromLocalProgress(it) } + } } suspend fun authorize( - host: String, - username: String, - password: String, + host: String, + username: String, + password: String, ): ApiResult { - Log.d(TAG, "Authorizing for $username@$host") - return provideAuthService().authorize(host, username, password) { onPostLogin(host, it) } + Log.d(TAG, "Authorizing for $username@$host") + return provideAuthService().authorize(host, username, password) { onPostLogin(host, it) } } suspend fun startOAuth( - host: String, - onSuccess: () -> Unit, - onFailure: (ApiError) -> Unit, + host: String, + onSuccess: () -> Unit, + onFailure: (ApiError) -> Unit, ) { - Log.d(TAG, "Starting OAuth for $host") + Log.d(TAG, "Starting OAuth for $host") - return provideAuthService() - .startOAuth( - host = host, - onSuccess = onSuccess, - onFailure = { onFailure(it) }, - ) + return provideAuthService() + .startOAuth( + host = host, + onSuccess = onSuccess, + onFailure = { onFailure(it) }, + ) } suspend fun onPostLogin( - host: String, - account: UserAccount, + host: String, + account: UserAccount, ) { - provideAuthService() - .persistCredentials( - host = host, - username = account.username, - token = account.token, - ) + provideAuthService() + .persistCredentials( + host = host, + username = account.username, + token = account.token, + ) - fetchLibraries() - .fold( - onSuccess = { - val preferredLibrary = it - .find { item -> item.id == account.preferredLibraryId } - ?: it.firstOrNull() + fetchLibraries() + .fold( + onSuccess = { + val preferredLibrary = + it + .find { item -> item.id == account.preferredLibraryId } + ?: it.firstOrNull() - preferredLibrary - ?.let { library -> - preferences.savePreferredLibrary( - Library( - id = library.id, - title = library.title, - type = library.type, - ), - ) - } - }, - onFailure = { - account - .preferredLibraryId - ?.let { library -> - Library( - id = library, - title = "Default Library", - type = LibraryType.LIBRARY, - ) - } - ?.let { preferences.savePreferredLibrary(it) } - }, - ) + preferredLibrary + ?.let { library -> + preferences.savePreferredLibrary( + Library( + id = library.id, + title = library.title, + type = library.type, + ), + ) + } + }, + onFailure = { + account + .preferredLibraryId + ?.let { library -> + Library( + id = library, + title = "Default Library", + type = LibraryType.LIBRARY, + ) + }?.let { preferences.savePreferredLibrary(it) } + }, + ) } - private suspend fun syncFromLocalProgress( - detailedItems: List, - ): List { - val localRecentlyBooks = localCacheRepository - .fetchRecentListenedBooks() - .fold( - onSuccess = { it }, - onFailure = { return@fold detailedItems }, - ) + private suspend fun syncFromLocalProgress(detailedItems: List): List { + val localRecentlyBooks = + localCacheRepository + .fetchRecentListenedBooks() + .fold( + onSuccess = { it }, + onFailure = { return@fold detailedItems }, + ) - val syncedRecentlyBooks = detailedItems - .mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } } - .map { (remote, local) -> - val localTimestamp = local.listenedLastUpdate ?: return@map remote - val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote + val syncedRecentlyBooks = + detailedItems + .mapNotNull { item -> localRecentlyBooks.find { it.id == item.id }?.let { item to it } } + .map { (remote, local) -> + val localTimestamp = local.listenedLastUpdate ?: return@map remote + val remoteTimestamp = remote.listenedLastUpdate ?: return@map remote - when (remoteTimestamp > localTimestamp) { - true -> remote - false -> local - } + when (remoteTimestamp > localTimestamp) { + true -> remote + false -> local } + } - return detailedItems - .map { item -> - syncedRecentlyBooks - .find { item.id == it.id } - ?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) } - ?: item - } + return detailedItems + .map { item -> + syncedRecentlyBooks + .find { item.id == it.id } + ?.let { local -> item.copy(listenedPercentage = local.listenedPercentage) } + ?: item + } } private suspend fun syncFromLocalProgress(detailedItem: DetailedItem): DetailedItem { - val cachedBook = localCacheRepository.fetchBook(detailedItem.id) ?: return detailedItem + val cachedBook = localCacheRepository.fetchBook(detailedItem.id) ?: return detailedItem - val cachedProgress = cachedBook.progress ?: return detailedItem - val channelProgress = detailedItem.progress + val cachedProgress = cachedBook.progress ?: return detailedItem + val channelProgress = detailedItem.progress - val updatedProgress = listOfNotNull(cachedProgress, channelProgress) - .maxByOrNull { it.lastUpdate } - ?: return detailedItem + val updatedProgress = + listOfNotNull(cachedProgress, channelProgress) + .maxByOrNull { it.lastUpdate } + ?: return detailedItem - Log.d( - TAG, - """ - Merging local playback progress into channel-fetched: - Channel Progress: $channelProgress - Cached Progress: $cachedProgress - Final Progress: $updatedProgress - """.trimIndent(), - ) + Log.d( + TAG, + """ + Merging local playback progress into channel-fetched: + Channel Progress: $channelProgress + Cached Progress: $cachedProgress + Final Progress: $updatedProgress + """.trimIndent(), + ) - return detailedItem.copy(progress = updatedProgress) + return detailedItem.copy(progress = updatedProgress) } suspend fun fetchConnectionInfo() = providePreferredChannel().fetchConnectionInfo() - fun provideAuthService(): ChannelAuthService = channels[preferences.getChannel()] + fun provideAuthService(): ChannelAuthService = + channels[preferences.getChannel()] ?.provideChannelAuth() ?: throw IllegalStateException("Selected auth service has been requested but not selected") - fun providePreferredChannel(): MediaChannel = channels[preferences.getChannel()] + fun providePreferredChannel(): MediaChannel = + channels[preferences.getChannel()] ?.provideMediaChannel() ?: throw IllegalStateException("Selected auth service has been requested but not selected") companion object { - - private const val TAG: String = "LissenMediaProvider" + private const val TAG: String = "LissenMediaProvider" } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/CacheBookStorageProperties.kt b/app/src/main/java/org/grakovne/lissen/content/cache/CacheBookStorageProperties.kt index 7400d47a..6dac0f90 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/CacheBookStorageProperties.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/CacheBookStorageProperties.kt @@ -7,29 +7,33 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CacheBookStorageProperties @Inject constructor( +class CacheBookStorageProperties + @Inject + constructor( @ApplicationContext private val context: Context, -) { - - fun provideBookCache(bookId: String) = context + ) { + fun provideBookCache(bookId: String) = + context .getExternalFilesDir(MEDIA_CACHE_FOLDER) ?.resolve(bookId) - fun provideMediaCachePatch(bookId: String, fileId: String) = context + fun provideMediaCachePatch( + bookId: String, + fileId: String, + ) = context + .getExternalFilesDir(MEDIA_CACHE_FOLDER) + ?.resolve(bookId) + ?.resolve(fileId) + ?: throw IllegalStateException("") + + fun provideBookCoverPath(bookId: String): File = + context .getExternalFilesDir(MEDIA_CACHE_FOLDER) ?.resolve(bookId) - ?.resolve(fileId) + ?.resolve("cover.img") ?: throw IllegalStateException("") - fun provideBookCoverPath(bookId: String): File { - return context - .getExternalFilesDir(MEDIA_CACHE_FOLDER) - ?.resolve(bookId) - ?.resolve("cover.img") - ?: throw IllegalStateException("") - } - companion object { - const val MEDIA_CACHE_FOLDER = "media_cache" + const val MEDIA_CACHE_FOLDER = "media_cache" } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/CacheState.kt b/app/src/main/java/org/grakovne/lissen/content/cache/CacheState.kt index f7603704..4f3801c4 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/CacheState.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/CacheState.kt @@ -5,6 +5,6 @@ import org.grakovne.lissen.domain.CacheStatus @Keep data class CacheState( - val status: CacheStatus, - val progress: Double = 0.0, + val status: CacheStatus, + val progress: Double = 0.0, ) diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/CalculateRequestedChapters.kt b/app/src/main/java/org/grakovne/lissen/content/cache/CalculateRequestedChapters.kt index 5db0c3f8..68b8b994 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/CalculateRequestedChapters.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/CalculateRequestedChapters.kt @@ -9,18 +9,19 @@ import org.grakovne.lissen.domain.PlayingChapter import org.grakovne.lissen.playback.service.calculateChapterIndex fun calculateRequestedChapters( - book: DetailedItem, - option: DownloadOption, - currentTotalPosition: Double, + book: DetailedItem, + option: DownloadOption, + currentTotalPosition: Double, ): List { - val chapterIndex = calculateChapterIndex(book, currentTotalPosition) + val chapterIndex = calculateChapterIndex(book, currentTotalPosition) - return when (option) { - AllItemsDownloadOption -> book.chapters - CurrentItemDownloadOption -> listOfNotNull(book.chapters.getOrNull(chapterIndex)) - is NumberItemDownloadOption -> book.chapters.subList( - chapterIndex.coerceAtLeast(0), - (chapterIndex + option.itemsNumber).coerceIn(chapterIndex..book.chapters.size), - ) - } + return when (option) { + AllItemsDownloadOption -> book.chapters + CurrentItemDownloadOption -> listOfNotNull(book.chapters.getOrNull(chapterIndex)) + is NumberItemDownloadOption -> + book.chapters.subList( + chapterIndex.coerceAtLeast(0), + (chapterIndex + option.itemsNumber).coerceIn(chapterIndex..book.chapters.size), + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingExecutor.kt b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingExecutor.kt index fdf2f59f..bd5d4c99 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingExecutor.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingExecutor.kt @@ -6,21 +6,17 @@ import org.grakovne.lissen.domain.DetailedItem import org.grakovne.lissen.domain.DownloadOption class ContentCachingExecutor( - private val item: DetailedItem, - private val options: DownloadOption, - private val position: Double, - private val contentCachingManager: ContentCachingManager, + private val item: DetailedItem, + private val options: DownloadOption, + private val position: Double, + private val contentCachingManager: ContentCachingManager, ) { - - fun run( - channel: MediaChannel, - ): Flow { - return contentCachingManager - .cacheMediaItem( - mediaItem = item, - option = options, - channel = channel, - currentTotalPosition = position, - ) - } + fun run(channel: MediaChannel): Flow = + contentCachingManager + .cacheMediaItem( + mediaItem = item, + option = options, + channel = channel, + currentTotalPosition = position, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingManager.kt b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingManager.kt index 49a7e9bf..c0040588 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingManager.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingManager.kt @@ -20,162 +20,172 @@ import javax.inject.Singleton import kotlin.coroutines.coroutineContext @Singleton -class ContentCachingManager @Inject constructor( +class ContentCachingManager + @Inject + constructor( private val bookRepository: CachedBookRepository, private val libraryRepository: CachedLibraryRepository, private val properties: CacheBookStorageProperties, private val requestHeadersProvider: RequestHeadersProvider, -) { - + ) { fun cacheMediaItem( - mediaItem: DetailedItem, - option: DownloadOption, - channel: MediaChannel, - currentTotalPosition: Double, + mediaItem: DetailedItem, + option: DownloadOption, + channel: MediaChannel, + currentTotalPosition: Double, ) = flow { - val context = coroutineContext - emit(CacheState(CacheStatus.Caching)) + val context = coroutineContext + emit(CacheState(CacheStatus.Caching)) - val requestedChapters = calculateRequestedChapters( - book = mediaItem, - option = option, - currentTotalPosition = currentTotalPosition, + val requestedChapters = + calculateRequestedChapters( + book = mediaItem, + option = option, + currentTotalPosition = currentTotalPosition, ) - val requestedFiles = findRequestedFiles(mediaItem, requestedChapters) + val requestedFiles = findRequestedFiles(mediaItem, requestedChapters) - val mediaCachingResult = cacheBookMedia( - mediaItem.id, - requestedFiles, - channel, + val mediaCachingResult = + cacheBookMedia( + mediaItem.id, + requestedFiles, + channel, ) { withContext(context) { emit(CacheState(CacheStatus.Caching, it)) } } - val coverCachingResult = cacheBookCover(mediaItem, channel) - val librariesCachingResult = cacheLibraries(channel) + val coverCachingResult = cacheBookCover(mediaItem, channel) + val librariesCachingResult = cacheLibraries(channel) - when { - listOf( - mediaCachingResult, - coverCachingResult, - librariesCachingResult, - ) - .all { it.status == CacheStatus.Completed } -> { - cacheBookInfo(mediaItem, requestedChapters) - emit(CacheState(CacheStatus.Completed)) - } - - else -> emit(CacheState(CacheStatus.Error)) + when { + listOf( + mediaCachingResult, + coverCachingResult, + librariesCachingResult, + ).all { it.status == CacheStatus.Completed } -> { + cacheBookInfo(mediaItem, requestedChapters) + emit(CacheState(CacheStatus.Completed)) } + + else -> emit(CacheState(CacheStatus.Error)) + } } suspend fun dropCache(itemId: String) { - bookRepository.removeBook(itemId) + bookRepository.removeBook(itemId) - val cachedContent = properties - .provideBookCache(itemId) - ?: return + val cachedContent = + properties + .provideBookCache(itemId) + ?: return - if (cachedContent.exists()) { - cachedContent.deleteRecursively() - } + if (cachedContent.exists()) { + cachedContent.deleteRecursively() + } } fun hasMetadataCached(mediaItemId: String) = bookRepository.provideCacheState(mediaItemId) private suspend fun cacheBookMedia( - bookId: String, - files: List, - channel: MediaChannel, - onProgress: suspend (Double) -> Unit, - ): CacheState = withContext(Dispatchers.IO) { + bookId: String, + files: List, + channel: MediaChannel, + onProgress: suspend (Double) -> Unit, + ): CacheState = + withContext(Dispatchers.IO) { val headers = requestHeadersProvider.fetchRequestHeaders() val client = createOkHttpClient() files.mapIndexed { index, file -> - val uri = channel.provideFileUri(bookId, file.id) - val requestBuilder = Request.Builder().url(uri.toString()) - headers.forEach { requestBuilder.addHeader(it.name, it.value) } + val uri = channel.provideFileUri(bookId, file.id) + val requestBuilder = Request.Builder().url(uri.toString()) + headers.forEach { requestBuilder.addHeader(it.name, it.value) } - val request = requestBuilder.build() - val response = client.newCall(request).execute() + val request = requestBuilder.build() + val response = client.newCall(request).execute() - if (!response.isSuccessful) { - Log.e(TAG, "Unable to cache media content: $response") - return@withContext CacheState(CacheStatus.Error) + if (!response.isSuccessful) { + Log.e(TAG, "Unable to cache media content: $response") + return@withContext CacheState(CacheStatus.Error) + } + + val body = response.body ?: return@withContext CacheState(CacheStatus.Error) + val dest = properties.provideMediaCachePatch(bookId, file.id) + dest.parentFile?.mkdirs() + + try { + dest.outputStream().use { output -> + body.byteStream().use { input -> + input.copyTo(output) + } } + } catch (ex: Exception) { + return@withContext CacheState(CacheStatus.Error) + } - val body = response.body ?: return@withContext CacheState(CacheStatus.Error) - val dest = properties.provideMediaCachePatch(bookId, file.id) - dest.parentFile?.mkdirs() - - try { - dest.outputStream().use { output -> - body.byteStream().use { input -> - input.copyTo(output) - } - } - } catch (ex: Exception) { - return@withContext CacheState(CacheStatus.Error) - } - - onProgress(files.size.takeIf { it != 0 }?.let { index / it.toDouble() } ?: 0.0) + onProgress(files.size.takeIf { it != 0 }?.let { index / it.toDouble() } ?: 0.0) } CacheState(CacheStatus.Completed) - } + } - private suspend fun cacheBookCover(book: DetailedItem, channel: MediaChannel): CacheState { - val file = properties.provideBookCoverPath(book.id) + private suspend fun cacheBookCover( + book: DetailedItem, + channel: MediaChannel, + ): CacheState { + val file = properties.provideBookCoverPath(book.id) - return withContext(Dispatchers.IO) { - channel - .fetchBookCover(book.id) - .fold( - onSuccess = { inputStream -> - if (!file.exists()) { - file.parentFile?.mkdirs() - file.createNewFile() - } + return withContext(Dispatchers.IO) { + channel + .fetchBookCover(book.id) + .fold( + onSuccess = { inputStream -> + if (!file.exists()) { + file.parentFile?.mkdirs() + file.createNewFile() + } - file.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - }, - onFailure = { - }, - ) + file.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + }, + onFailure = { + }, + ) - CacheState(CacheStatus.Completed) - } + CacheState(CacheStatus.Completed) + } } private suspend fun cacheBookInfo( - book: DetailedItem, - fetchedChapters: List, - ): CacheState = bookRepository + book: DetailedItem, + fetchedChapters: List, + ): CacheState = + bookRepository .cacheBook(book, fetchedChapters) .let { CacheState(CacheStatus.Completed) } - private suspend fun cacheLibraries(channel: MediaChannel): CacheState = channel + private suspend fun cacheLibraries(channel: MediaChannel): CacheState = + channel .fetchLibraries() .foldAsync( - onSuccess = { - libraryRepository.cacheLibraries(it) - CacheState(CacheStatus.Completed) - }, - onFailure = { - CacheState(CacheStatus.Error) - }, + onSuccess = { + libraryRepository.cacheLibraries(it) + CacheState(CacheStatus.Completed) + }, + onFailure = { + CacheState(CacheStatus.Error) + }, ) private fun findRequestedFiles( - book: DetailedItem, - requestedChapters: List, - ): List = requestedChapters + book: DetailedItem, + requestedChapters: List, + ): List = + requestedChapters .flatMap { findRelatedFiles(it, book.files) } .distinctBy { it.id } companion object { - private const val TAG = "ContentCachingManager" + private const val TAG = "ContentCachingManager" } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingNotificationService.kt b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingNotificationService.kt index 84c38fdf..0da25518 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingNotificationService.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingNotificationService.kt @@ -13,47 +13,49 @@ import javax.inject.Singleton import kotlin.math.roundToInt @Singleton -class ContentCachingNotificationService @Inject constructor( +class ContentCachingNotificationService + @Inject + constructor( @ApplicationContext private val context: Context, -) { - + ) { private val service = context.getSystemService(NotificationManager::class.java) - fun cancel() = context + fun cancel() = + context .getSystemService(NotificationManager::class.java) .cancel(NOTIFICATION_ID) - fun updateCachingNotification( - items: List>, - ): Notification { - val cachingItems = items - .filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed).contains(state.status) } + fun updateCachingNotification(items: List>): Notification { + val cachingItems = + items + .filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed).contains(state.status) } - val itemProgress = cachingItems.sumOf { (_, state) -> - when (state.status) { - CacheStatus.Caching -> state.progress - CacheStatus.Completed -> 1.0 - else -> 0.0 - } + val itemProgress = + cachingItems.sumOf { (_, state) -> + when (state.status) { + CacheStatus.Caching -> state.progress + CacheStatus.Completed -> 1.0 + else -> 0.0 + } } - return Notification - .Builder(context, createNotificationChannel()) - .setContentText(items.provideCachingTitles()) - .setContentTitle(context.getString(R.string.notification_content_caching_title)) - .setSmallIcon(R.drawable.ic_downloading) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setProgress( - cachingItems.size * 100, - (itemProgress * 100).roundToInt(), - cachingItems.isEmpty(), - ) - .build() - .also { service.notify(NOTIFICATION_ID, it) } + return Notification + .Builder(context, createNotificationChannel()) + .setContentText(items.provideCachingTitles()) + .setContentTitle(context.getString(R.string.notification_content_caching_title)) + .setSmallIcon(R.drawable.ic_downloading) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setProgress( + cachingItems.size * 100, + (itemProgress * 100).roundToInt(), + cachingItems.isEmpty(), + ).build() + .also { service.notify(NOTIFICATION_ID, it) } } - fun updateErrorNotification(): Notification = Notification + fun updateErrorNotification(): Notification = + Notification .Builder(context, createNotificationChannel()) .setContentTitle(context.getString(R.string.notification_content_caching_error_title)) .setContentText(context.getString(R.string.notification_content_caching_error_description)) @@ -62,24 +64,25 @@ class ContentCachingNotificationService @Inject constructor( .also { service.notify(NOTIFICATION_ID, it) } private fun createNotificationChannel(): String { - val channelId = "caching_channel" + val channelId = "caching_channel" - val channel = NotificationChannel( - channelId, - context.getString(R.string.notification_content_caching_channel), - NotificationManager.IMPORTANCE_DEFAULT, + val channel = + NotificationChannel( + channelId, + context.getString(R.string.notification_content_caching_channel), + NotificationManager.IMPORTANCE_DEFAULT, ) - service.createNotificationChannel(channel) - return channelId + service.createNotificationChannel(channel) + return channelId } companion object { + private fun List>.provideCachingTitles() = + this + .filter { (_, state) -> CacheStatus.Caching == state.status } + .joinToString(", ") { (key, _) -> key.title } - private fun List>.provideCachingTitles() = this - .filter { (_, state) -> CacheStatus.Caching == state.status } - .joinToString(", ") { (key, _) -> key.title } - - const val NOTIFICATION_ID = 2042025 + const val NOTIFICATION_ID = 2042025 } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingProgress.kt b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingProgress.kt index 21446215..9f87eb9d 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingProgress.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingProgress.kt @@ -7,11 +7,16 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ContentCachingProgress @Inject constructor() { +class ContentCachingProgress + @Inject + constructor() { private val _statusFlow = MutableSharedFlow>(replay = 1) val statusFlow = _statusFlow.asSharedFlow() - suspend fun emit(item: DetailedItem, progress: CacheState) { - _statusFlow.emit(item to progress) + suspend fun emit( + item: DetailedItem, + progress: CacheState, + ) { + _statusFlow.emit(item to progress) } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingService.kt b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingService.kt index 386254fd..e3926087 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingService.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingService.kt @@ -17,117 +17,116 @@ import javax.inject.Inject @AndroidEntryPoint class ContentCachingService : LifecycleService() { + @Inject + lateinit var contentCachingManager: ContentCachingManager - @Inject - lateinit var contentCachingManager: ContentCachingManager + @Inject + lateinit var mediaProvider: LissenMediaProvider - @Inject - lateinit var mediaProvider: LissenMediaProvider + @Inject + lateinit var cacheProgressBus: ContentCachingProgress - @Inject - lateinit var cacheProgressBus: ContentCachingProgress + @Inject + lateinit var notificationService: ContentCachingNotificationService - @Inject - lateinit var notificationService: ContentCachingNotificationService + private val executionStatuses = mutableMapOf() - private val executionStatuses = mutableMapOf() - - @Suppress("DEPRECATION") - override fun onStartCommand( - intent: Intent?, - flags: Int, - startId: Int, - ): Int { - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { - startForeground( - NOTIFICATION_ID, - notificationService.updateCachingNotification(emptyList()), - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) - } - else -> { - startForeground( - NOTIFICATION_ID, - notificationService.updateCachingNotification(emptyList()), - ) - } - } - - val task = intent - ?.getSerializableExtra(CACHING_TASK_EXTRA) - as? ContentCachingTask - ?: return START_STICKY - - lifecycleScope.launch { - val item = mediaProvider - .providePreferredChannel() - .fetchBook(task.itemId) - .fold( - onSuccess = { it }, - onFailure = { - notificationService.updateErrorNotification() - null - }, - ) - ?: return@launch - - val executor = ContentCachingExecutor( - item = item, - options = task.options, - position = task.currentPosition, - contentCachingManager = contentCachingManager, - ) - - executor - .run(mediaProvider.providePreferredChannel()) - .collect { progress -> - executionStatuses[item] = progress - cacheProgressBus.emit(item, progress) - - Log.d(TAG, "Caching progress updated: $progress") - - when (inProgress()) { - true -> - executionStatuses - .entries - .map { (item, status) -> item to status } - .let { notificationService.updateCachingNotification(it) } - - false -> finish() - } - } - } - - return super.onStartCommand(intent, flags, startId) + @Suppress("DEPRECATION") + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + startForeground( + NOTIFICATION_ID, + notificationService.updateCachingNotification(emptyList()), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + else -> { + startForeground( + NOTIFICATION_ID, + notificationService.updateCachingNotification(emptyList()), + ) + } } - private fun inProgress(): Boolean = - executionStatuses.values.any { it.status == CacheStatus.Caching } + val task = + intent + ?.getSerializableExtra(CACHING_TASK_EXTRA) + as? ContentCachingTask + ?: return START_STICKY - private fun hasErrors(): Boolean = - executionStatuses.values.any { it.status == CacheStatus.Error } + lifecycleScope.launch { + val item = + mediaProvider + .providePreferredChannel() + .fetchBook(task.itemId) + .fold( + onSuccess = { it }, + onFailure = { + notificationService.updateErrorNotification() + null + }, + ) + ?: return@launch - private fun finish() { - when (hasErrors()) { - true -> { - notificationService.updateErrorNotification() - stopForeground(STOP_FOREGROUND_DETACH) - } + val executor = + ContentCachingExecutor( + item = item, + options = task.options, + position = task.currentPosition, + contentCachingManager = contentCachingManager, + ) - false -> { - stopForeground(STOP_FOREGROUND_REMOVE) - notificationService.cancel() - } + executor + .run(mediaProvider.providePreferredChannel()) + .collect { progress -> + executionStatuses[item] = progress + cacheProgressBus.emit(item, progress) + + Log.d(TAG, "Caching progress updated: $progress") + + when (inProgress()) { + true -> + executionStatuses + .entries + .map { (item, status) -> item to status } + .let { notificationService.updateCachingNotification(it) } + + false -> finish() + } } - - stopSelf() - Log.d(TAG, "All tasks finished, stopping foreground service") } - companion object { + return super.onStartCommand(intent, flags, startId) + } - const val CACHING_TASK_EXTRA = "CACHING_TASK_EXTRA" - private const val TAG = "ContentCachingService" + private fun inProgress(): Boolean = executionStatuses.values.any { it.status == CacheStatus.Caching } + + private fun hasErrors(): Boolean = executionStatuses.values.any { it.status == CacheStatus.Error } + + private fun finish() { + when (hasErrors()) { + true -> { + notificationService.updateErrorNotification() + stopForeground(STOP_FOREGROUND_DETACH) + } + + false -> { + stopForeground(STOP_FOREGROUND_REMOVE) + notificationService.cancel() + } } + + stopSelf() + Log.d(TAG, "All tasks finished, stopping foreground service") + } + + companion object { + const val CACHING_TASK_EXTRA = "CACHING_TASK_EXTRA" + private const val TAG = "ContentCachingService" + } } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/FindRelatedFiles.kt b/app/src/main/java/org/grakovne/lissen/content/cache/FindRelatedFiles.kt index 4981ed7b..fc535fac 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/FindRelatedFiles.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/FindRelatedFiles.kt @@ -4,27 +4,28 @@ import org.grakovne.lissen.domain.BookFile import org.grakovne.lissen.domain.PlayingChapter fun findRelatedFiles( - chapter: PlayingChapter, - files: List, + chapter: PlayingChapter, + files: List, ): List { - val chapterStartRounded = chapter.start.round() - val chapterEndRounded = chapter.end.round() + val chapterStartRounded = chapter.start.round() + val chapterEndRounded = chapter.end.round() - val startTimes = files - .runningFold(0.0) { acc, file -> acc + file.duration } - .dropLast(1) + val startTimes = + files + .runningFold(0.0) { acc, file -> acc + file.duration } + .dropLast(1) - val fileStartTimes = files.zip(startTimes) + val fileStartTimes = files.zip(startTimes) - return fileStartTimes - .filter { (file, fileStartTime) -> - val fileStartTimeRounded = fileStartTime.round() - val fileEndTimeRounded = (fileStartTime + file.duration).round() + return fileStartTimes + .filter { (file, fileStartTime) -> + val fileStartTimeRounded = fileStartTime.round() + val fileEndTimeRounded = (fileStartTime + file.duration).round() - fileStartTimeRounded < chapterEndRounded && chapterStartRounded < fileEndTimeRounded - } - .map { it.first } + fileStartTimeRounded < chapterEndRounded && chapterStartRounded < fileEndTimeRounded + }.map { it.first } } private const val PRECISION = 0.01 + private fun Double.round(): Double = kotlin.math.round(this / PRECISION) * PRECISION diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheModule.kt b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheModule.kt index 887f7725..0dd99966 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheModule.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheModule.kt @@ -14,42 +14,42 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object LocalCacheModule { + private const val DATABASE_NAME = "lissen_local_cache_storage" - private const val DATABASE_NAME = "lissen_local_cache_storage" + @Provides + @Singleton + fun provideAppDatabase( + @ApplicationContext context: Context, + ): LocalCacheStorage { + val database = + Room.databaseBuilder( + context = context, + klass = LocalCacheStorage::class.java, + name = DATABASE_NAME, + ) - @Provides - @Singleton - fun provideAppDatabase(@ApplicationContext context: Context): LocalCacheStorage { - val database = Room.databaseBuilder( - context = context, - klass = LocalCacheStorage::class.java, - name = DATABASE_NAME, - ) + return database + .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_3_4) + .addMigrations(MIGRATION_4_5) + .addMigrations(MIGRATION_5_6) + .addMigrations(MIGRATION_6_7) + .addMigrations(MIGRATION_7_8) + .addMigrations(MIGRATION_8_9) + .addMigrations(MIGRATION_9_10) + .addMigrations(MIGRATION_10_11) + .addMigrations(MIGRATION_11_12) + .addMigrations(MIGRATION_12_13) + .addMigrations(MIGRATION_13_14) + .build() + } - return database - .addMigrations(MIGRATION_1_2) - .addMigrations(MIGRATION_2_3) - .addMigrations(MIGRATION_3_4) - .addMigrations(MIGRATION_4_5) - .addMigrations(MIGRATION_5_6) - .addMigrations(MIGRATION_6_7) - .addMigrations(MIGRATION_7_8) - .addMigrations(MIGRATION_8_9) - .addMigrations(MIGRATION_9_10) - .addMigrations(MIGRATION_10_11) - .addMigrations(MIGRATION_11_12) - .addMigrations(MIGRATION_12_13) - .addMigrations(MIGRATION_13_14) - .build() - } + @Provides + @Singleton + fun provideCachedBookDao(appDatabase: LocalCacheStorage): CachedBookDao = appDatabase.cachedBookDao() - @Provides - @Singleton - fun provideCachedBookDao(appDatabase: LocalCacheStorage): CachedBookDao = - appDatabase.cachedBookDao() - - @Provides - @Singleton - fun provideCachedLibraryDao(appDatabase: LocalCacheStorage): CachedLibraryDao = - appDatabase.cachedLibraryDao() + @Provides + @Singleton + fun provideCachedLibraryDao(appDatabase: LocalCacheStorage): CachedLibraryDao = appDatabase.cachedLibraryDao() } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt index b1ce798e..a04c25b2 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheRepository.kt @@ -19,72 +19,78 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LocalCacheRepository @Inject constructor( +class LocalCacheRepository + @Inject + constructor( private val cachedBookRepository: CachedBookRepository, private val cachedLibraryRepository: CachedLibraryRepository, -) { - - fun provideFileUri(libraryItemId: String, fileId: String): Uri? = - cachedBookRepository - .provideFileUri(libraryItemId, fileId) - .takeIf { it.toFile().exists() } + ) { + fun provideFileUri( + libraryItemId: String, + fileId: String, + ): Uri? = + cachedBookRepository + .provideFileUri(libraryItemId, fileId) + .takeIf { it.toFile().exists() } /** * For the local cache we avoiding to create intermediary entity like Session and using BookId * as a Playback Session Key */ suspend fun syncProgress( - bookId: String, - progress: PlaybackProgress, + bookId: String, + progress: PlaybackProgress, ): ApiResult { - cachedBookRepository.syncProgress(bookId, progress) - return ApiResult.Success(Unit) + cachedBookRepository.syncProgress(bookId, progress) + return ApiResult.Success(Unit) } fun fetchBookCover(bookId: String): ApiResult { - val cover = cachedBookRepository - .provideBookCover(bookId) + val cover = + cachedBookRepository + .provideBookCover(bookId) - return when (cover.exists()) { - true -> ApiResult.Success(cover.inputStream()) - false -> ApiResult.Error(ApiError.InternalError) - } + return when (cover.exists()) { + true -> ApiResult.Success(cover.inputStream()) + false -> ApiResult.Error(ApiError.InternalError) + } } - suspend fun searchBooks( - query: String, - ): ApiResult> = cachedBookRepository + suspend fun searchBooks(query: String): ApiResult> = + cachedBookRepository .searchBooks(query = query) .let { ApiResult.Success(it) } suspend fun fetchBooks( - pageSize: Int, - pageNumber: Int, + pageSize: Int, + pageNumber: Int, ): ApiResult> { - val books = cachedBookRepository - .fetchBooks(pageNumber = pageNumber, pageSize = pageSize) + val books = + cachedBookRepository + .fetchBooks(pageNumber = pageNumber, pageSize = pageSize) - return ApiResult - .Success( - PagedItems( - items = books, - currentPage = pageNumber, - ), - ) + return ApiResult + .Success( + PagedItems( + items = books, + currentPage = pageNumber, + ), + ) } - suspend fun fetchLibraries(): ApiResult> = cachedLibraryRepository + suspend fun fetchLibraries(): ApiResult> = + cachedLibraryRepository .fetchLibraries() .let { ApiResult.Success(it) } suspend fun updateLibraries(libraries: List) { - cachedLibraryRepository.cacheLibraries(libraries) + cachedLibraryRepository.cacheLibraries(libraries) } suspend fun fetchRecentListenedBooks(): ApiResult> = - cachedBookRepository - .fetchRecentBooks() - .let { ApiResult.Success(it) } + cachedBookRepository + .fetchRecentBooks() + .let { ApiResult.Success(it) } /** * Fetches a detailed book item by its ID from the cached repository. @@ -99,32 +105,36 @@ class LocalCacheRepository @Inject constructor( * or `null` if the book is not found in the cache. */ suspend fun fetchBook(bookId: String): DetailedItem? { - val cachedBook = cachedBookRepository - .fetchBook(bookId) - ?: return null + val cachedBook = + cachedBookRepository + .fetchBook(bookId) + ?: return null - val cachedPosition = cachedBook - .progress - ?.currentTime - ?: 0.0 + val cachedPosition = + cachedBook + .progress + ?.currentTime + ?: 0.0 - val currentChapter = calculateChapterIndex(cachedBook, cachedPosition) + val currentChapter = calculateChapterIndex(cachedBook, cachedPosition) - return when (currentChapter in cachedBook.chapters.indices && cachedBook.chapters[currentChapter].available) { - true -> cachedBook + return when (currentChapter in cachedBook.chapters.indices && cachedBook.chapters[currentChapter].available) { + true -> cachedBook - false -> - cachedBook - .copy( - progress = MediaProgress( - currentTime = cachedBook.chapters - .firstOrNull { it.available } - ?.start - ?: return null, - isFinished = false, - lastUpdate = 946728000000, // 2000-01-01T12:00 - ), - ) - } + false -> + cachedBook + .copy( + progress = + MediaProgress( + currentTime = + cachedBook.chapters + .firstOrNull { it.available } + ?.start + ?: return null, + isFinished = false, + lastUpdate = 946728000000, // 2000-01-01T12:00 + ), + ) + } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheStorage.kt b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheStorage.kt index 903fd66b..7efd4da2 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheStorage.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheStorage.kt @@ -11,19 +11,18 @@ import org.grakovne.lissen.content.cache.entity.CachedLibraryEntity import org.grakovne.lissen.content.cache.entity.MediaProgressEntity @Database( - entities = [ - BookEntity::class, - BookFileEntity::class, - BookChapterEntity::class, - MediaProgressEntity::class, - CachedLibraryEntity::class, - ], - version = 14, - exportSchema = true, + entities = [ + BookEntity::class, + BookFileEntity::class, + BookChapterEntity::class, + MediaProgressEntity::class, + CachedLibraryEntity::class, + ], + version = 14, + exportSchema = true, ) abstract class LocalCacheStorage : RoomDatabase() { + abstract fun cachedBookDao(): CachedBookDao - abstract fun cachedBookDao(): CachedBookDao - - abstract fun cachedLibraryDao(): CachedLibraryDao + abstract fun cachedLibraryDao(): CachedLibraryDao } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/Migrations.kt b/app/src/main/java/org/grakovne/lissen/content/cache/Migrations.kt index 66d36976..5115946b 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/Migrations.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/Migrations.kt @@ -4,242 +4,255 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import org.grakovne.lissen.channel.common.LibraryType -val MIGRATION_1_2 = object : Migration(1, 2) { +val MIGRATION_1_2 = + object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE detailed_books RENAME TO detailed_books_old") + db.execSQL("ALTER TABLE detailed_books RENAME TO detailed_books_old") - db.execSQL( - """ - CREATE TABLE detailed_books ( - id TEXT NOT NULL PRIMARY KEY, - title TEXT NOT NULL, - author TEXT, - duration INTEGER NOT NULL - ) - """.trimIndent(), + db.execSQL( + """ + CREATE TABLE detailed_books ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + author TEXT, + duration INTEGER NOT NULL ) + """.trimIndent(), + ) - db.execSQL( - """ - INSERT INTO detailed_books (id, title, author, duration) - SELECT id, title, author, duration FROM detailed_books_old - """.trimIndent(), - ) + db.execSQL( + """ + INSERT INTO detailed_books (id, title, author, duration) + SELECT id, title, author, duration FROM detailed_books_old + """.trimIndent(), + ) - db.execSQL("DROP TABLE detailed_books_old") + db.execSQL("DROP TABLE detailed_books_old") } -} + } -val MIGRATION_2_3 = object : Migration(2, 3) { +val MIGRATION_2_3 = + object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE libraries ADD COLUMN type TEXT") + db.execSQL("ALTER TABLE libraries ADD COLUMN type TEXT") - db.execSQL( - """ - UPDATE libraries - SET type = '${LibraryType.LIBRARY.name}' - """.trimIndent(), + db.execSQL( + """ + UPDATE libraries + SET type = '${LibraryType.LIBRARY.name}' + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TABLE libraries_new ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + type TEXT NOT NULL ) + """.trimIndent(), + ) - db.execSQL( - """ - CREATE TABLE libraries_new ( - id TEXT NOT NULL PRIMARY KEY, - title TEXT NOT NULL, - type TEXT NOT NULL - ) - """.trimIndent(), - ) + db.execSQL( + """ + INSERT INTO libraries_new (id, title, type) + SELECT id, title, type FROM libraries + """.trimIndent(), + ) - db.execSQL( - """ - INSERT INTO libraries_new (id, title, type) - SELECT id, title, type FROM libraries - """.trimIndent(), - ) - - db.execSQL("DROP TABLE libraries") - db.execSQL("ALTER TABLE libraries_new RENAME TO libraries") + db.execSQL("DROP TABLE libraries") + db.execSQL("ALTER TABLE libraries_new RENAME TO libraries") } -} + } -val MIGRATION_3_4 = object : Migration(3, 4) { +val MIGRATION_3_4 = + object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE detailed_books ADD COLUMN libraryId TEXT") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN libraryId TEXT") - db.execSQL( - """ - UPDATE detailed_books - SET libraryId = NULL - """.trimIndent(), + db.execSQL( + """ + UPDATE detailed_books + SET libraryId = NULL + """.trimIndent(), + ) + } + } + +val MIGRATION_4_5 = + object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE book_chapters ADD COLUMN isCached INTEGER NOT NULL DEFAULT 0") + + db.execSQL( + """ + UPDATE book_chapters + SET isCached = 1 + """.trimIndent(), + ) + } + } + +val MIGRATION_5_6 = + object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE detailed_books ADD COLUMN year TEXT") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN abstract TEXT") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN publisher TEXT") + } + } + +val MIGRATION_6_7 = + object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE detailed_books ADD COLUMN subtitle TEXT") + } + } + +val MIGRATION_7_8 = + object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE book_series ( + id TEXT NOT NULL PRIMARY KEY, + bookId TEXT NOT NULL, + serialNumber INTEGER NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE ) + """.trimIndent(), + ) + + db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)") } -} + } -val MIGRATION_4_5 = object : Migration(4, 5) { +val MIGRATION_8_9 = + object : Migration(8, 9) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE book_chapters ADD COLUMN isCached INTEGER NOT NULL DEFAULT 0") - - db.execSQL( - """ - UPDATE book_chapters - SET isCached = 1 - """.trimIndent(), + db.execSQL( + """ + CREATE TABLE book_series_new ( + id TEXT NOT NULL PRIMARY KEY, + bookId TEXT NOT NULL, + serialNumber TEXT, + name TEXT NOT NULL, + FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE ) - } -} + """.trimIndent(), + ) -val MIGRATION_5_6 = object : Migration(5, 6) { + db.execSQL( + """ + INSERT INTO book_series_new (id, bookId, serialNumber, name) + SELECT id, bookId, serialNumber, name FROM book_series + """.trimIndent(), + ) + + db.execSQL("DROP TABLE book_series") + db.execSQL("ALTER TABLE book_series_new RENAME TO book_series") + db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)") + } + } + +val MIGRATION_9_10 = + object : Migration(9, 10) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE detailed_books ADD COLUMN year TEXT") - db.execSQL("ALTER TABLE detailed_books ADD COLUMN abstract TEXT") - db.execSQL("ALTER TABLE detailed_books ADD COLUMN publisher TEXT") + db.execSQL("DROP TABLE IF EXISTS book_series") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesJson TEXT") } -} + } -val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE detailed_books ADD COLUMN subtitle TEXT") - } -} - -val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE book_series ( - id TEXT NOT NULL PRIMARY KEY, - bookId TEXT NOT NULL, - serialNumber INTEGER NOT NULL, - name TEXT NOT NULL, - FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE - ) - """.trimIndent(), - ) - - db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)") - } -} - -val MIGRATION_8_9 = object : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE book_series_new ( - id TEXT NOT NULL PRIMARY KEY, - bookId TEXT NOT NULL, - serialNumber TEXT, - name TEXT NOT NULL, - FOREIGN KEY (bookId) REFERENCES detailed_books(id) ON DELETE CASCADE - ) - """.trimIndent(), - ) - - db.execSQL( - """ - INSERT INTO book_series_new (id, bookId, serialNumber, name) - SELECT id, bookId, serialNumber, name FROM book_series - """.trimIndent(), - ) - - db.execSQL("DROP TABLE book_series") - db.execSQL("ALTER TABLE book_series_new RENAME TO book_series") - db.execSQL("CREATE INDEX index_book_series_bookId ON book_series(bookId)") - } -} - -val MIGRATION_9_10 = object : Migration(9, 10) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("DROP TABLE IF EXISTS book_series") - db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesJson TEXT") - } -} - -val MIGRATION_10_11 = object : Migration(10, 11) { +val MIGRATION_10_11 = + object : Migration(10, 11) { override fun migrate(database: SupportSQLiteDatabase) { - val now = System.currentTimeMillis() / 1000 + val now = System.currentTimeMillis() / 1000 - database.execSQL( - """ - CREATE TABLE detailed_books_new ( - id TEXT NOT NULL PRIMARY KEY, - title TEXT NOT NULL, - author TEXT, - duration INTEGER NOT NULL, - abstract TEXT, - subtitle TEXT, - year TEXT, - libraryId TEXT, - publisher TEXT, - seriesJson TEXT, - createdAt INTEGER NOT NULL - ) - """.trimIndent(), + database.execSQL( + """ + CREATE TABLE detailed_books_new ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + author TEXT, + duration INTEGER NOT NULL, + abstract TEXT, + subtitle TEXT, + year TEXT, + libraryId TEXT, + publisher TEXT, + seriesJson TEXT, + createdAt INTEGER NOT NULL ) + """.trimIndent(), + ) - database.execSQL( - """ - INSERT INTO detailed_books_new ( - id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt - ) - SELECT - id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, $now - FROM detailed_books - """.trimIndent(), + database.execSQL( + """ + INSERT INTO detailed_books_new ( + id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt ) + SELECT + id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, $now + FROM detailed_books + """.trimIndent(), + ) - database.execSQL("DROP TABLE detailed_books") - database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books") + database.execSQL("DROP TABLE detailed_books") + database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books") } -} + } -val MIGRATION_11_12 = object : Migration(11, 12) { +val MIGRATION_11_12 = + object : Migration(11, 12) { override fun migrate(database: SupportSQLiteDatabase) { - val now = System.currentTimeMillis() / 1000 + val now = System.currentTimeMillis() / 1000 - database.execSQL( - """ - CREATE TABLE detailed_books_new ( - id TEXT NOT NULL PRIMARY KEY, - title TEXT NOT NULL, - author TEXT, - duration INTEGER NOT NULL, - abstract TEXT, - subtitle TEXT, - year TEXT, - libraryId TEXT, - publisher TEXT, - seriesJson TEXT, - createdAt INTEGER NOT NULL, - updatedAt INTEGER NOT NULL - ) - """.trimIndent(), + database.execSQL( + """ + CREATE TABLE detailed_books_new ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + author TEXT, + duration INTEGER NOT NULL, + abstract TEXT, + subtitle TEXT, + year TEXT, + libraryId TEXT, + publisher TEXT, + seriesJson TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL ) + """.trimIndent(), + ) - database.execSQL( - """ - INSERT INTO detailed_books_new ( - id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, updatedAt - ) - SELECT - id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, $now - FROM detailed_books - """.trimIndent(), + database.execSQL( + """ + INSERT INTO detailed_books_new ( + id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, updatedAt ) + SELECT + id, title, author, duration, abstract, subtitle, year, libraryId, publisher, seriesJson, createdAt, $now + FROM detailed_books + """.trimIndent(), + ) - database.execSQL("DROP TABLE detailed_books") - database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books") + database.execSQL("DROP TABLE detailed_books") + database.execSQL("ALTER TABLE detailed_books_new RENAME TO detailed_books") } -} + } -val MIGRATION_12_13 = object : Migration(12, 13) { +val MIGRATION_12_13 = + object : Migration(12, 13) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE detailed_books ADD COLUMN narrator TEXT") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN narrator TEXT") } -} + } -val MIGRATION_13_14 = object : Migration(13, 14) { +val MIGRATION_13_14 = + object : Migration(13, 14) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesNames TEXT") + db.execSQL("ALTER TABLE detailed_books ADD COLUMN seriesNames TEXT") } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedBookRepository.kt b/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedBookRepository.kt index b19e6ff9..fb637175 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedBookRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedBookRepository.kt @@ -22,118 +22,130 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CachedBookRepository @Inject constructor( +class CachedBookRepository + @Inject + constructor( private val bookDao: CachedBookDao, private val properties: CacheBookStorageProperties, private val cachedBookEntityConverter: CachedBookEntityConverter, private val cachedBookEntityDetailedConverter: CachedBookEntityDetailedConverter, private val cachedBookEntityRecentConverter: CachedBookEntityRecentConverter, private val preferences: LissenSharedPreferences, -) { - - fun provideFileUri(bookId: String, fileId: String): Uri = properties + ) { + fun provideFileUri( + bookId: String, + fileId: String, + ): Uri = + properties .provideMediaCachePatch(bookId, fileId) .toUri() fun provideBookCover(bookId: String): File = properties.provideBookCoverPath(bookId) suspend fun removeBook(bookId: String) { - bookDao - .fetchBook(bookId) - ?.let { bookDao.deleteBook(it) } + bookDao + .fetchBook(bookId) + ?.let { bookDao.deleteBook(it) } } suspend fun cacheBook( - book: DetailedItem, - fetchedChapters: List, + book: DetailedItem, + fetchedChapters: List, ) { - bookDao.upsertCachedBook(book, fetchedChapters) + bookDao.upsertCachedBook(book, fetchedChapters) } fun provideCacheState(bookId: String) = bookDao.isBookCached(bookId) suspend fun fetchBooks( - pageNumber: Int, - pageSize: Int, + pageNumber: Int, + pageSize: Int, ): List { - val (option, direction) = buildOrdering() + val (option, direction) = buildOrdering() - val request = FetchRequestBuilder() - .libraryId(preferences.getPreferredLibrary()?.id) - .pageNumber(pageNumber) - .pageSize(pageSize) - .orderField(option) - .orderDirection(direction) - .build() + val request = + FetchRequestBuilder() + .libraryId(preferences.getPreferredLibrary()?.id) + .pageNumber(pageNumber) + .pageSize(pageSize) + .orderField(option) + .orderDirection(direction) + .build() - return bookDao - .fetchCachedBooks(request) - .map { cachedBookEntityConverter.apply(it) } + return bookDao + .fetchCachedBooks(request) + .map { cachedBookEntityConverter.apply(it) } } - suspend fun searchBooks( - query: String, - ): List { - val (option, direction) = buildOrdering() + suspend fun searchBooks(query: String): List { + val (option, direction) = buildOrdering() - val request = SearchRequestBuilder() - .searchQuery(query) - .libraryId(preferences.getPreferredLibrary()?.id) - .orderField(option) - .orderDirection(direction) - .build() + val request = + SearchRequestBuilder() + .searchQuery(query) + .libraryId(preferences.getPreferredLibrary()?.id) + .orderField(option) + .orderDirection(direction) + .build() - return bookDao - .searchBooks(request) - .map { cachedBookEntityConverter.apply(it) } + return bookDao + .searchBooks(request) + .map { cachedBookEntityConverter.apply(it) } } suspend fun fetchRecentBooks(): List { - val recentBooks = bookDao.fetchRecentlyListenedCachedBooks( - libraryId = preferences.getPreferredLibrary()?.id, + val recentBooks = + bookDao.fetchRecentlyListenedCachedBooks( + libraryId = preferences.getPreferredLibrary()?.id, ) - val progress = recentBooks - .map { it.id } - .mapNotNull { bookDao.fetchMediaProgress(it) } - .associate { it.bookId to (it.lastUpdate to it.currentTime) } + val progress = + recentBooks + .map { it.id } + .mapNotNull { bookDao.fetchMediaProgress(it) } + .associate { it.bookId to (it.lastUpdate to it.currentTime) } - return recentBooks - .map { cachedBookEntityRecentConverter.apply(it, progress[it.id]) } + return recentBooks + .map { cachedBookEntityRecentConverter.apply(it, progress[it.id]) } } - suspend fun fetchBook( - bookId: String, - ): DetailedItem? = bookDao + suspend fun fetchBook(bookId: String): DetailedItem? = + bookDao .fetchCachedBook(bookId) ?.let { cachedBookEntityDetailedConverter.apply(it) } - suspend fun syncProgress(bookId: String, progress: PlaybackProgress) { - val book = bookDao.fetchCachedBook(bookId) ?: return + suspend fun syncProgress( + bookId: String, + progress: PlaybackProgress, + ) { + val book = bookDao.fetchCachedBook(bookId) ?: return - val entity = MediaProgressEntity( - bookId = bookId, - currentTime = progress.currentTotalTime, - isFinished = progress.currentTotalTime == book.chapters.sumOf { it.duration }, - lastUpdate = Instant.now().toEpochMilli(), + val entity = + MediaProgressEntity( + bookId = bookId, + currentTime = progress.currentTotalTime, + isFinished = progress.currentTotalTime == book.chapters.sumOf { it.duration }, + lastUpdate = Instant.now().toEpochMilli(), ) - bookDao.upsertMediaProgress(entity) + bookDao.upsertMediaProgress(entity) } private fun buildOrdering(): Pair { - val option = when (preferences.getLibraryOrdering().option) { - LibraryOrderingOption.TITLE -> "title" - LibraryOrderingOption.AUTHOR -> "author" - LibraryOrderingOption.CREATED_AT -> "createdAt" - LibraryOrderingOption.UPDATED_AT -> "updatedAt" + val option = + when (preferences.getLibraryOrdering().option) { + LibraryOrderingOption.TITLE -> "title" + LibraryOrderingOption.AUTHOR -> "author" + LibraryOrderingOption.CREATED_AT -> "createdAt" + LibraryOrderingOption.UPDATED_AT -> "updatedAt" } - val direction = when (preferences.getLibraryOrdering().direction) { - LibraryOrderingDirection.ASCENDING -> "asc" - LibraryOrderingDirection.DESCENDING -> "desc" + val direction = + when (preferences.getLibraryOrdering().direction) { + LibraryOrderingDirection.ASCENDING -> "asc" + LibraryOrderingDirection.DESCENDING -> "desc" } - return option to direction + return option to direction } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedLibraryRepository.kt b/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedLibraryRepository.kt index 90df92f1..29590c80 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedLibraryRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/api/CachedLibraryRepository.kt @@ -7,14 +7,16 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CachedLibraryRepository @Inject constructor( +class CachedLibraryRepository + @Inject + constructor( private val dao: CachedLibraryDao, private val converter: CachedLibraryEntityConverter, -) { - + ) { suspend fun cacheLibraries(libraries: List) = dao.updateLibraries(libraries) - suspend fun fetchLibraries() = dao + suspend fun fetchLibraries() = + dao .fetchLibraries() .map { converter.apply(it) } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/api/FetchRequestBuilder.kt b/app/src/main/java/org/grakovne/lissen/content/cache/api/FetchRequestBuilder.kt index 5955eadb..2216466f 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/api/FetchRequestBuilder.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/api/FetchRequestBuilder.kt @@ -4,49 +4,57 @@ import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery class FetchRequestBuilder { - private var libraryId: String? = null - private var pageNumber: Int = 0 - private var pageSize: Int = 20 - private var orderField: String = "title" - private var orderDirection: String = "ASC" + private var libraryId: String? = null + private var pageNumber: Int = 0 + private var pageSize: Int = 20 + private var orderField: String = "title" + private var orderDirection: String = "ASC" - fun libraryId(id: String?) = apply { this.libraryId = id } - fun pageNumber(number: Int) = apply { this.pageNumber = number } - fun pageSize(size: Int) = apply { this.pageSize = size } - fun orderField(field: String) = apply { this.orderField = field } - fun orderDirection(direction: String) = apply { this.orderDirection = direction } + fun libraryId(id: String?) = apply { this.libraryId = id } - fun build(): SupportSQLiteQuery { - val args = mutableListOf() + fun pageNumber(number: Int) = apply { this.pageNumber = number } - val whereClause = when (val libraryId = libraryId) { - null -> "libraryId IS NULL" - else -> { - args.add(libraryId) - "(libraryId = ? OR libraryId IS NULL)" - } + fun pageSize(size: Int) = apply { this.pageSize = size } + + fun orderField(field: String) = apply { this.orderField = field } + + fun orderDirection(direction: String) = apply { this.orderDirection = direction } + + fun build(): SupportSQLiteQuery { + val args = mutableListOf() + + val whereClause = + when (val libraryId = libraryId) { + null -> "libraryId IS NULL" + else -> { + args.add(libraryId) + "(libraryId = ? OR libraryId IS NULL)" } + } - val field = when (orderField) { - "title", "author", "duration" -> orderField - else -> "title" - } + val field = + when (orderField) { + "title", "author", "duration" -> orderField + else -> "title" + } - val direction = when (orderDirection.uppercase()) { - "ASC", "DESC" -> orderDirection.uppercase() - else -> "ASC" - } + val direction = + when (orderDirection.uppercase()) { + "ASC", "DESC" -> orderDirection.uppercase() + else -> "ASC" + } - args.add(pageSize) - args.add(pageNumber * pageSize) + args.add(pageSize) + args.add(pageNumber * pageSize) - val sql = """ - SELECT * FROM detailed_books - WHERE $whereClause - ORDER BY $field $direction - LIMIT ? OFFSET ? - """.trimIndent() + val sql = + """ + SELECT * FROM detailed_books + WHERE $whereClause + ORDER BY $field $direction + LIMIT ? OFFSET ? + """.trimIndent() - return SimpleSQLiteQuery(sql, args.toTypedArray()) - } + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/api/SearchRequestBuilder.kt b/app/src/main/java/org/grakovne/lissen/content/cache/api/SearchRequestBuilder.kt index 2422cccc..ac6d9c62 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/api/SearchRequestBuilder.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/api/SearchRequestBuilder.kt @@ -4,50 +4,57 @@ import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery class SearchRequestBuilder { - private var libraryId: String? = null - private var searchQuery: String = "" - private var orderField: String = "title" - private var orderDirection: String = "ASC" + private var libraryId: String? = null + private var searchQuery: String = "" + private var orderField: String = "title" + private var orderDirection: String = "ASC" - fun libraryId(id: String?) = apply { this.libraryId = id } - fun searchQuery(query: String) = apply { this.searchQuery = query } - fun orderField(field: String) = apply { this.orderField = field } - fun orderDirection(direction: String) = apply { this.orderDirection = direction } + fun libraryId(id: String?) = apply { this.libraryId = id } - fun build(): SupportSQLiteQuery { - val args = mutableListOf() + fun searchQuery(query: String) = apply { this.searchQuery = query } - val whereClause = buildString { - when (val libraryId = libraryId) { - null -> append("(libraryId IS NULL)") - else -> { - append("(libraryId = ? OR libraryId IS NULL)") - args.add(libraryId) - } - } - append(" AND (title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)") - val pattern = "%$searchQuery%" - args.add(pattern) - args.add(pattern) - args.add(pattern) + fun orderField(field: String) = apply { this.orderField = field } + + fun orderDirection(direction: String) = apply { this.orderDirection = direction } + + fun build(): SupportSQLiteQuery { + val args = mutableListOf() + + val whereClause = + buildString { + when (val libraryId = libraryId) { + null -> append("(libraryId IS NULL)") + else -> { + append("(libraryId = ? OR libraryId IS NULL)") + args.add(libraryId) + } } + append(" AND (title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)") + val pattern = "%$searchQuery%" + args.add(pattern) + args.add(pattern) + args.add(pattern) + } - val field = when (orderField) { - "title", "author", "duration" -> orderField - else -> "title" - } + val field = + when (orderField) { + "title", "author", "duration" -> orderField + else -> "title" + } - val direction = when (orderDirection.uppercase()) { - "ASC", "DESC" -> orderDirection.uppercase() - else -> "ASC" - } + val direction = + when (orderDirection.uppercase()) { + "ASC", "DESC" -> orderDirection.uppercase() + else -> "ASC" + } - val sql = """ - SELECT * FROM detailed_books - WHERE $whereClause - ORDER BY $field $direction - """.trimIndent() + val sql = + """ + SELECT * FROM detailed_books + WHERE $whereClause + ORDER BY $field $direction + """.trimIndent() - return SimpleSQLiteQuery(sql, args.toTypedArray()) - } + return SimpleSQLiteQuery(sql, args.toTypedArray()) + } } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityConverter.kt b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityConverter.kt index 4ca514b3..3989f5a5 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityConverter.kt @@ -9,27 +9,29 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CachedBookEntityConverter @Inject constructor() { - - fun apply(entity: BookEntity): Book = Book( +class CachedBookEntityConverter + @Inject + constructor() { + fun apply(entity: BookEntity): Book = + Book( id = entity.id, title = entity.title, subtitle = entity.subtitle, author = entity.author, - series = entity + series = + entity .seriesJson ?.let { - val type = object : TypeToken>() {}.type - gson.fromJson>(it, type) - } - ?.joinToString(", ") { series -> - buildString { - append(series.title) - series.sequence - ?.takeIf(String::isNotBlank) - ?.let { append(" #$it") } - } + val type = object : TypeToken>() {}.type + gson.fromJson>(it, type) + }?.joinToString(", ") { series -> + buildString { + append(series.title) + series.sequence + ?.takeIf(String::isNotBlank) + ?.let { append(" #$it") } + } }, duration = entity.duration, - ) -} + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityDetailedConverter.kt b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityDetailedConverter.kt index 1d87365d..4c627f28 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityDetailedConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityDetailedConverter.kt @@ -13,9 +13,11 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CachedBookEntityDetailedConverter @Inject constructor() { - - fun apply(entity: CachedBookEntity): DetailedItem = DetailedItem( +class CachedBookEntityDetailedConverter + @Inject + constructor() { + fun apply(entity: CachedBookEntity): DetailedItem = + DetailedItem( id = entity.detailedBook.id, title = entity.detailedBook.title, subtitle = entity.detailedBook.subtitle, @@ -23,54 +25,56 @@ class CachedBookEntityDetailedConverter @Inject constructor() { narrator = entity.detailedBook.narrator, libraryId = entity.detailedBook.libraryId, localProvided = true, - files = entity.files.map { fileEntity -> + files = + entity.files.map { fileEntity -> BookFile( - id = fileEntity.bookFileId, - name = fileEntity.name, - duration = fileEntity.duration, - mimeType = fileEntity.mimeType, + id = fileEntity.bookFileId, + name = fileEntity.name, + duration = fileEntity.duration, + mimeType = fileEntity.mimeType, ) - }, - chapters = entity.chapters.map { chapterEntity -> + }, + chapters = + entity.chapters.map { chapterEntity -> PlayingChapter( - duration = chapterEntity.duration, - start = chapterEntity.start, - end = chapterEntity.end, - title = chapterEntity.title, - available = chapterEntity.isCached, - id = chapterEntity.bookChapterId, - podcastEpisodeState = null, // currently state is not available for local mode + duration = chapterEntity.duration, + start = chapterEntity.start, + end = chapterEntity.end, + title = chapterEntity.title, + available = chapterEntity.isCached, + id = chapterEntity.bookChapterId, + podcastEpisodeState = null, // currently state is not available for local mode ) - }, + }, abstract = entity.detailedBook.abstract, publisher = entity.detailedBook.publisher, year = entity.detailedBook.year, createdAt = entity.detailedBook.createdAt, updatedAt = entity.detailedBook.updatedAt, - series = entity + series = + entity .detailedBook .seriesJson ?.let { - val type = object : TypeToken>() {}.type - gson.fromJson>(it, type) - } - ?.map { - BookSeries( - name = it.title, - serialNumber = it.sequence, - ) + val type = object : TypeToken>() {}.type + gson.fromJson>(it, type) + }?.map { + BookSeries( + name = it.title, + serialNumber = it.sequence, + ) } ?: emptyList(), - progress = entity.progress?.let { progressEntity -> + progress = + entity.progress?.let { progressEntity -> MediaProgress( - currentTime = progressEntity.currentTime, - isFinished = progressEntity.isFinished, - lastUpdate = progressEntity.lastUpdate, + currentTime = progressEntity.currentTime, + isFinished = progressEntity.isFinished, + lastUpdate = progressEntity.lastUpdate, ) - }, - ) + }, + ) companion object { - - val gson = Gson() + val gson = Gson() } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityRecentConverter.kt b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityRecentConverter.kt index 4cf7ecb0..19d25715 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityRecentConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityRecentConverter.kt @@ -6,18 +6,24 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CachedBookEntityRecentConverter @Inject constructor() { - - fun apply(entity: BookEntity, currentTime: Pair?): RecentBook = RecentBook( +class CachedBookEntityRecentConverter + @Inject + constructor() { + fun apply( + entity: BookEntity, + currentTime: Pair?, + ): RecentBook = + RecentBook( id = entity.id, title = entity.title, subtitle = entity.subtitle, author = entity.author, listenedLastUpdate = currentTime?.first ?: 0, - listenedPercentage = currentTime + listenedPercentage = + currentTime ?.second ?.let { it / entity.duration } ?.let { it * 100 } ?.toInt(), - ) -} + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedLibraryEntityConverter.kt b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedLibraryEntityConverter.kt index a69d5454..d852b541 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedLibraryEntityConverter.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedLibraryEntityConverter.kt @@ -6,11 +6,13 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CachedLibraryEntityConverter @Inject constructor() { - - fun apply(entity: CachedLibraryEntity): Library = Library( +class CachedLibraryEntityConverter + @Inject + constructor() { + fun apply(entity: CachedLibraryEntity): Library = + Library( id = entity.id, title = entity.title, type = entity.type, - ) -} + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedBookDao.kt b/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedBookDao.kt index bf5b036a..d1403a4d 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedBookDao.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedBookDao.kt @@ -23,137 +23,143 @@ import org.grakovne.lissen.domain.PlayingChapter @Dao interface CachedBookDao { + @Transaction + suspend fun upsertCachedBook( + book: DetailedItem, + fetchedChapters: List, + ) { + val bookEntity = + BookEntity( + id = book.id, + title = book.title, + subtitle = book.subtitle, + author = book.author, + narrator = book.narrator, + duration = book.chapters.sumOf { it.duration }.toInt(), + libraryId = book.libraryId, + year = book.year, + abstract = book.abstract, + publisher = book.publisher, + createdAt = book.createdAt, + updatedAt = book.updatedAt, + seriesNames = + book + .series + .joinToString(" ") { it.name }, + seriesJson = + book + .series + .map { BookSeriesDto(title = it.name, sequence = it.serialNumber) } + .let { gson.toJson(it) }, + ) - @Transaction - suspend fun upsertCachedBook( - book: DetailedItem, - fetchedChapters: List, - ) { - val bookEntity = BookEntity( - id = book.id, - title = book.title, - subtitle = book.subtitle, - author = book.author, - narrator = book.narrator, - duration = book.chapters.sumOf { it.duration }.toInt(), - libraryId = book.libraryId, - year = book.year, - abstract = book.abstract, - publisher = book.publisher, - createdAt = book.createdAt, - updatedAt = book.updatedAt, - seriesNames = book - .series - .joinToString(" ") { it.name }, - seriesJson = book - .series - .map { BookSeriesDto(title = it.name, sequence = it.serialNumber) } - .let { gson.toJson(it) }, - ) + val bookFiles = + book + .files + .map { file -> + BookFileEntity( + bookFileId = file.id, + name = file.name, + duration = file.duration, + mimeType = file.mimeType, + bookId = book.id, + ) + } - val bookFiles = book - .files - .map { file -> - BookFileEntity( - bookFileId = file.id, - name = file.name, - duration = file.duration, - mimeType = file.mimeType, - bookId = book.id, - ) - } + val cachedBookChapters = + fetchCachedBook(book.id) + ?.chapters + ?: emptyList() - val cachedBookChapters = fetchCachedBook(book.id) - ?.chapters - ?: emptyList() + val bookChapters = + book + .chapters + .map { chapter -> + BookChapterEntity( + bookChapterId = chapter.id, + duration = chapter.duration, + start = chapter.start, + end = chapter.end, + title = chapter.title, + bookId = book.id, + isCached = + fetchedChapters.any { it.id == chapter.id } || + cachedBookChapters.any { it.bookChapterId == chapter.id && it.isCached }, + ) + } - val bookChapters = book - .chapters - .map { chapter -> - BookChapterEntity( - bookChapterId = chapter.id, - duration = chapter.duration, - start = chapter.start, - end = chapter.end, - title = chapter.title, - bookId = book.id, - isCached = fetchedChapters.any { it.id == chapter.id } || cachedBookChapters.any { it.bookChapterId == chapter.id && it.isCached }, - ) - } + val mediaProgress = + book + .progress + ?.let { progress -> + MediaProgressEntity( + bookId = book.id, + currentTime = progress.currentTime, + isFinished = progress.isFinished, + lastUpdate = progress.lastUpdate, + ) + } - val mediaProgress = book - .progress - ?.let { progress -> - MediaProgressEntity( - bookId = book.id, - currentTime = progress.currentTime, - isFinished = progress.isFinished, - lastUpdate = progress.lastUpdate, - ) - } + upsertBook(bookEntity) + upsertBookFiles(bookFiles) + upsertBookChapters(bookChapters) + mediaProgress?.let { upsertMediaProgress(it) } + } - upsertBook(bookEntity) - upsertBookFiles(bookFiles) - upsertBookChapters(bookChapters) - mediaProgress?.let { upsertMediaProgress(it) } - } + @Transaction + @RawQuery + suspend fun fetchCachedBooks(query: SupportSQLiteQuery): List - @Transaction - @RawQuery - suspend fun fetchCachedBooks( - query: SupportSQLiteQuery, - ): List + @Transaction + @RawQuery + suspend fun searchBooks(query: SupportSQLiteQuery): List - @Transaction - @RawQuery - suspend fun searchBooks(query: SupportSQLiteQuery): List - - @Transaction - @RewriteQueriesToDropUnusedColumns - @Query( - """ + @Transaction + @RewriteQueriesToDropUnusedColumns + @Query( + """ SELECT * FROM detailed_books INNER JOIN media_progress ON detailed_books.id = media_progress.bookId WHERE (libraryId IS NULL OR libraryId = :libraryId) ORDER BY media_progress.lastUpdate DESC LIMIT 10 """, - ) - suspend fun fetchRecentlyListenedCachedBooks(libraryId: String?): List + ) + suspend fun fetchRecentlyListenedCachedBooks(libraryId: String?): List - @Transaction - @Query("SELECT * FROM detailed_books WHERE id = :bookId") - suspend fun fetchCachedBook(bookId: String): CachedBookEntity? + @Transaction + @Query("SELECT * FROM detailed_books WHERE id = :bookId") + suspend fun fetchCachedBook(bookId: String): CachedBookEntity? - @Query("SELECT COUNT(*) > 0 FROM detailed_books WHERE id = :bookId") - fun isBookCached(bookId: String): LiveData + @Query("SELECT COUNT(*) > 0 FROM detailed_books WHERE id = :bookId") + fun isBookCached(bookId: String): LiveData - @Transaction - @Query("SELECT * FROM detailed_books WHERE id = :bookId") - suspend fun fetchBook(bookId: String): BookEntity? + @Transaction + @Query("SELECT * FROM detailed_books WHERE id = :bookId") + suspend fun fetchBook(bookId: String): BookEntity? - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBook(book: BookEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertBook(book: BookEntity) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBookFiles(files: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertBookFiles(files: List) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertBookChapters(chapters: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertBookChapters(chapters: List) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertMediaProgress(progress: MediaProgressEntity) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertMediaProgress(progress: MediaProgressEntity) - @Transaction - @Query("SELECT * FROM media_progress WHERE bookId = :bookId") - suspend fun fetchMediaProgress(bookId: String): MediaProgressEntity? + @Transaction + @Query("SELECT * FROM media_progress WHERE bookId = :bookId") + suspend fun fetchMediaProgress(bookId: String): MediaProgressEntity? - @Update - suspend fun updateMediaProgress(progress: MediaProgressEntity) + @Update + suspend fun updateMediaProgress(progress: MediaProgressEntity) - @Delete - suspend fun deleteBook(book: BookEntity) + @Delete + suspend fun deleteBook(book: BookEntity) - companion object { - val gson = Gson() - } + companion object { + val gson = Gson() + } } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedLibraryDao.kt b/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedLibraryDao.kt index c046701b..2badaff7 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedLibraryDao.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedLibraryDao.kt @@ -10,32 +10,32 @@ import org.grakovne.lissen.domain.Library @Dao interface CachedLibraryDao { + @Transaction + suspend fun updateLibraries(libraries: List) { + val entities = + libraries.map { + CachedLibraryEntity( + id = it.id, + title = it.title, + type = it.type, + ) + } - @Transaction - suspend fun updateLibraries(libraries: List) { - val entities = libraries.map { - CachedLibraryEntity( - id = it.id, - title = it.title, - type = it.type, - ) - } + upsertLibraries(entities) + deleteLibrariesExcept(entities.map { it.id }) + } - upsertLibraries(entities) - deleteLibrariesExcept(entities.map { it.id }) - } + @Transaction + @Query("SELECT * FROM libraries WHERE id = :libraryId") + suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity? - @Transaction - @Query("SELECT * FROM libraries WHERE id = :libraryId") - suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity? + @Transaction + @Query("SELECT * FROM libraries") + suspend fun fetchLibraries(): List - @Transaction - @Query("SELECT * FROM libraries") - suspend fun fetchLibraries(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertLibraries(libraries: List) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertLibraries(libraries: List) - - @Query("DELETE FROM libraries WHERE id NOT IN (:ids)") - suspend fun deleteLibrariesExcept(ids: List) + @Query("DELETE FROM libraries WHERE id NOT IN (:ids)") + suspend fun deleteLibrariesExcept(ids: List) } diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedBookEntity.kt b/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedBookEntity.kt index ab9fecee..de26f9d3 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedBookEntity.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedBookEntity.kt @@ -11,114 +11,111 @@ import java.io.Serializable @Keep data class CachedBookEntity( - @Embedded val detailedBook: BookEntity, - - @Relation( - parentColumn = "id", - entityColumn = "bookId", - ) - val files: List, - - @Relation( - parentColumn = "id", - entityColumn = "bookId", - ) - val chapters: List, - - @Relation( - parentColumn = "id", - entityColumn = "bookId", - ) - val progress: MediaProgressEntity?, + @Embedded val detailedBook: BookEntity, + @Relation( + parentColumn = "id", + entityColumn = "bookId", + ) + val files: List, + @Relation( + parentColumn = "id", + entityColumn = "bookId", + ) + val chapters: List, + @Relation( + parentColumn = "id", + entityColumn = "bookId", + ) + val progress: MediaProgressEntity?, ) @Keep @Entity(tableName = "detailed_books") data class BookEntity( - @PrimaryKey val id: String, - val title: String, - val subtitle: String?, - val author: String?, - val narrator: String?, - val year: String?, - val abstract: String?, - val publisher: String?, - val duration: Int, - val libraryId: String?, - val seriesJson: String?, // List Json - val seriesNames: String?, - val createdAt: Long, - val updatedAt: Long, + @PrimaryKey val id: String, + val title: String, + val subtitle: String?, + val author: String?, + val narrator: String?, + val year: String?, + val abstract: String?, + val publisher: String?, + val duration: Int, + val libraryId: String?, + val seriesJson: String?, // List Json + val seriesNames: String?, + val createdAt: Long, + val updatedAt: Long, ) : Serializable @Keep @Entity( - tableName = "book_files", - foreignKeys = [ - ForeignKey( - entity = BookEntity::class, - parentColumns = ["id"], - childColumns = ["bookId"], - onDelete = ForeignKey.CASCADE, - ), - ], - indices = [Index(value = ["bookId"])], + tableName = "book_files", + foreignKeys = [ + ForeignKey( + entity = BookEntity::class, + parentColumns = ["id"], + childColumns = ["bookId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["bookId"])], ) data class BookFileEntity( - @PrimaryKey(autoGenerate = true) val id: Long = 0L, - val bookFileId: String, - val name: String, - val duration: Double, - val mimeType: String, - val bookId: String, + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val bookFileId: String, + val name: String, + val duration: Double, + val mimeType: String, + val bookId: String, ) : Serializable @Keep @Entity( - tableName = "book_chapters", - foreignKeys = [ - ForeignKey( - entity = BookEntity::class, - parentColumns = ["id"], - childColumns = ["bookId"], - onDelete = ForeignKey.CASCADE, - ), - ], - indices = [Index(value = ["bookId"])], + tableName = "book_chapters", + foreignKeys = [ + ForeignKey( + entity = BookEntity::class, + parentColumns = ["id"], + childColumns = ["bookId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["bookId"])], ) data class BookChapterEntity( - @PrimaryKey(autoGenerate = true) val id: Long = 0L, - val bookChapterId: String, - val duration: Double, - val start: Double, - val end: Double, - val title: String, - val bookId: String, - val isCached: Boolean, + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + val bookChapterId: String, + val duration: Double, + val start: Double, + val end: Double, + val title: String, + val bookId: String, + val isCached: Boolean, ) : Serializable @Keep @Entity( - tableName = "media_progress", - foreignKeys = [ - ForeignKey( - entity = BookEntity::class, - parentColumns = ["id"], - childColumns = ["bookId"], - onDelete = ForeignKey.CASCADE, - ), - ], - indices = [Index(value = ["bookId"])], + tableName = "media_progress", + foreignKeys = [ + ForeignKey( + entity = BookEntity::class, + parentColumns = ["id"], + childColumns = ["bookId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["bookId"])], ) data class MediaProgressEntity( - @PrimaryKey val bookId: String, - val currentTime: Double, - val isFinished: Boolean, - val lastUpdate: Long, + @PrimaryKey val bookId: String, + val currentTime: Double, + val isFinished: Boolean, + val lastUpdate: Long, ) : Serializable @Keep data class BookSeriesDto( - val title: String, - val sequence: String?, + val title: String, + val sequence: String?, ) diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedLibraryEntity.kt b/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedLibraryEntity.kt index dfdf381e..1bebd599 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedLibraryEntity.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedLibraryEntity.kt @@ -8,11 +8,11 @@ import java.io.Serializable @Keep @Entity( - tableName = "libraries", + tableName = "libraries", ) data class CachedLibraryEntity( - @PrimaryKey - val id: String, - val title: String, - val type: LibraryType, + @PrimaryKey + val id: String, + val title: String, + val type: LibraryType, ) : Serializable diff --git a/app/src/main/java/org/grakovne/lissen/content/cache/entity/PlaybackProgressEntity.kt b/app/src/main/java/org/grakovne/lissen/content/cache/entity/PlaybackProgressEntity.kt index 22c43839..75bbac39 100644 --- a/app/src/main/java/org/grakovne/lissen/content/cache/entity/PlaybackProgressEntity.kt +++ b/app/src/main/java/org/grakovne/lissen/content/cache/entity/PlaybackProgressEntity.kt @@ -7,7 +7,7 @@ import androidx.room.PrimaryKey @Keep @Entity(tableName = "playback_progress") data class PlaybackProgressEntity( - @PrimaryKey(autoGenerate = true) val id: Long = 0, - val currentTime: Double, - val totalTime: Double, + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val currentTime: Double, + val totalTime: Double, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/Book.kt b/app/src/main/java/org/grakovne/lissen/domain/Book.kt index 3b1eac04..25dd51f1 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/Book.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/Book.kt @@ -4,10 +4,10 @@ import androidx.annotation.Keep @Keep data class Book( - val id: String, - val subtitle: String?, - val series: String?, - val title: String, - val author: String?, - val duration: Int, + val id: String, + val subtitle: String?, + val series: String?, + val title: String, + val author: String?, + val duration: Int, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/CacheStatus.kt b/app/src/main/java/org/grakovne/lissen/domain/CacheStatus.kt index 2be8b49c..c19be3f6 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/CacheStatus.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/CacheStatus.kt @@ -1,8 +1,11 @@ package org.grakovne.lissen.domain sealed class CacheStatus { - data object Idle : CacheStatus() - data object Caching : CacheStatus() - data object Completed : CacheStatus() - data object Error : CacheStatus() + data object Idle : CacheStatus() + + data object Caching : CacheStatus() + + data object Completed : CacheStatus() + + data object Error : CacheStatus() } diff --git a/app/src/main/java/org/grakovne/lissen/domain/ContentCachingTask.kt b/app/src/main/java/org/grakovne/lissen/domain/ContentCachingTask.kt index 1c1140e8..8cef4d7b 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/ContentCachingTask.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/ContentCachingTask.kt @@ -5,7 +5,7 @@ import java.io.Serializable @Keep data class ContentCachingTask( - val itemId: String, - val options: DownloadOption, - val currentPosition: Double, + val itemId: String, + val options: DownloadOption, + val currentPosition: Double, ) : Serializable diff --git a/app/src/main/java/org/grakovne/lissen/domain/DetailedItem.kt b/app/src/main/java/org/grakovne/lissen/domain/DetailedItem.kt index a37dc921..fd727f8f 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/DetailedItem.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/DetailedItem.kt @@ -5,57 +5,57 @@ import java.io.Serializable @Keep data class DetailedItem( - val id: String, - val title: String, - val subtitle: String?, - val author: String?, - val narrator: String?, - val publisher: String?, - val series: List, - val year: String?, - val abstract: String?, - val files: List, - val chapters: List, - val progress: MediaProgress?, - val libraryId: String?, - val localProvided: Boolean, - val createdAt: Long, - val updatedAt: Long, + val id: String, + val title: String, + val subtitle: String?, + val author: String?, + val narrator: String?, + val publisher: String?, + val series: List, + val year: String?, + val abstract: String?, + val files: List, + val chapters: List, + val progress: MediaProgress?, + val libraryId: String?, + val localProvided: Boolean, + val createdAt: Long, + val updatedAt: Long, ) : Serializable @Keep data class BookFile( - val id: String, - val name: String, - val duration: Double, - val mimeType: String, + val id: String, + val name: String, + val duration: Double, + val mimeType: String, ) : Serializable @Keep data class MediaProgress( - val currentTime: Double, - val isFinished: Boolean, - val lastUpdate: Long, + val currentTime: Double, + val isFinished: Boolean, + val lastUpdate: Long, ) : Serializable @Keep data class PlayingChapter( - val available: Boolean, - val podcastEpisodeState: BookChapterState?, - val duration: Double, - val start: Double, - val end: Double, - val title: String, - val id: String, + val available: Boolean, + val podcastEpisodeState: BookChapterState?, + val duration: Double, + val start: Double, + val end: Double, + val title: String, + val id: String, ) : Serializable @Keep data class BookSeries( - val serialNumber: String?, - val name: String, + val serialNumber: String?, + val name: String, ) : Serializable @Keep enum class BookChapterState { - FINISHED, + FINISHED, } diff --git a/app/src/main/java/org/grakovne/lissen/domain/DownloadOption.kt b/app/src/main/java/org/grakovne/lissen/domain/DownloadOption.kt index 7fed51b1..7ab60302 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/DownloadOption.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/DownloadOption.kt @@ -6,6 +6,10 @@ import java.io.Serializable @Keep sealed interface DownloadOption : Serializable -class NumberItemDownloadOption(val itemsNumber: Int) : DownloadOption +class NumberItemDownloadOption( + val itemsNumber: Int, +) : DownloadOption + data object CurrentItemDownloadOption : DownloadOption + data object AllItemsDownloadOption : DownloadOption diff --git a/app/src/main/java/org/grakovne/lissen/domain/Library.kt b/app/src/main/java/org/grakovne/lissen/domain/Library.kt index f72f520c..0558fe7b 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/Library.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/Library.kt @@ -5,7 +5,7 @@ import org.grakovne.lissen.channel.common.LibraryType @Keep data class Library( - val id: String, - val title: String, - val type: LibraryType, + val id: String, + val title: String, + val type: LibraryType, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/PagedItems.kt b/app/src/main/java/org/grakovne/lissen/domain/PagedItems.kt index 4ef241e9..97691f01 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/PagedItems.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/PagedItems.kt @@ -4,6 +4,6 @@ import androidx.annotation.Keep @Keep data class PagedItems( - val items: List, - val currentPage: Int, + val items: List, + val currentPage: Int, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/PlaybackProgress.kt b/app/src/main/java/org/grakovne/lissen/domain/PlaybackProgress.kt index d1b523ed..2574bc1d 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/PlaybackProgress.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/PlaybackProgress.kt @@ -4,6 +4,6 @@ import androidx.annotation.Keep @Keep data class PlaybackProgress( - val currentChapterTime: Double, - val currentTotalTime: Double, + val currentChapterTime: Double, + val currentTotalTime: Double, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/PlaybackSession.kt b/app/src/main/java/org/grakovne/lissen/domain/PlaybackSession.kt index 05e804ed..a437fc1b 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/PlaybackSession.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/PlaybackSession.kt @@ -4,6 +4,6 @@ import androidx.annotation.Keep @Keep data class PlaybackSession( - val sessionId: String, - val bookId: String, + val sessionId: String, + val bookId: String, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/RecentBook.kt b/app/src/main/java/org/grakovne/lissen/domain/RecentBook.kt index 46758db9..acba4897 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/RecentBook.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/RecentBook.kt @@ -4,10 +4,10 @@ import androidx.annotation.Keep @Keep data class RecentBook( - val id: String, - val title: String, - val subtitle: String?, - val author: String?, - val listenedPercentage: Int?, - val listenedLastUpdate: Long?, + val id: String, + val title: String, + val subtitle: String?, + val author: String?, + val listenedPercentage: Int?, + val listenedLastUpdate: Long?, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/RewindOnPauseTime.kt b/app/src/main/java/org/grakovne/lissen/domain/RewindOnPauseTime.kt index 6df06593..f4b5bd6d 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/RewindOnPauseTime.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/RewindOnPauseTime.kt @@ -4,15 +4,14 @@ import androidx.annotation.Keep @Keep data class RewindOnPauseTime( - val enabled: Boolean, - val time: SeekTimeOption, + val enabled: Boolean, + val time: SeekTimeOption, ) { - - companion object { - - val Default = RewindOnPauseTime( - enabled = false, - time = SeekTimeOption.SEEK_5, - ) - } + companion object { + val Default = + RewindOnPauseTime( + enabled = false, + time = SeekTimeOption.SEEK_5, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/domain/SeekTime.kt b/app/src/main/java/org/grakovne/lissen/domain/SeekTime.kt index 3ca52471..94104773 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/SeekTime.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/SeekTime.kt @@ -4,13 +4,14 @@ import androidx.annotation.Keep @Keep data class SeekTime( - val rewind: SeekTimeOption, - val forward: SeekTimeOption, + val rewind: SeekTimeOption, + val forward: SeekTimeOption, ) { - companion object { - val Default = SeekTime( - rewind = SeekTimeOption.SEEK_10, - forward = SeekTimeOption.SEEK_30, - ) - } + companion object { + val Default = + SeekTime( + rewind = SeekTimeOption.SEEK_10, + forward = SeekTimeOption.SEEK_30, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/domain/SeekTimeOption.kt b/app/src/main/java/org/grakovne/lissen/domain/SeekTimeOption.kt index 00707709..b01fa34f 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/SeekTimeOption.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/SeekTimeOption.kt @@ -1,9 +1,9 @@ package org.grakovne.lissen.domain enum class SeekTimeOption { - SEEK_5, - SEEK_10, - SEEK_15, - SEEK_30, - SEEK_60, + SEEK_5, + SEEK_10, + SEEK_15, + SEEK_30, + SEEK_60, } diff --git a/app/src/main/java/org/grakovne/lissen/domain/TimerOption.kt b/app/src/main/java/org/grakovne/lissen/domain/TimerOption.kt index a3e37518..8f3fc184 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/TimerOption.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/TimerOption.kt @@ -2,5 +2,8 @@ package org.grakovne.lissen.domain sealed interface TimerOption -class DurationTimerOption(val duration: Int) : TimerOption +class DurationTimerOption( + val duration: Int, +) : TimerOption + data object CurrentEpisodeTimerOption : TimerOption diff --git a/app/src/main/java/org/grakovne/lissen/domain/UserAccount.kt b/app/src/main/java/org/grakovne/lissen/domain/UserAccount.kt index 1611c511..76f52057 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/UserAccount.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/UserAccount.kt @@ -4,7 +4,7 @@ import androidx.annotation.Keep @Keep data class UserAccount( - val token: String, - val username: String, - val preferredLibraryId: String?, + val token: String, + val username: String, + val preferredLibraryId: String?, ) diff --git a/app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt b/app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt index 45b15d75..2722e089 100644 --- a/app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt +++ b/app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt @@ -5,29 +5,28 @@ import java.util.UUID @Keep data class ServerRequestHeader( - val name: String, - val value: String, - val id: UUID = UUID.randomUUID(), + val name: String, + val value: String, + val id: UUID = UUID.randomUUID(), ) { + companion object { + fun empty() = ServerRequestHeader("", "") - companion object { - fun empty() = ServerRequestHeader("", "") + fun ServerRequestHeader.clean(): ServerRequestHeader { + val name = this.name.clean() + val value = this.value.clean() - fun ServerRequestHeader.clean(): ServerRequestHeader { - val name = this.name.clean() - val value = this.value.clean() - - return this.copy(name = name, value = value) - } - - /** - * Cleans this string to contain only valid tchar characters for HTTP header names as per RFC 7230. - * - * @return A string containing only allowed tchar characters. - */ - private fun String.clean(): String { - val invalidCharacters = Regex("[^!#\$%&'*+\\-.^_`|~0-9A-Za-z]") - return this.replace(invalidCharacters, "").trim() - } + return this.copy(name = name, value = value) } + + /** + * Cleans this string to contain only valid tchar characters for HTTP header names as per RFC 7230. + * + * @return A string containing only allowed tchar characters. + */ + private fun String.clean(): String { + val invalidCharacters = Regex("[^!#\$%&'*+\\-.^_`|~0-9A-Za-z]") + return this.replace(invalidCharacters, "").trim() + } + } } diff --git a/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt b/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt index 3cf278fb..b5e5a392 100644 --- a/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt +++ b/app/src/main/java/org/grakovne/lissen/persistence/preferences/LissenSharedPreferences.kt @@ -32,330 +32,332 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class LissenSharedPreferences @Inject constructor(@ApplicationContext context: Context) { - +class LissenSharedPreferences + @Inject + constructor( + @ApplicationContext context: Context, + ) { private val sharedPreferences: SharedPreferences = - context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE) + context.getSharedPreferences("secure_prefs", Context.MODE_PRIVATE) fun hasCredentials(): Boolean { - val host = getHost() - val username = getUsername() - val token = getToken() + val host = getHost() + val username = getUsername() + val token = getToken() - return try { - host != null && username != null && token != null - } catch (ex: Exception) { - false - } + return try { + host != null && username != null && token != null + } catch (ex: Exception) { + false + } } fun clearPreferences() { - sharedPreferences.edit { - remove(KEY_HOST) - remove(KEY_USERNAME) - remove(KEY_TOKEN) + sharedPreferences.edit { + remove(KEY_HOST) + remove(KEY_USERNAME) + remove(KEY_TOKEN) - remove(KEY_SERVER_VERSION) + remove(KEY_SERVER_VERSION) - remove(CACHE_FORCE_ENABLED) + remove(CACHE_FORCE_ENABLED) - remove(KEY_PREFERRED_LIBRARY_ID) - remove(KEY_PREFERRED_LIBRARY_NAME) + remove(KEY_PREFERRED_LIBRARY_ID) + remove(KEY_PREFERRED_LIBRARY_NAME) - remove(KEY_PREFERRED_PLAYBACK_SPEED) - } + remove(KEY_PREFERRED_PLAYBACK_SPEED) + } } fun saveHost(host: String) = sharedPreferences.edit { putString(KEY_HOST, host) } + fun getHost(): String? = sharedPreferences.getString(KEY_HOST, null) fun getDeviceId(): String { - val existingDeviceId = sharedPreferences.getString(KEY_DEVICE_ID, null) + val existingDeviceId = sharedPreferences.getString(KEY_DEVICE_ID, null) - if (existingDeviceId != null) { - return existingDeviceId - } + if (existingDeviceId != null) { + return existingDeviceId + } - return UUID - .randomUUID() - .toString() - .also { sharedPreferences.edit { putString(KEY_DEVICE_ID, it) } } + return UUID + .randomUUID() + .toString() + .also { sharedPreferences.edit { putString(KEY_DEVICE_ID, it) } } } // Once the different channel will supported, this shall be extended fun getChannel() = ChannelCode.AUDIOBOOKSHELF fun getPreferredLibrary(): Library? { - val id = getPreferredLibraryId() ?: return null - val name = getPreferredLibraryName() ?: return null + val id = getPreferredLibraryId() ?: return null + val name = getPreferredLibraryName() ?: return null - val type = getPreferredLibraryType() + val type = getPreferredLibraryType() - return Library( - id = id, - title = name, - type = type, - ) + return Library( + id = id, + title = name, + type = type, + ) } fun savePreferredLibrary(library: Library) { - saveActiveLibraryId(library.id) - saveActiveLibraryName(library.title) - saveActiveLibraryType(library.type) + saveActiveLibraryId(library.id) + saveActiveLibraryName(library.title) + saveActiveLibraryType(library.type) } fun saveLibraryOrdering(configuration: LibraryOrderingConfiguration) { - sharedPreferences.edit { - val json = gson.toJson(configuration) - putString(KEY_PREFERRED_LIBRARY_ORDERING, json) - } + sharedPreferences.edit { + val json = gson.toJson(configuration) + putString(KEY_PREFERRED_LIBRARY_ORDERING, json) + } } fun getLibraryOrdering(): LibraryOrderingConfiguration { - val json = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ORDERING, null) - val type = object : TypeToken() {}.type + val json = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ORDERING, null) + val type = object : TypeToken() {}.type - return when (json == null) { - true -> LibraryOrderingConfiguration.default - false -> gson.fromJson(json, type) - } + return when (json == null) { + true -> LibraryOrderingConfiguration.default + false -> gson.fromJson(json, type) + } } fun saveColorScheme(colorScheme: ColorScheme) = - sharedPreferences.edit { putString(KEY_PREFERRED_COLOR_SCHEME, colorScheme.name) } + sharedPreferences.edit { + putString(KEY_PREFERRED_COLOR_SCHEME, colorScheme.name) + } fun getColorScheme(): ColorScheme = - sharedPreferences.getString(KEY_PREFERRED_COLOR_SCHEME, ColorScheme.FOLLOW_SYSTEM.name) - ?.let { ColorScheme.valueOf(it) } - ?: ColorScheme.FOLLOW_SYSTEM + sharedPreferences + .getString(KEY_PREFERRED_COLOR_SCHEME, ColorScheme.FOLLOW_SYSTEM.name) + ?.let { ColorScheme.valueOf(it) } + ?: ColorScheme.FOLLOW_SYSTEM - fun savePlaybackSpeed(factor: Float) = - sharedPreferences.edit { putFloat(KEY_PREFERRED_PLAYBACK_SPEED, factor) } + fun savePlaybackSpeed(factor: Float) = sharedPreferences.edit { putFloat(KEY_PREFERRED_PLAYBACK_SPEED, factor) } - fun getPlaybackSpeed(): Float = - sharedPreferences.getFloat(KEY_PREFERRED_PLAYBACK_SPEED, 1f) + fun getPlaybackSpeed(): Float = sharedPreferences.getFloat(KEY_PREFERRED_PLAYBACK_SPEED, 1f) - val playingBookFlow: Flow = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + val playingBookFlow: Flow = + callbackFlow { + val listener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == KEY_PLAYING_BOOK) { - trySend(getPlayingBook()) + trySend(getPlayingBook()) } - } + } sharedPreferences.registerOnSharedPreferenceChangeListener(listener) trySend(getPlayingBook()) awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) } - }.distinctUntilChanged() + }.distinctUntilChanged() - val colorSchemeFlow: Flow = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + val colorSchemeFlow: Flow = + callbackFlow { + val listener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == KEY_PREFERRED_COLOR_SCHEME) { - trySend(getColorScheme()) + trySend(getColorScheme()) } - } + } sharedPreferences.registerOnSharedPreferenceChangeListener(listener) trySend(getColorScheme()) awaitClose { sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) } - }.distinctUntilChanged() + }.distinctUntilChanged() - private fun saveActiveLibraryId(host: String) = - sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_ID, host) } + private fun saveActiveLibraryId(host: String) = sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_ID, host) } - private fun getPreferredLibraryId(): String? = - sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ID, null) + private fun getPreferredLibraryId(): String? = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_ID, null) - private fun saveActiveLibraryName(host: String) = - sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_NAME, host) } + private fun saveActiveLibraryName(host: String) = sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_NAME, host) } private fun getPreferredLibraryType(): LibraryType = - sharedPreferences - .getString(KEY_PREFERRED_LIBRARY_TYPE, null) - ?.let { LibraryType.valueOf(it) } - ?: LibraryType.LIBRARY // We have to set the library type AUDIOBOOKSHELF_LIBRARY for backward compatibility + sharedPreferences + .getString(KEY_PREFERRED_LIBRARY_TYPE, null) + ?.let { LibraryType.valueOf(it) } + ?: LibraryType.LIBRARY // We have to set the library type AUDIOBOOKSHELF_LIBRARY for backward compatibility private fun saveActiveLibraryType(type: LibraryType) = - sharedPreferences.edit { putString(KEY_PREFERRED_LIBRARY_TYPE, type.name) } + sharedPreferences.edit { + putString(KEY_PREFERRED_LIBRARY_TYPE, type.name) + } - private fun getPreferredLibraryName(): String? = - sharedPreferences.getString(KEY_PREFERRED_LIBRARY_NAME, null) + private fun getPreferredLibraryName(): String? = sharedPreferences.getString(KEY_PREFERRED_LIBRARY_NAME, null) - fun enableForceCache() = - sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, true) } + fun enableForceCache() = sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, true) } - fun disableForceCache() = - sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, false) } + fun disableForceCache() = sharedPreferences.edit { putBoolean(CACHE_FORCE_ENABLED, false) } - fun isForceCache(): Boolean { - return sharedPreferences.getBoolean(CACHE_FORCE_ENABLED, false) - } + fun isForceCache(): Boolean = sharedPreferences.getBoolean(CACHE_FORCE_ENABLED, false) - fun saveUsername(username: String) = - sharedPreferences.edit { putString(KEY_USERNAME, username) } + fun saveUsername(username: String) = sharedPreferences.edit { putString(KEY_USERNAME, username) } fun getUsername(): String? = sharedPreferences.getString(KEY_USERNAME, null) - fun saveServerVersion(version: String) = - sharedPreferences.edit { putString(KEY_SERVER_VERSION, version) } + fun saveServerVersion(version: String) = sharedPreferences.edit { putString(KEY_SERVER_VERSION, version) } fun getServerVersion(): String? = sharedPreferences.getString(KEY_SERVER_VERSION, null) fun saveToken(password: String) { - val encrypted = encrypt(password) - sharedPreferences.edit { putString(KEY_TOKEN, encrypted) } + val encrypted = encrypt(password) + sharedPreferences.edit { putString(KEY_TOKEN, encrypted) } } fun getToken(): String? { - val encrypted = sharedPreferences.getString(KEY_TOKEN, null) ?: return null - return decrypt(encrypted) + val encrypted = sharedPreferences.getString(KEY_TOKEN, null) ?: return null + return decrypt(encrypted) } fun savePlayingBook(book: DetailedItem?) { - if (null == book) { - sharedPreferences.edit { - remove(KEY_PLAYING_BOOK) - } - return - } - + if (null == book) { sharedPreferences.edit { - val json = gson.toJson(book) - putString(KEY_PLAYING_BOOK, json) + remove(KEY_PLAYING_BOOK) } + return + } + + sharedPreferences.edit { + val json = gson.toJson(book) + putString(KEY_PLAYING_BOOK, json) + } } fun getPlayingBook(): DetailedItem? { - val json = sharedPreferences.getString(KEY_PLAYING_BOOK, null) - val type = object : TypeToken() {}.type + val json = sharedPreferences.getString(KEY_PLAYING_BOOK, null) + val type = object : TypeToken() {}.type - return when (json == null) { - true -> null - false -> gson.fromJson(json, type) - } + return when (json == null) { + true -> null + false -> gson.fromJson(json, type) + } } fun saveSeekTime(seekTime: SeekTime) { - val json = gson.toJson(seekTime) - sharedPreferences.edit(commit = true) { putString(KEY_PREFERRED_SEEK_TIME, json) } + val json = gson.toJson(seekTime) + sharedPreferences.edit(commit = true) { putString(KEY_PREFERRED_SEEK_TIME, json) } } fun getSeekTime(): SeekTime { - val json = sharedPreferences.getString(KEY_PREFERRED_SEEK_TIME, null) - val type = object : TypeToken() {}.type + val json = sharedPreferences.getString(KEY_PREFERRED_SEEK_TIME, null) + val type = object : TypeToken() {}.type - return when (json == null) { - true -> SeekTime.Default - false -> gson.fromJson(json, type) - } + return when (json == null) { + true -> SeekTime.Default + false -> gson.fromJson(json, type) + } } fun saveRewindOnPause(rewindOnPauseTime: RewindOnPauseTime) { - val json = gson.toJson(rewindOnPauseTime) - sharedPreferences.edit(commit = true) { putString(KEY_REWIND_ON_PAUSE, json) } + val json = gson.toJson(rewindOnPauseTime) + sharedPreferences.edit(commit = true) { putString(KEY_REWIND_ON_PAUSE, json) } } fun getRewindOnPause(): RewindOnPauseTime { - val json = sharedPreferences.getString(KEY_REWIND_ON_PAUSE, null) - val type = object : TypeToken() {}.type + val json = sharedPreferences.getString(KEY_REWIND_ON_PAUSE, null) + val type = object : TypeToken() {}.type - return when (json == null) { - true -> RewindOnPauseTime.Default - false -> gson.fromJson(json, type) - } + return when (json == null) { + true -> RewindOnPauseTime.Default + false -> gson.fromJson(json, type) + } } fun saveCustomHeaders(headers: List) { - sharedPreferences.edit { - val json = gson.toJson(headers) - putString(KEY_CUSTOM_HEADERS, json) - } + sharedPreferences.edit { + val json = gson.toJson(headers) + putString(KEY_CUSTOM_HEADERS, json) + } } fun getCustomHeaders(): List { - val json = sharedPreferences.getString(KEY_CUSTOM_HEADERS, null) - val type = object : TypeToken>() {}.type + val json = sharedPreferences.getString(KEY_CUSTOM_HEADERS, null) + val type = object : TypeToken>() {}.type - return when (json == null) { - true -> emptyList() - false -> gson.fromJson(json, type) - } + return when (json == null) { + true -> emptyList() + false -> gson.fromJson(json, type) + } } companion object { + private const val KEY_ALIAS = "secure_key_alias" + private const val KEY_HOST = "host" + private const val KEY_USERNAME = "username" + private const val KEY_TOKEN = "token" + private const val CACHE_FORCE_ENABLED = "cache_force_enabled" - private const val KEY_ALIAS = "secure_key_alias" - private const val KEY_HOST = "host" - private const val KEY_USERNAME = "username" - private const val KEY_TOKEN = "token" - private const val CACHE_FORCE_ENABLED = "cache_force_enabled" + private const val KEY_SERVER_VERSION = "server_version" - private const val KEY_SERVER_VERSION = "server_version" + private const val KEY_DEVICE_ID = "device_id" - private const val KEY_DEVICE_ID = "device_id" + private const val KEY_PREFERRED_LIBRARY_ID = "preferred_library_id" + private const val KEY_PREFERRED_LIBRARY_NAME = "preferred_library_name" + private const val KEY_PREFERRED_LIBRARY_TYPE = "preferred_library_type" - private const val KEY_PREFERRED_LIBRARY_ID = "preferred_library_id" - private const val KEY_PREFERRED_LIBRARY_NAME = "preferred_library_name" - private const val KEY_PREFERRED_LIBRARY_TYPE = "preferred_library_type" + private const val KEY_PREFERRED_PLAYBACK_SPEED = "preferred_playback_speed" + private const val KEY_PREFERRED_SEEK_TIME = "preferred_seek_time" - private const val KEY_PREFERRED_PLAYBACK_SPEED = "preferred_playback_speed" - private const val KEY_PREFERRED_SEEK_TIME = "preferred_seek_time" + private const val KEY_REWIND_ON_PAUSE = "rewind_on_pause" - private const val KEY_REWIND_ON_PAUSE = "rewind_on_pause" + private const val KEY_PREFERRED_COLOR_SCHEME = "preferred_color_scheme" + private const val KEY_PREFERRED_LIBRARY_ORDERING = "preferred_library_ordering" - private const val KEY_PREFERRED_COLOR_SCHEME = "preferred_color_scheme" - private const val KEY_PREFERRED_LIBRARY_ORDERING = "preferred_library_ordering" + private const val KEY_CUSTOM_HEADERS = "custom_headers" - private const val KEY_CUSTOM_HEADERS = "custom_headers" + private const val KEY_PLAYING_BOOK = "playing_book" - private const val KEY_PLAYING_BOOK = "playing_book" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val TRANSFORMATION = "AES/GCM/NoPadding" + private fun getSecretKey(): SecretKey { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) - private fun getSecretKey(): SecretKey { - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) - keyStore.load(null) + keyStore.getKey(KEY_ALIAS, null)?.let { + return it as SecretKey + } - keyStore.getKey(KEY_ALIAS, null)?.let { - return it as SecretKey - } - - val keyGenerator = - KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val keyGenParameterSpec = + KeyGenParameterSpec + .Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build() - keyGenerator.init(keyGenParameterSpec) - return keyGenerator.generateKey() + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + keyGenerator.init(keyGenParameterSpec) + return keyGenerator.generateKey() + } + + private fun encrypt(data: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) + + val cipherText = cipher.doFinal(data.toByteArray()) + val ivAndCipherText = cipher.iv + cipherText + + return Base64.encodeToString(ivAndCipherText, Base64.DEFAULT) + } + + private fun decrypt(data: String): String? { + val decodedData = Base64.decode(data, Base64.DEFAULT) + val iv = decodedData.sliceArray(0 until 12) + val cipherText = decodedData.sliceArray(12 until decodedData.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) + + return try { + String(cipher.doFinal(cipherText)) + } catch (ex: Exception) { + null } + } - private fun encrypt(data: String): String { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, getSecretKey()) - - val cipherText = cipher.doFinal(data.toByteArray()) - val ivAndCipherText = cipher.iv + cipherText - - return Base64.encodeToString(ivAndCipherText, Base64.DEFAULT) - } - - private fun decrypt(data: String): String? { - val decodedData = Base64.decode(data, Base64.DEFAULT) - val iv = decodedData.sliceArray(0 until 12) - val cipherText = decodedData.sliceArray(12 until decodedData.size) - - val cipher = Cipher.getInstance(TRANSFORMATION) - val spec = GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) - - return try { - String(cipher.doFinal(cipherText)) - } catch (ex: Exception) { - null - } - } - - private val gson = Gson() + private val gson = Gson() } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt b/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt index 4262d59d..adf004dd 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/MediaModule.kt @@ -33,136 +33,146 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object MediaModule { + @OptIn(UnstableApi::class) + @Provides + @Singleton + fun provideExoPlayer( + @ApplicationContext context: Context, + ): ExoPlayer = + ExoPlayer + .Builder(context) + .setSeekBackIncrementMs(10_000) + .setSeekForwardIncrementMs(30_000) + .setHandleAudioBecomingNoisy(true) + .setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + true, + ).build() - @OptIn(UnstableApi::class) - @Provides - @Singleton - fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer { - return ExoPlayer.Builder(context) - .setSeekBackIncrementMs(10_000) - .setSeekForwardIncrementMs(30_000) - .setHandleAudioBecomingNoisy(true) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), - true, - ) - .build() - } + @OptIn(UnstableApi::class) + @Provides + @Singleton + fun provideMediaSession( + @ApplicationContext context: Context, + preferences: LissenSharedPreferences, + mediaRepository: MediaRepository, + exoPlayer: ExoPlayer, + ): MediaSession { + val sessionActivityPendingIntent = + PendingIntent.getActivity( + context, + 0, + Intent(context, AppActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) - @OptIn(UnstableApi::class) - @Provides - @Singleton - fun provideMediaSession( - @ApplicationContext context: Context, - preferences: LissenSharedPreferences, - mediaRepository: MediaRepository, - exoPlayer: ExoPlayer, - ): MediaSession { - val sessionActivityPendingIntent = PendingIntent.getActivity( - context, - 0, - Intent(context, AppActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) + return MediaSession + .Builder(context, exoPlayer) + .setCallback( + object : MediaSession.Callback { + override fun onMediaButtonEvent( + session: MediaSession, + controllerInfo: MediaSession.ControllerInfo, + intent: Intent, + ): Boolean { + Log.d(TAG, "Executing media button event from: $controllerInfo") - return MediaSession.Builder(context, exoPlayer) - .setCallback(object : MediaSession.Callback { - override fun onMediaButtonEvent( - session: MediaSession, - controllerInfo: MediaSession.ControllerInfo, - intent: Intent, - ): Boolean { - Log.d(TAG, "Executing media button event from: $controllerInfo") + val keyEvent = + intent + .getParcelableExtra(Intent.EXTRA_KEY_EVENT) + ?: return super.onMediaButtonEvent(session, controllerInfo, intent) - val keyEvent = intent - .getParcelableExtra(Intent.EXTRA_KEY_EVENT) - ?: return super.onMediaButtonEvent(session, controllerInfo, intent) + Log.d(TAG, "Got media key event: $keyEvent") - Log.d(TAG, "Got media key event: $keyEvent") + if (keyEvent.action != KeyEvent.ACTION_DOWN) { + return super.onMediaButtonEvent(session, controllerInfo, intent) + } - if (keyEvent.action != KeyEvent.ACTION_DOWN) { - return super.onMediaButtonEvent(session, controllerInfo, intent) - } + when (keyEvent.keyCode) { + KEYCODE_MEDIA_NEXT -> { + mediaRepository.forward() + return true + } - when (keyEvent.keyCode) { - KEYCODE_MEDIA_NEXT -> { - mediaRepository.forward() - return true - } + KEYCODE_MEDIA_PREVIOUS -> { + mediaRepository.rewind() + return true + } - KEYCODE_MEDIA_PREVIOUS -> { - mediaRepository.rewind() - return true - } + else -> return super.onMediaButtonEvent(session, controllerInfo, intent) + } + } - else -> return super.onMediaButtonEvent(session, controllerInfo, intent) - } - } + @OptIn(UnstableApi::class) + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): MediaSession.ConnectionResult { + val rewindCommand = SessionCommand(REWIND_COMMAND, Bundle.EMPTY) + val forwardCommand = SessionCommand(FORWARD_COMMAND, Bundle.EMPTY) + val seekTime = preferences.getSeekTime() - @OptIn(UnstableApi::class) - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo, - ): MediaSession.ConnectionResult { - val rewindCommand = SessionCommand(REWIND_COMMAND, Bundle.EMPTY) - val forwardCommand = SessionCommand(FORWARD_COMMAND, Bundle.EMPTY) - val seekTime = preferences.getSeekTime() + val sessionCommands = + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(rewindCommand) + .add(forwardCommand) + .build() - val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() - .add(rewindCommand) - .add(forwardCommand) - .build() + val rewindButton = + CommandButton + .Builder(provideRewindCommand(seekTime.rewind)) + .setSessionCommand(rewindCommand) + .setDisplayName("Rewind") + .setEnabled(true) + .build() - val rewindButton = CommandButton - .Builder(provideRewindCommand(seekTime.rewind)) - .setSessionCommand(rewindCommand) - .setDisplayName("Rewind") - .setEnabled(true) - .build() + val forwardButton = + CommandButton + .Builder(provideForwardCommand(seekTime.forward)) + .setSessionCommand(forwardCommand) + .setDisplayName("Forward") + .setEnabled(true) + .build() - val forwardButton = CommandButton - .Builder(provideForwardCommand(seekTime.forward)) - .setSessionCommand(forwardCommand) - .setDisplayName("Forward") - .setEnabled(true) - .build() + return MediaSession + .ConnectionResult + .AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .setCustomLayout(listOf(rewindButton, forwardButton)) + .build() + } - return MediaSession - .ConnectionResult - .AcceptedResultBuilder(session) - .setAvailableSessionCommands(sessionCommands) - .setCustomLayout(listOf(rewindButton, forwardButton)) - .build() - } + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + Log.d(TAG, "Executing: ${customCommand.customAction}") - override fun onCustomCommand( - session: MediaSession, - controller: MediaSession.ControllerInfo, - customCommand: SessionCommand, - args: Bundle, - ): ListenableFuture { - Log.d(TAG, "Executing: ${customCommand.customAction}") + when (customCommand.customAction) { + FORWARD_COMMAND -> mediaRepository.forward() + REWIND_COMMAND -> mediaRepository.rewind() + } - when (customCommand.customAction) { - FORWARD_COMMAND -> mediaRepository.forward() - REWIND_COMMAND -> mediaRepository.rewind() - } + return super.onCustomCommand(session, controller, customCommand, args) + } + }, + ).setSessionActivity(sessionActivityPendingIntent) + .build() + } - return super.onCustomCommand(session, controller, customCommand, args) - } - }) - .setSessionActivity(sessionActivityPendingIntent) - .build() - } + private fun provideRewindCommand(seekTime: SeekTimeOption) = CommandButton.ICON_SKIP_BACK - private fun provideRewindCommand(seekTime: SeekTimeOption) = CommandButton.ICON_SKIP_BACK - private fun provideForwardCommand(seekTime: SeekTimeOption) = CommandButton.ICON_SKIP_FORWARD + private fun provideForwardCommand(seekTime: SeekTimeOption) = CommandButton.ICON_SKIP_FORWARD - private const val REWIND_COMMAND = "notification_rewind" - private const val FORWARD_COMMAND = "notification_forward" + private const val REWIND_COMMAND = "notification_rewind" + private const val FORWARD_COMMAND = "notification_forward" - private const val TAG = "MediaModule" + private const val TAG = "MediaModule" } diff --git a/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt b/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt index 36af3c31..bcfb88b9 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/MediaRepository.kt @@ -46,18 +46,20 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class MediaRepository @Inject constructor( +class MediaRepository + @Inject + constructor( @ApplicationContext private val context: Context, private val preferences: LissenSharedPreferences, private val mediaChannel: LissenMediaProvider, -) { - + ) { private lateinit var mediaController: MediaController - private val token = SessionToken( + private val token = + SessionToken( context, ComponentName(context, PlaybackService::class.java), - ) + ) private val _isPlaying = MutableLiveData(false) val isPlaying: LiveData = _isPlaying @@ -81,446 +83,474 @@ class MediaRepository @Inject constructor( private val _playbackSpeed = MutableLiveData(preferences.getPlaybackSpeed()) val playbackSpeed: LiveData = _playbackSpeed - private val _currentChapterIndex = MediatorLiveData().apply { + private val _currentChapterIndex = + MediatorLiveData().apply { addSource(totalPosition) { updateCurrentTrackData() } addSource(playingBook) { updateCurrentTrackData() } - } + } val currentChapterIndex: LiveData = _currentChapterIndex - private val _currentChapterPosition = MediatorLiveData().apply { + private val _currentChapterPosition = + MediatorLiveData().apply { addSource(totalPosition) { updateCurrentTrackData() } addSource(playingBook) { updateCurrentTrackData() } - } + } val currentChapterPosition: LiveData = _currentChapterPosition - private val _currentChapterDuration = MediatorLiveData().apply { + private val _currentChapterDuration = + MediatorLiveData().apply { addSource(totalPosition) { updateCurrentTrackData() } addSource(playingBook) { updateCurrentTrackData() } - } + } val currentChapterDuration: LiveData = _currentChapterDuration private val handler = Handler(Looper.getMainLooper()) init { - val controllerBuilder = MediaController.Builder(context, token) - val futureController = controllerBuilder.buildAsync() + val controllerBuilder = MediaController.Builder(context, token) + val futureController = controllerBuilder.buildAsync() - Futures.addCallback( - futureController, - object : FutureCallback { - override fun onSuccess(controller: MediaController) { - mediaController = controller + Futures.addCallback( + futureController, + object : FutureCallback { + override fun onSuccess(controller: MediaController) { + mediaController = controller - LocalBroadcastManager - .getInstance(context) - .registerReceiver(playbackReadyReceiver, IntentFilter(PLAYBACK_READY)) + LocalBroadcastManager + .getInstance(context) + .registerReceiver(playbackReadyReceiver, IntentFilter(PLAYBACK_READY)) - LocalBroadcastManager - .getInstance(context) - .registerReceiver(timerExpiredReceiver, IntentFilter(TIMER_EXPIRED)) + LocalBroadcastManager + .getInstance(context) + .registerReceiver(timerExpiredReceiver, IntentFilter(TIMER_EXPIRED)) - mediaController.addListener(object : Player.Listener { - - override fun onIsPlayingChanged(isPlaying: Boolean) { - _isPlaying.value = isPlaying - } - - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - mediaController.seekTo(0, 0) - mediaController.pause() - } - } - }) + mediaController.addListener( + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _isPlaying.value = isPlaying } - override fun onFailure(t: Throwable) { - Log.e(TAG, "Unable to add callback to player") + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + mediaController.seekTo(0, 0) + mediaController.pause() + } } - }, - MoreExecutors.directExecutor(), - ) + }, + ) + } + + override fun onFailure(t: Throwable) { + Log.e(TAG, "Unable to add callback to player") + } + }, + MoreExecutors.directExecutor(), + ) } - private val playbackReadyReceiver = object : BroadcastReceiver() { + private val playbackReadyReceiver = + object : BroadcastReceiver() { @Suppress("DEPRECATION") - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == PLAYBACK_READY) { - val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + if (intent?.action == PLAYBACK_READY) { + val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem - book?.let { - CoroutineScope(Dispatchers.Main).launch { - updateProgress(book).await() - startUpdatingProgress(book) + book?.let { + CoroutineScope(Dispatchers.Main).launch { + updateProgress(book).await() + startUpdatingProgress(book) - _playingBook.postValue(it) - preferences.savePlayingBook(it) + _playingBook.postValue(it) + preferences.savePlayingBook(it) - _isPlaybackReady.postValue(true) + _isPlaybackReady.postValue(true) - if (_playAfterPrepare.value == true) { - _playAfterPrepare.postValue(false) - play() - } - } + if (_playAfterPrepare.value == true) { + _playAfterPrepare.postValue(false) + play() } + } } + } } - } + } - private val timerExpiredReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == TIMER_EXPIRED) { - _timerOption.postValue(null) - } + private val timerExpiredReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + if (intent?.action == TIMER_EXPIRED) { + _timerOption.postValue(null) + } } - } + } fun updateTimer( - timerOption: TimerOption?, - position: Double? = null, + timerOption: TimerOption?, + position: Double? = null, ) { - _timerOption.postValue(timerOption) + _timerOption.postValue(timerOption) - when (timerOption) { - is DurationTimerOption -> scheduleServiceTimer(timerOption.duration * 60.0) + when (timerOption) { + is DurationTimerOption -> scheduleServiceTimer(timerOption.duration * 60.0) - is CurrentEpisodeTimerOption -> { - val playingBook = playingBook.value ?: return - val currentPosition = position ?: totalPosition.value ?: return + is CurrentEpisodeTimerOption -> { + val playingBook = playingBook.value ?: return + val currentPosition = position ?: totalPosition.value ?: return - val chapterDuration = calculateChapterIndex(playingBook, currentPosition) - .let { playingBook.chapters[it] } - .duration + val chapterDuration = + calculateChapterIndex(playingBook, currentPosition) + .let { playingBook.chapters[it] } + .duration - val chapterPosition = calculateChapterPosition( - book = playingBook, - overallPosition = currentPosition, - ) + val chapterPosition = + calculateChapterPosition( + book = playingBook, + overallPosition = currentPosition, + ) - scheduleServiceTimer(chapterDuration - chapterPosition) - } - - null -> cancelServiceTimer() + scheduleServiceTimer(chapterDuration - chapterPosition) } + + null -> cancelServiceTimer() + } } fun rewindOnPause() { - totalPosition - .value - ?.let { seekTo(it - getSeekTime(preferences.getRewindOnPause().time)) } + totalPosition + .value + ?.let { seekTo(it - getSeekTime(preferences.getRewindOnPause().time)) } } fun rewind() { - totalPosition - .value - ?.let { seekTo(it - getSeekTime(preferences.getSeekTime().rewind)) } + totalPosition + .value + ?.let { seekTo(it - getSeekTime(preferences.getSeekTime().rewind)) } } fun forward() { - totalPosition - .value - ?.let { seekTo(it + getSeekTime(preferences.getSeekTime().forward)) } + totalPosition + .value + ?.let { seekTo(it + getSeekTime(preferences.getSeekTime().forward)) } } fun setChapter(index: Int) { - val book = playingBook.value ?: return - try { - val chapterStartsAt = book - .chapters[index] - .start + val book = playingBook.value ?: return + try { + val chapterStartsAt = + book + .chapters[index] + .start - seekTo(chapterStartsAt) - } catch (ex: Exception) { - return - } + seekTo(chapterStartsAt) + } catch (ex: Exception) { + return + } } fun clearPlayingBook() { - pause() - _playingBook.postValue(null) - preferences.savePlayingBook(null) + pause() + _playingBook.postValue(null) + preferences.savePlayingBook(null) } fun setChapterPosition(chapterPosition: Double) { - val book = playingBook.value ?: return - val overallPosition = totalPosition.value ?: return + val book = playingBook.value ?: return + val overallPosition = totalPosition.value ?: return - val currentIndex = calculateChapterIndex(book, overallPosition) + val currentIndex = calculateChapterIndex(book, overallPosition) - if (currentIndex < 0) { - return - } + if (currentIndex < 0) { + return + } - try { - val absolutePosition = currentIndex - .let { chapterIndex -> book.chapters[chapterIndex].start } - .let { it + chapterPosition } + try { + val absolutePosition = + currentIndex + .let { chapterIndex -> book.chapters[chapterIndex].start } + .let { it + chapterPosition } - seekTo(absolutePosition) - } catch (ex: Exception) { - return - } + seekTo(absolutePosition) + } catch (ex: Exception) { + return + } } fun prepareAndPlay( - book: DetailedItem, - fromBackground: Boolean, + book: DetailedItem, + fromBackground: Boolean, ) { - when (isPlaybackReady.value) { - true -> play() - else -> { - _playAfterPrepare.postValue(true) - startPreparingPlayback(book, fromBackground) - } + when (isPlaybackReady.value) { + true -> play() + else -> { + _playAfterPrepare.postValue(true) + startPreparingPlayback(book, fromBackground) } + } } fun togglePlayPause() { - if (currentChapterIndex.value == -1) { - Log.w(TAG, "Tried to toggle play/pause in the empty book. Skipping") - return - } + if (currentChapterIndex.value == -1) { + Log.w(TAG, "Tried to toggle play/pause in the empty book. Skipping") + return + } - when (isPlaying.value) { - true -> pause() - else -> play() - } + when (isPlaying.value) { + true -> pause() + else -> play() + } } fun setPlaybackSpeed(factor: Float) { - val speed = when { - factor < 0.5f -> 0.5f - factor > 3f -> 3f - else -> factor + val speed = + when { + factor < 0.5f -> 0.5f + factor > 3f -> 3f + else -> factor } - if (::mediaController.isInitialized) { - mediaController.setPlaybackSpeed(speed) - } + if (::mediaController.isInitialized) { + mediaController.setPlaybackSpeed(speed) + } - _playbackSpeed.postValue(speed) - preferences.savePlaybackSpeed(speed) + _playbackSpeed.postValue(speed) + preferences.savePlaybackSpeed(speed) } suspend fun preparePlayback( - bookId: String, - fromBackground: Boolean = false, + bookId: String, + fromBackground: Boolean = false, ) { - coroutineScope { - withContext(Dispatchers.IO) { - mediaChannel - .fetchBook(bookId) - .foldAsync( - onSuccess = { startPreparingPlayback(it, fromBackground) }, - onFailure = { _mediaPreparingError.postValue(true) }, - ) - } + coroutineScope { + withContext(Dispatchers.IO) { + mediaChannel + .fetchBook(bookId) + .foldAsync( + onSuccess = { startPreparingPlayback(it, fromBackground) }, + onFailure = { _mediaPreparingError.postValue(true) }, + ) } + } } fun nextTrack() { - val book = playingBook.value ?: return - val overallPosition = totalPosition.value ?: return + val book = playingBook.value ?: return + val overallPosition = totalPosition.value ?: return - val currentIndex = calculateChapterIndex(book, overallPosition) + val currentIndex = calculateChapterIndex(book, overallPosition) - val nextChapterIndex = currentIndex + 1 - setChapter(nextChapterIndex) + val nextChapterIndex = currentIndex + 1 + setChapter(nextChapterIndex) } fun previousTrack(rewindRequired: Boolean = true) { - val book = playingBook.value ?: return - val overallPosition = totalPosition.value ?: return + val book = playingBook.value ?: return + val overallPosition = totalPosition.value ?: return - val currentIndex = calculateChapterIndex(book, overallPosition) - val chapterPosition = calculateChapterPosition( - book = book, - overallPosition = overallPosition, + val currentIndex = calculateChapterIndex(book, overallPosition) + val chapterPosition = + calculateChapterPosition( + book = book, + overallPosition = overallPosition, ) - val currentIndexReplay = (chapterPosition > CURRENT_TRACK_REPLAY_THRESHOLD || currentIndex == 0) + val currentIndexReplay = (chapterPosition > CURRENT_TRACK_REPLAY_THRESHOLD || currentIndex == 0) - when { - currentIndexReplay && rewindRequired -> setChapter(currentIndex) - currentIndex > 0 -> setChapter(currentIndex - 1) - } + when { + currentIndexReplay && rewindRequired -> setChapter(currentIndex) + currentIndex > 0 -> setChapter(currentIndex - 1) + } } private fun scheduleServiceTimer(delay: Double) { - val intent = Intent(context, PlaybackService::class.java).apply { - action = PlaybackService.ACTION_SET_TIMER - putExtra(TIMER_VALUE_EXTRA, delay) + val intent = + Intent(context, PlaybackService::class.java).apply { + action = PlaybackService.ACTION_SET_TIMER + putExtra(TIMER_VALUE_EXTRA, delay) } - context.startService(intent) + context.startService(intent) } private fun cancelServiceTimer() { - val intent = Intent(context, PlaybackService::class.java).apply { - action = PlaybackService.ACTION_CANCEL_TIMER + val intent = + Intent(context, PlaybackService::class.java).apply { + action = PlaybackService.ACTION_CANCEL_TIMER } - context.startService(intent) + context.startService(intent) } private fun startUpdatingProgress(detailedItem: DetailedItem) { - handler.removeCallbacksAndMessages(null) + handler.removeCallbacksAndMessages(null) - handler.postDelayed( - object : Runnable { - override fun run() { - updateProgress(detailedItem) - handler.postDelayed(this, 500) - } - }, - 500, - ) + handler.postDelayed( + object : Runnable { + override fun run() { + updateProgress(detailedItem) + handler.postDelayed(this, 500) + } + }, + 500, + ) } fun clearPreparedItem() { - timerOption - .value - ?.let { updateTimer(timerOption = null) } + timerOption + .value + ?.let { updateTimer(timerOption = null) } - _mediaPreparingError.postValue(false) - _isPlaybackReady.postValue(false) + _mediaPreparingError.postValue(false) + _isPlaybackReady.postValue(false) } private fun startPreparingPlayback( - book: DetailedItem, - fromBackground: Boolean, + book: DetailedItem, + fromBackground: Boolean, ) { - if (_playingBook.value != book) { - _totalPosition.postValue(0.0) - _isPlaying.postValue(false) + if (_playingBook.value != book) { + _totalPosition.postValue(0.0) + _isPlaying.postValue(false) - val intent = Intent(context, PlaybackService::class.java).apply { - action = PlaybackService.ACTION_SET_PLAYBACK - putExtra(BOOK_EXTRA, book) - } + val intent = + Intent(context, PlaybackService::class.java).apply { + action = PlaybackService.ACTION_SET_PLAYBACK + putExtra(BOOK_EXTRA, book) + } - when (fromBackground) { - true -> context.startForegroundService(intent) - false -> context.startService(intent) - } + when (fromBackground) { + true -> context.startForegroundService(intent) + false -> context.startService(intent) } + } } - private fun updateProgress(detailedItem: DetailedItem): Deferred { - return CoroutineScope(Dispatchers.Main).async { - val currentIndex = mediaController.currentMediaItemIndex - val accumulated = detailedItem.files.take(currentIndex).sumOf { it.duration } - val currentFilePosition = mediaController.currentPosition / 1000.0 + private fun updateProgress(detailedItem: DetailedItem): Deferred = + CoroutineScope(Dispatchers.Main).async { + val currentIndex = mediaController.currentMediaItemIndex + val accumulated = detailedItem.files.take(currentIndex).sumOf { it.duration } + val currentFilePosition = mediaController.currentPosition / 1000.0 - _totalPosition.postValue(accumulated + currentFilePosition) - } - } + _totalPosition.postValue(accumulated + currentFilePosition) + } private fun play() { - val intent = Intent(context, PlaybackService::class.java).apply { - action = PlaybackService.ACTION_PLAY + val intent = + Intent(context, PlaybackService::class.java).apply { + action = PlaybackService.ACTION_PLAY } - context.startForegroundService(intent) + context.startForegroundService(intent) } private fun pause() { - val intent = Intent(context, PlaybackService::class.java).apply { - action = PlaybackService.ACTION_PAUSE + val intent = + Intent(context, PlaybackService::class.java).apply { + action = PlaybackService.ACTION_PAUSE } - context.startService(intent) + context.startService(intent) } private fun seekTo(position: Double) { - val book = playingBook.value ?: return + val book = playingBook.value ?: return - if (book.chapters.isEmpty()) { - Log.d(TAG, "Tried to seek on the empty book") - return + if (book.chapters.isEmpty()) { + Log.d(TAG, "Tried to seek on the empty book") + return + } + + val overallDuration = + book + .chapters + .sumOf { it.duration } + + val current = totalPosition.value ?: 0.0 + + val direction = + when (current > maxOf(0.0, position)) { + true -> ScrollingDirection.BACKWARD + false -> ScrollingDirection.FORWARD } - val overallDuration = book - .chapters - .sumOf { it.duration } + var safePosition = minOf(overallDuration, maxOf(0.0, position)) - val current = totalPosition.value ?: 0.0 + while (book.chapters[calculateChapterIndex(book, safePosition)].available.not()) { + val chapterIndex = + when (direction) { + ScrollingDirection.FORWARD -> calculateChapterIndex(book, safePosition) + 1 + ScrollingDirection.BACKWARD -> calculateChapterIndex(book, safePosition) - 1 + } - val direction = when (current > maxOf(0.0, position)) { - true -> ScrollingDirection.BACKWARD - false -> ScrollingDirection.FORWARD + safePosition = + when { + chapterIndex in 0..book.chapters.lastIndex -> book.chapters[chapterIndex].start + else -> break + } + } + + val intent = + Intent(context, PlaybackService::class.java).apply { + action = ACTION_SEEK_TO + + putExtra(BOOK_EXTRA, playingBook.value) + putExtra(POSITION, safePosition) } - var safePosition = minOf(overallDuration, maxOf(0.0, position)) + context.startService(intent) - while (book.chapters[calculateChapterIndex(book, safePosition)].available.not()) { - val chapterIndex = when (direction) { - ScrollingDirection.FORWARD -> calculateChapterIndex(book, safePosition) + 1 - ScrollingDirection.BACKWARD -> calculateChapterIndex(book, safePosition) - 1 - } + when (_timerOption.value) { + is CurrentEpisodeTimerOption -> + updateTimer( + timerOption = _timerOption.value, + position = safePosition, + ) - safePosition = when { - chapterIndex in 0..book.chapters.lastIndex -> book.chapters[chapterIndex].start - else -> break - } - } - - val intent = Intent(context, PlaybackService::class.java).apply { - action = ACTION_SEEK_TO - - putExtra(BOOK_EXTRA, playingBook.value) - putExtra(POSITION, safePosition) - } - - context.startService(intent) - - when (_timerOption.value) { - is CurrentEpisodeTimerOption -> updateTimer( - timerOption = _timerOption.value, - position = safePosition, - ) - - is DurationTimerOption -> Unit - null -> Unit - } + is DurationTimerOption -> Unit + null -> Unit + } } private fun updateCurrentTrackData() { - val book = playingBook.value ?: return - val totalPosition = totalPosition.value ?: return + val book = playingBook.value ?: return + val totalPosition = totalPosition.value ?: return - val trackIndex = calculateChapterIndex(book, totalPosition) - val trackPosition = calculateChapterPosition(book, totalPosition) + val trackIndex = calculateChapterIndex(book, totalPosition) + val trackPosition = calculateChapterPosition(book, totalPosition) - _currentChapterIndex.postValue(trackIndex) - _currentChapterPosition.postValue(trackPosition) - _currentChapterDuration.postValue( - book - .chapters - .getOrNull(trackIndex) - ?.duration - ?: 0.0, - ) + _currentChapterIndex.postValue(trackIndex) + _currentChapterPosition.postValue(trackPosition) + _currentChapterDuration.postValue( + book + .chapters + .getOrNull(trackIndex) + ?.duration + ?: 0.0, + ) } private companion object { + private const val CURRENT_TRACK_REPLAY_THRESHOLD = 5 + private const val TAG = "MediaRepository" - private const val CURRENT_TRACK_REPLAY_THRESHOLD = 5 - private const val TAG = "MediaRepository" - - private fun getSeekTime(option: SeekTimeOption?): Long = when (option) { - SeekTimeOption.SEEK_5 -> 5L - SeekTimeOption.SEEK_10 -> 10L - SeekTimeOption.SEEK_15 -> 15L - SeekTimeOption.SEEK_30 -> 30L - SeekTimeOption.SEEK_60 -> 60L - else -> 30L + private fun getSeekTime(option: SeekTimeOption?): Long = + when (option) { + SeekTimeOption.SEEK_5 -> 5L + SeekTimeOption.SEEK_10 -> 10L + SeekTimeOption.SEEK_15 -> 15L + SeekTimeOption.SEEK_30 -> 30L + SeekTimeOption.SEEK_60 -> 60L + else -> 30L } } -} + } enum class ScrollingDirection { - FORWARD, - BACKWARD, + FORWARD, + BACKWARD, } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterIndex.kt b/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterIndex.kt index 609437e7..ed41d542 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterIndex.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterIndex.kt @@ -2,15 +2,18 @@ package org.grakovne.lissen.playback.service import org.grakovne.lissen.domain.DetailedItem -fun calculateChapterIndex(item: DetailedItem, totalPosition: Double): Int { - var accumulatedDuration = 0.0 +fun calculateChapterIndex( + item: DetailedItem, + totalPosition: Double, +): Int { + var accumulatedDuration = 0.0 - for ((index, chapter) in item.chapters.withIndex()) { - accumulatedDuration += chapter.duration - if (totalPosition < accumulatedDuration - 0.1) { - return index - } + for ((index, chapter) in item.chapters.withIndex()) { + accumulatedDuration += chapter.duration + if (totalPosition < accumulatedDuration - 0.1) { + return index } + } - return item.chapters.size - 1 + return item.chapters.size - 1 } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterPosition.kt b/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterPosition.kt index 82ada426..1819aa10 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterPosition.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterPosition.kt @@ -2,16 +2,19 @@ package org.grakovne.lissen.playback.service import org.grakovne.lissen.domain.DetailedItem -fun calculateChapterPosition(book: DetailedItem, overallPosition: Double): Double { - var accumulatedDuration = 0.0 +fun calculateChapterPosition( + book: DetailedItem, + overallPosition: Double, +): Double { + var accumulatedDuration = 0.0 - for (chapter in book.chapters) { - val chapterEnd = accumulatedDuration + chapter.duration - if (overallPosition < chapterEnd - 0.1) { - return (overallPosition - accumulatedDuration) - } - accumulatedDuration = chapterEnd + for (chapter in book.chapters) { + val chapterEnd = accumulatedDuration + chapter.duration + if (overallPosition < chapterEnd - 0.1) { + return (overallPosition - accumulatedDuration) } + accumulatedDuration = chapterEnd + } - return 0.0 + return 0.0 } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/MimeTypeProvider.kt b/app/src/main/java/org/grakovne/lissen/playback/service/MimeTypeProvider.kt index 5390f63d..5e459e88 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/MimeTypeProvider.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/MimeTypeProvider.kt @@ -1,18 +1,18 @@ package org.grakovne.lissen.playback.service class MimeTypeProvider { - - companion object { - fun getSupportedMimeTypes() = listOf( - "audio/flac", - "audio/mp4", - "audio/aac", - "audio/mpeg", - "audio/mp3", - "audio/webm", - "audio/ac3", - "audio/opus", - "audio/vorbis", - ) - } + companion object { + fun getSupportedMimeTypes() = + listOf( + "audio/flac", + "audio/mp4", + "audio/aac", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/ac3", + "audio/opus", + "audio/vorbis", + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationModule.kt b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationModule.kt index 37b1b02e..7593cefb 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationModule.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationModule.kt @@ -10,8 +10,7 @@ import org.grakovne.lissen.common.RunningComponent @Module @InstallIn(SingletonComponent::class) interface PlaybackNotificationModule { - - @Binds - @IntoSet - fun bindPlaybackNotificationService(service: PlaybackNotificationService): RunningComponent + @Binds + @IntoSet + fun bindPlaybackNotificationService(service: PlaybackNotificationService): RunningComponent } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationService.kt b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationService.kt index 96d2186c..5a0ab308 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationService.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationService.kt @@ -12,75 +12,86 @@ import javax.inject.Singleton @Singleton @OptIn(UnstableApi::class) -class PlaybackNotificationService @Inject constructor( +class PlaybackNotificationService + @Inject + constructor( private val exoPlayer: ExoPlayer, private val sharedPreferences: LissenSharedPreferences, -) : RunningComponent { - + ) : RunningComponent { override fun onCreate() { - exoPlayer.addListener(object : Player.Listener { + exoPlayer.addListener( + object : Player.Listener { + override fun onPlayWhenReadyChanged( + playWhenReady: Boolean, + reason: Int, + ) { + super.onPlayWhenReadyChanged(playWhenReady, reason) - override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { - super.onPlayWhenReadyChanged(playWhenReady, reason) + if (playWhenReady) { + exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) + } + } - if (playWhenReady) { - exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) - } + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + val previousIndex = oldPosition.mediaItemIndex + val currentIndex = newPosition.mediaItemIndex + + if (exoPlayer.currentMediaItem?.mediaId != SilenceMediaSource::class.simpleName) { + return } - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int, - ) { - val previousIndex = oldPosition.mediaItemIndex - val currentIndex = newPosition.mediaItemIndex - - if (exoPlayer.currentMediaItem?.mediaId != SilenceMediaSource::class.simpleName) { - return + if (currentIndex != previousIndex) { + val direction = + when ( + currentIndex > previousIndex || + (currentIndex == 0 && previousIndex == exoPlayer.mediaItemCount - 1) + ) { + true -> Direction.FORWARD + false -> Direction.BACKWARD } - if (currentIndex != previousIndex) { - val direction = when (currentIndex > previousIndex || (currentIndex == 0 && previousIndex == exoPlayer.mediaItemCount - 1)) { - true -> Direction.FORWARD - false -> Direction.BACKWARD - } + val nextTrack = + findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer, 0) + nextTrack?.let { exoPlayer.seekTo(it, 0) } - val nextTrack = findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer, 0) - nextTrack?.let { exoPlayer.seekTo(it, 0) } - - if (nextTrack == null || nextTrack < currentIndex) { - exoPlayer.pause() - } - } + if (nextTrack == null || nextTrack < currentIndex) { + exoPlayer.pause() + } } - }) + } + }, + ) } private fun findAvailableTrackIndex( - currentItem: Int, - direction: Direction, - exoPlayer: ExoPlayer, - iteration: Int, + currentItem: Int, + direction: Direction, + exoPlayer: ExoPlayer, + iteration: Int, ): Int? { - if (exoPlayer.getMediaItemAt(currentItem).mediaId != SilenceMediaSource::class.simpleName) { - return currentItem + if (exoPlayer.getMediaItemAt(currentItem).mediaId != SilenceMediaSource::class.simpleName) { + return currentItem + } + + if (iteration > 4096) { + return null + } + + val foundItem = + when (direction) { + Direction.FORWARD -> (currentItem + 1) % exoPlayer.mediaItemCount + Direction.BACKWARD -> if (currentItem - 1 < 0) exoPlayer.mediaItemCount - 1 else currentItem - 1 } - if (iteration > 4096) { - return null - } - - val foundItem = when (direction) { - Direction.FORWARD -> (currentItem + 1) % exoPlayer.mediaItemCount - Direction.BACKWARD -> if (currentItem - 1 < 0) exoPlayer.mediaItemCount - 1 else currentItem - 1 - } - - return findAvailableTrackIndex(foundItem, direction, exoPlayer, iteration + 1) + return findAvailableTrackIndex(foundItem, direction, exoPlayer, iteration + 1) } -} + } private enum class Direction { - FORWARD, - BACKWARD, + FORWARD, + BACKWARD, } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt index 05437f86..e7f132d8 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackService.kt @@ -38,290 +38,300 @@ import javax.inject.Inject @AndroidEntryPoint class PlaybackService : MediaSessionService() { + @Inject + lateinit var exoPlayer: ExoPlayer - @Inject - lateinit var exoPlayer: ExoPlayer + @Inject + lateinit var mediaSession: MediaSession - @Inject - lateinit var mediaSession: MediaSession + @Inject + lateinit var mediaChannel: LissenMediaProvider - @Inject - lateinit var mediaChannel: LissenMediaProvider + @Inject + lateinit var playbackSynchronizationService: PlaybackSynchronizationService - @Inject - lateinit var playbackSynchronizationService: PlaybackSynchronizationService + @Inject + lateinit var sharedPreferences: LissenSharedPreferences - @Inject - lateinit var sharedPreferences: LissenSharedPreferences + @Inject + lateinit var channelProvider: LissenMediaProvider - @Inject - lateinit var channelProvider: LissenMediaProvider + @Inject + lateinit var requestHeadersProvider: RequestHeadersProvider - @Inject - lateinit var requestHeadersProvider: RequestHeadersProvider + private val playerServiceScope = MainScope() - private val playerServiceScope = MainScope() + private val handler = Handler(Looper.getMainLooper()) - private val handler = Handler(Looper.getMainLooper()) + @Suppress("DEPRECATION") + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + super.onStartCommand(intent, flags, startId) - @Suppress("DEPRECATION") - override fun onStartCommand( - intent: Intent?, - flags: Int, - startId: Int, - ): Int { - super.onStartCommand(intent, flags, startId) + when (intent?.action) { + ACTION_SET_TIMER -> { + val delay = intent.getDoubleExtra(TIMER_VALUE_EXTRA, 0.0) - when (intent?.action) { - ACTION_SET_TIMER -> { - val delay = intent.getDoubleExtra(TIMER_VALUE_EXTRA, 0.0) - - if (delay > 0) { - setTimer(delay) - } - - return START_NOT_STICKY - } - - ACTION_CANCEL_TIMER -> { - cancelTimer() - return START_NOT_STICKY - } - - ACTION_PLAY -> { - playerServiceScope - .launch { - exoPlayer.prepare() - exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) - exoPlayer.playWhenReady = true - } - return START_STICKY - } - - ACTION_PAUSE -> { - pause() - return START_NOT_STICKY - } - - ACTION_SET_PLAYBACK -> { - val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem - book?.let { - playerServiceScope - .launch { preparePlayback(it) } - } - return START_NOT_STICKY - } - - ACTION_SEEK_TO -> { - val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem - val position = intent.getDoubleExtra(POSITION, 0.0) - book?.let { seek(it.files, position) } - return START_NOT_STICKY - } - - else -> { - return START_NOT_STICKY - } + if (delay > 0) { + setTimer(delay) } - } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession - - override fun onDestroy() { - playerServiceScope.cancel() - - mediaSession.release() - exoPlayer.release() - exoPlayer.clearMediaItems() - - super.onDestroy() - } - - @OptIn(UnstableApi::class) - private suspend fun preparePlayback(book: DetailedItem) { - exoPlayer.playWhenReady = false - - withContext(Dispatchers.IO) { - val prepareQueue = async { - val cover: ByteArray? = channelProvider - .fetchBookCover(bookId = book.id) - .fold( - onSuccess = { - try { - it.readBytes() - } catch (ex: Exception) { - null - } - }, - onFailure = { null }, - ) - - val cachedCover = cover - ?.let { content -> - File - .createTempFile(book.id, null, LissenApplication.appContext.cacheDir) - .also { it.writeBytes(content) } - } - - val sourceFactory = buildDataSourceFactory() - - val playingQueue = book - .files - .map { file -> - mediaChannel - .provideFileUri(book.id, file.id) - .fold( - onSuccess = { request -> - val mediaData = MediaMetadata.Builder() - .setTitle(file.name) - .setArtist(book.title) - .setArtworkUri(cachedCover?.toUri()) - - val mediaItem = MediaItem.Builder() - .setMediaId(file.id) - .setUri(request) - .setTag(book) - .setMediaMetadata(mediaData.build()) - .build() - - ProgressiveMediaSource - .Factory(sourceFactory) - .createMediaSource(mediaItem) - }, - onFailure = { - SilenceMediaSource((file.duration * 1000).toLong()) - }, - ) - } - - withContext(Dispatchers.Main) { - exoPlayer.setMediaSources(playingQueue) - exoPlayer.prepare() - - setPlaybackProgress(book.files, book.progress) - } - } - - val prepareSession = async { - playbackSynchronizationService.startPlaybackSynchronization(book) - } - - awaitAll(prepareSession, prepareQueue) - - val intent = Intent(PLAYBACK_READY).apply { - putExtra(BOOK_EXTRA, book) - } - - LocalBroadcastManager - .getInstance(baseContext) - .sendBroadcast(intent) - } - } - - private fun setTimer(delay: Double) { - val delayMs = delay * 1000 + return START_NOT_STICKY + } + ACTION_CANCEL_TIMER -> { cancelTimer() + return START_NOT_STICKY + } - handler.postDelayed( - { - pause() - LocalBroadcastManager - .getInstance(baseContext) - .sendBroadcast(Intent(TIMER_EXPIRED)) - }, - delayMs.toLong(), - ) - Log.d(TAG, "Timer started for $delayMs ms.") - } - - private fun cancelTimer() { - handler.removeCallbacksAndMessages(null) - Log.d(TAG, "Timer canceled.") - } - - private fun pause() { + ACTION_PLAY -> { playerServiceScope - .launch { - exoPlayer.playWhenReady = false - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - } + .launch { + exoPlayer.prepare() + exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) + exoPlayer.playWhenReady = true + } + return START_STICKY + } - private fun seek( - items: List, - position: Double?, - ) { - if (items.isEmpty()) { - Log.w(TAG, "Tried to seek position $position in the empty book. Skipping") - return + ACTION_PAUSE -> { + pause() + return START_NOT_STICKY + } + + ACTION_SET_PLAYBACK -> { + val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem + book?.let { + playerServiceScope + .launch { preparePlayback(it) } + } + return START_NOT_STICKY + } + + ACTION_SEEK_TO -> { + val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem + val position = intent.getDoubleExtra(POSITION, 0.0) + book?.let { seek(it.files, position) } + return START_NOT_STICKY + } + + else -> { + return START_NOT_STICKY + } + } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession + + override fun onDestroy() { + playerServiceScope.cancel() + + mediaSession.release() + exoPlayer.release() + exoPlayer.clearMediaItems() + + super.onDestroy() + } + + @OptIn(UnstableApi::class) + private suspend fun preparePlayback(book: DetailedItem) { + exoPlayer.playWhenReady = false + + withContext(Dispatchers.IO) { + val prepareQueue = + async { + val cover: ByteArray? = + channelProvider + .fetchBookCover(bookId = book.id) + .fold( + onSuccess = { + try { + it.readBytes() + } catch (ex: Exception) { + null + } + }, + onFailure = { null }, + ) + + val cachedCover = + cover + ?.let { content -> + File + .createTempFile(book.id, null, LissenApplication.appContext.cacheDir) + .also { it.writeBytes(content) } + } + + val sourceFactory = buildDataSourceFactory() + + val playingQueue = + book + .files + .map { file -> + mediaChannel + .provideFileUri(book.id, file.id) + .fold( + onSuccess = { request -> + val mediaData = + MediaMetadata + .Builder() + .setTitle(file.name) + .setArtist(book.title) + .setArtworkUri(cachedCover?.toUri()) + + val mediaItem = + MediaItem + .Builder() + .setMediaId(file.id) + .setUri(request) + .setTag(book) + .setMediaMetadata(mediaData.build()) + .build() + + ProgressiveMediaSource + .Factory(sourceFactory) + .createMediaSource(mediaItem) + }, + onFailure = { + SilenceMediaSource((file.duration * 1000).toLong()) + }, + ) + } + + withContext(Dispatchers.Main) { + exoPlayer.setMediaSources(playingQueue) + exoPlayer.prepare() + + setPlaybackProgress(book.files, book.progress) + } } - when (position) { - null -> exoPlayer.seekTo(0, 0) - else -> { - val positionMs = (position * 1000).toLong() - - val durationsMs = items.map { (it.duration * 1000).toLong() } - val cumulativeDurationsMs = durationsMs.runningFold(0L) { acc, duration -> acc + duration } - - val targetChapterIndex = cumulativeDurationsMs.indexOfFirst { it > positionMs } - - when (targetChapterIndex - 1 >= 0) { - true -> { - val chapterStartTimeMs = cumulativeDurationsMs[targetChapterIndex - 1] - val chapterProgressMs = positionMs - chapterStartTimeMs - exoPlayer.seekTo(targetChapterIndex - 1, chapterProgressMs) - } - - false -> { - val lastChapterIndex = items.size - 1 - val lastChapterDurationMs = durationsMs.last() - exoPlayer.seekTo(lastChapterIndex, lastChapterDurationMs) - } - } - } + val prepareSession = + async { + playbackSynchronizationService.startPlaybackSynchronization(book) } + + awaitAll(prepareSession, prepareQueue) + + val intent = + Intent(PLAYBACK_READY).apply { + putExtra(BOOK_EXTRA, book) + } + + LocalBroadcastManager + .getInstance(baseContext) + .sendBroadcast(intent) + } + } + + private fun setTimer(delay: Double) { + val delayMs = delay * 1000 + + cancelTimer() + + handler.postDelayed( + { + pause() + LocalBroadcastManager + .getInstance(baseContext) + .sendBroadcast(Intent(TIMER_EXPIRED)) + }, + delayMs.toLong(), + ) + Log.d(TAG, "Timer started for $delayMs ms.") + } + + private fun cancelTimer() { + handler.removeCallbacksAndMessages(null) + Log.d(TAG, "Timer canceled.") + } + + private fun pause() { + playerServiceScope + .launch { + exoPlayer.playWhenReady = false + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun seek( + items: List, + position: Double?, + ) { + if (items.isEmpty()) { + Log.w(TAG, "Tried to seek position $position in the empty book. Skipping") + return } - private fun setPlaybackProgress( - chapters: List, - progress: MediaProgress?, - ) = seek(chapters, progress?.currentTime) + when (position) { + null -> exoPlayer.seekTo(0, 0) + else -> { + val positionMs = (position * 1000).toLong() - @OptIn(UnstableApi::class) - private fun buildDataSourceFactory(): DefaultDataSource.Factory { - val requestHeaders = requestHeadersProvider - .fetchRequestHeaders() - .associate { it.name to it.value } + val durationsMs = items.map { (it.duration * 1000).toLong() } + val cumulativeDurationsMs = durationsMs.runningFold(0L) { acc, duration -> acc + duration } - val okHttpDataSourceFactory = OkHttpDataSource - .Factory(createOkHttpClient()) - .setDefaultRequestProperties(requestHeaders) + val targetChapterIndex = cumulativeDurationsMs.indexOfFirst { it > positionMs } - return DefaultDataSource.Factory( - baseContext, - okHttpDataSourceFactory, - ) + when (targetChapterIndex - 1 >= 0) { + true -> { + val chapterStartTimeMs = cumulativeDurationsMs[targetChapterIndex - 1] + val chapterProgressMs = positionMs - chapterStartTimeMs + exoPlayer.seekTo(targetChapterIndex - 1, chapterProgressMs) + } + + false -> { + val lastChapterIndex = items.size - 1 + val lastChapterDurationMs = durationsMs.last() + exoPlayer.seekTo(lastChapterIndex, lastChapterDurationMs) + } + } + } } + } - companion object { + private fun setPlaybackProgress( + chapters: List, + progress: MediaProgress?, + ) = seek(chapters, progress?.currentTime) - const val ACTION_PLAY = "org.grakovne.lissen.player.service.PLAY" - const val ACTION_PAUSE = "org.grakovne.lissen.player.service.PAUSE" - const val ACTION_SET_PLAYBACK = "org.grakovne.lissen.player.service.SET_PLAYBACK" - const val ACTION_SEEK_TO = "org.grakovne.lissen.player.service.ACTION_SEEK_TO" - const val ACTION_SET_TIMER = "org.grakovne.lissen.player.service.ACTION_SET_TIMER" - const val ACTION_CANCEL_TIMER = "org.grakovne.lissen.player.service.CANCEL_TIMER" + @OptIn(UnstableApi::class) + private fun buildDataSourceFactory(): DefaultDataSource.Factory { + val requestHeaders = + requestHeadersProvider + .fetchRequestHeaders() + .associate { it.name to it.value } - const val BOOK_EXTRA = "org.grakovne.lissen.player.service.BOOK" - const val TIMER_VALUE_EXTRA = "org.grakovne.lissen.player.service.TIMER_VALUE" - const val TIMER_EXPIRED = "org.grakovne.lissen.player.service.TIMER_EXPIRED" + val okHttpDataSourceFactory = + OkHttpDataSource + .Factory(createOkHttpClient()) + .setDefaultRequestProperties(requestHeaders) - const val PLAYBACK_READY = "org.grakovne.lissen.player.service.PLAYBACK_READY" - const val POSITION = "org.grakovne.lissen.player.service.POSITION" + return DefaultDataSource.Factory( + baseContext, + okHttpDataSourceFactory, + ) + } - private const val TAG: String = "PlaybackService" - } + companion object { + const val ACTION_PLAY = "org.grakovne.lissen.player.service.PLAY" + const val ACTION_PAUSE = "org.grakovne.lissen.player.service.PAUSE" + const val ACTION_SET_PLAYBACK = "org.grakovne.lissen.player.service.SET_PLAYBACK" + const val ACTION_SEEK_TO = "org.grakovne.lissen.player.service.ACTION_SEEK_TO" + const val ACTION_SET_TIMER = "org.grakovne.lissen.player.service.ACTION_SET_TIMER" + const val ACTION_CANCEL_TIMER = "org.grakovne.lissen.player.service.CANCEL_TIMER" + + const val BOOK_EXTRA = "org.grakovne.lissen.player.service.BOOK" + const val TIMER_VALUE_EXTRA = "org.grakovne.lissen.player.service.TIMER_VALUE" + const val TIMER_EXPIRED = "org.grakovne.lissen.player.service.TIMER_EXPIRED" + + const val PLAYBACK_READY = "org.grakovne.lissen.player.service.PLAYBACK_READY" + const val POSITION = "org.grakovne.lissen.player.service.POSITION" + + private const val TAG: String = "PlaybackService" + } } diff --git a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt index 32224b78..497b82a2 100644 --- a/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt +++ b/app/src/main/java/org/grakovne/lissen/playback/service/PlaybackSynchronizationService.kt @@ -16,128 +16,132 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PlaybackSynchronizationService @Inject constructor( +class PlaybackSynchronizationService + @Inject + constructor( private val exoPlayer: ExoPlayer, private val mediaChannel: LissenMediaProvider, private val sharedPreferences: LissenSharedPreferences, -) { - + ) { private var currentBook: DetailedItem? = null private var currentChapterIndex: Int? = null private var playbackSession: PlaybackSession? = null private val serviceScope = MainScope() init { - exoPlayer.addListener(object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying) { - scheduleSynchronization() - } else { - executeSynchronization() - } + exoPlayer.addListener( + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + scheduleSynchronization() + } else { + executeSynchronization() } - }) + } + }, + ) } fun startPlaybackSynchronization(book: DetailedItem) { - serviceScope.coroutineContext.cancelChildren() - currentBook = book + serviceScope.coroutineContext.cancelChildren() + currentBook = book } private fun scheduleSynchronization() { - serviceScope - .launch { - if (exoPlayer.isPlaying) { - executeSynchronization() - delay(SYNC_INTERVAL) - scheduleSynchronization() - } - } + serviceScope + .launch { + if (exoPlayer.isPlaying) { + executeSynchronization() + delay(SYNC_INTERVAL) + scheduleSynchronization() + } + } } private fun executeSynchronization() { - val elapsedMs = exoPlayer.currentPosition - val overallProgress = getProgress(elapsedMs) + val elapsedMs = exoPlayer.currentPosition + val overallProgress = getProgress(elapsedMs) - serviceScope - .launch(Dispatchers.IO) { - playbackSession - ?.takeIf { it.bookId == currentBook?.id } - ?.let { synchronizeProgress(it, overallProgress) } - ?: openPlaybackSession(overallProgress) - } + serviceScope + .launch(Dispatchers.IO) { + playbackSession + ?.takeIf { it.bookId == currentBook?.id } + ?.let { synchronizeProgress(it, overallProgress) } + ?: openPlaybackSession(overallProgress) + } } private suspend fun synchronizeProgress( - it: PlaybackSession, - overallProgress: PlaybackProgress, + it: PlaybackSession, + overallProgress: PlaybackProgress, ): Unit? { - val currentIndex = currentBook - ?.let { calculateChapterIndex(it, overallProgress.currentTotalTime) } - ?: 0 + val currentIndex = + currentBook + ?.let { calculateChapterIndex(it, overallProgress.currentTotalTime) } + ?: 0 - if (currentIndex != currentChapterIndex) { - openPlaybackSession(overallProgress) - currentChapterIndex = currentIndex - } + if (currentIndex != currentChapterIndex) { + openPlaybackSession(overallProgress) + currentChapterIndex = currentIndex + } - return mediaChannel - .syncProgress( - sessionId = it.sessionId, - bookId = it.bookId, - progress = overallProgress, - ) - .foldAsync( - onSuccess = {}, - onFailure = { openPlaybackSession(overallProgress) }, - ) - } - - private suspend fun openPlaybackSession(overallProgress: PlaybackProgress) = currentBook - ?.let { book -> - val chapterIndex = calculateChapterIndex(book, overallProgress.currentTotalTime) - mediaChannel - .startPlayback( - bookId = book.id, - deviceId = sharedPreferences.getDeviceId(), - supportedMimeTypes = MimeTypeProvider.getSupportedMimeTypes(), - chapterId = book.chapters[chapterIndex].id, - ) - .fold( - onSuccess = { playbackSession = it }, - onFailure = {}, - ) - } - - private fun getProgress(currentElapsedMs: Long): PlaybackProgress { - val currentBook = ( - exoPlayer - .currentMediaItem - ?.localConfiguration - ?.tag as? DetailedItem - ) - ?: return PlaybackProgress( - currentChapterTime = 0.0, - currentTotalTime = 0.0, - ) - - val currentIndex = exoPlayer.currentMediaItemIndex - - val previousDuration = currentBook.files - .take(currentIndex) - .sumOf { it.duration * 1000 } - - val currentTotalTime = (previousDuration + currentElapsedMs) / 1000.0 - val currentChapterTime = calculateChapterPosition(currentBook, currentTotalTime) - - return PlaybackProgress( - currentTotalTime = currentTotalTime, - currentChapterTime = currentChapterTime, + return mediaChannel + .syncProgress( + sessionId = it.sessionId, + bookId = it.bookId, + progress = overallProgress, + ).foldAsync( + onSuccess = {}, + onFailure = { openPlaybackSession(overallProgress) }, ) } - companion object { + private suspend fun openPlaybackSession(overallProgress: PlaybackProgress) = + currentBook + ?.let { book -> + val chapterIndex = calculateChapterIndex(book, overallProgress.currentTotalTime) + mediaChannel + .startPlayback( + bookId = book.id, + deviceId = sharedPreferences.getDeviceId(), + supportedMimeTypes = MimeTypeProvider.getSupportedMimeTypes(), + chapterId = book.chapters[chapterIndex].id, + ).fold( + onSuccess = { playbackSession = it }, + onFailure = {}, + ) + } - private const val SYNC_INTERVAL = 30_000L + private fun getProgress(currentElapsedMs: Long): PlaybackProgress { + val currentBook = + ( + exoPlayer + .currentMediaItem + ?.localConfiguration + ?.tag as? DetailedItem + ) + ?: return PlaybackProgress( + currentChapterTime = 0.0, + currentTotalTime = 0.0, + ) + + val currentIndex = exoPlayer.currentMediaItemIndex + + val previousDuration = + currentBook.files + .take(currentIndex) + .sumOf { it.duration * 1000 } + + val currentTotalTime = (previousDuration + currentElapsedMs) / 1000.0 + val currentChapterTime = calculateChapterPosition(currentBook, currentTotalTime) + + return PlaybackProgress( + currentTotalTime = currentTotalTime, + currentChapterTime = currentChapterTime, + ) } -} + + companion object { + private const val SYNC_INTERVAL = 30_000L + } + } diff --git a/app/src/main/java/org/grakovne/lissen/shortcuts/ContinuePlaybackShortcut.kt b/app/src/main/java/org/grakovne/lissen/shortcuts/ContinuePlaybackShortcut.kt index 94f9827f..9f905f49 100644 --- a/app/src/main/java/org/grakovne/lissen/shortcuts/ContinuePlaybackShortcut.kt +++ b/app/src/main/java/org/grakovne/lissen/shortcuts/ContinuePlaybackShortcut.kt @@ -20,54 +20,52 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ContinuePlaybackShortcut @Inject constructor( +class ContinuePlaybackShortcut + @Inject + constructor( @ApplicationContext private val context: Context, private val sharedPreferences: LissenSharedPreferences, -) : RunningComponent { - + ) : RunningComponent { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onCreate() { - Log.d(TAG, "ContinuePlaybackShortcut registered") + Log.d(TAG, "ContinuePlaybackShortcut registered") - scope.launch { - sharedPreferences - .playingBookFlow - .collect { updateShortcut(it) } - } + scope.launch { + sharedPreferences + .playingBookFlow + .collect { updateShortcut(it) } + } } - private fun updateShortcut( - playingBook: DetailedItem?, - ) { - Log.d(TAG, "ContinuePlaybackShortcut is updating") + private fun updateShortcut(playingBook: DetailedItem?) { + Log.d(TAG, "ContinuePlaybackShortcut is updating") - val shortcutManager = context.getSystemService(ShortcutManager::class.java) + val shortcutManager = context.getSystemService(ShortcutManager::class.java) - if (playingBook == null) { - shortcutManager.removeDynamicShortcuts(listOf(SHORTCUT_TAG)) - return - } + if (playingBook == null) { + shortcutManager.removeDynamicShortcuts(listOf(SHORTCUT_TAG)) + return + } - val shortcut = ShortcutInfo - .Builder(context, SHORTCUT_TAG) - .setShortLabel(context.getString(R.string.continue_playback_shortcut_title)) - .setLongLabel(context.getString(R.string.continue_playback_shortcut_description)) - .setIcon(Icon.createWithResource(context, R.drawable.ic_play)) - .setIntent( - Intent(context, AppActivity::class.java).apply { - action = "continue_playback" - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - }, - ) - .build() + val shortcut = + ShortcutInfo + .Builder(context, SHORTCUT_TAG) + .setShortLabel(context.getString(R.string.continue_playback_shortcut_title)) + .setLongLabel(context.getString(R.string.continue_playback_shortcut_description)) + .setIcon(Icon.createWithResource(context, R.drawable.ic_play)) + .setIntent( + Intent(context, AppActivity::class.java).apply { + action = "continue_playback" + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + }, + ).build() - shortcutManager.dynamicShortcuts = listOf(shortcut) + shortcutManager.dynamicShortcuts = listOf(shortcut) } companion object { - - private const val SHORTCUT_TAG = "continue_playback_shortcut" - private const val TAG = "ContinuePlaybackShortcut" + private const val SHORTCUT_TAG = "continue_playback_shortcut" + private const val TAG = "ContinuePlaybackShortcut" } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/shortcuts/ShortcutsModule.kt b/app/src/main/java/org/grakovne/lissen/shortcuts/ShortcutsModule.kt index 986d0910..60ead195 100644 --- a/app/src/main/java/org/grakovne/lissen/shortcuts/ShortcutsModule.kt +++ b/app/src/main/java/org/grakovne/lissen/shortcuts/ShortcutsModule.kt @@ -10,8 +10,7 @@ import org.grakovne.lissen.common.RunningComponent @Module @InstallIn(SingletonComponent::class) interface ShortcutsModule { - - @Binds - @IntoSet - fun bindPlaybackNotificationService(service: ContinuePlaybackShortcut): RunningComponent + @Binds + @IntoSet + fun bindPlaybackNotificationService(service: ContinuePlaybackShortcut): RunningComponent } diff --git a/app/src/main/java/org/grakovne/lissen/ui/PlaybackSpeedSlider.kt b/app/src/main/java/org/grakovne/lissen/ui/PlaybackSpeedSlider.kt index c091c961..16736fb8 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/PlaybackSpeedSlider.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/PlaybackSpeedSlider.kt @@ -43,195 +43,210 @@ import kotlin.math.roundToInt @Composable fun PlaybackSpeedSlider( - speed: Float, - speedRange: ClosedRange, - modifier: Modifier = Modifier, - onSpeedUpdate: (Float) -> Unit, + speed: Float, + speedRange: ClosedRange, + modifier: Modifier = Modifier, + onSpeedUpdate: (Float) -> Unit, ) { - val sliderRange = speedRange.start.toSliderValue()..speedRange.endInclusive.toSliderValue() - val sliderState = rememberSaveable(saver = SpeedSliderState.saver(onSpeedUpdate)) { - SpeedSliderState( - current = speed.toSliderValue(), - bounds = sliderRange, - onUpdate = onSpeedUpdate, - ) + val sliderRange = speedRange.start.toSliderValue()..speedRange.endInclusive.toSliderValue() + val sliderState = + rememberSaveable(saver = SpeedSliderState.saver(onSpeedUpdate)) { + SpeedSliderState( + current = speed.toSliderValue(), + bounds = sliderRange, + onUpdate = onSpeedUpdate, + ) } - LaunchedEffect(Unit) { - sliderState.snapTo(sliderState.current) - } - LaunchedEffect(speed) { - sliderState.animateDecayTo(speed.toSliderValue().toFloat()) - } + LaunchedEffect(Unit) { + sliderState.snapTo(sliderState.current) + } + LaunchedEffect(speed) { + sliderState.animateDecayTo(speed.toSliderValue().toFloat()) + } - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = String.format(Locale.US, "%.1fx", sliderState.current.roundToInt().toSpeed()), + style = typography.headlineSmall, + ) + Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = null) + + BoxWithConstraints( + modifier = + Modifier + .fillMaxWidth() + .sliderDrag(sliderState, totalSegments), + contentAlignment = Alignment.TopCenter, ) { - Text( - text = String.format(Locale.US, "%.1fx", sliderState.current.roundToInt().toSpeed()), - style = typography.headlineSmall, + val segmentWidth: Dp = maxWidth / totalSegments + val segmentPixelWidth: Float = constraints.maxWidth.toFloat() / totalSegments + val visibleSegmentCount = (totalSegments + 1) / 2 + + val minIndex = (sliderState.current - visibleSegmentCount).toInt().coerceAtLeast(sliderRange.first) + val maxIndex = (sliderState.current + visibleSegmentCount).toInt().coerceAtMost(sliderRange.last) + + val centerPixel = constraints.maxWidth / 2f + + for (index in minIndex..maxIndex) { + SpeedSliderSegment( + index = index, + currentValue = sliderState.current, + segmentWidth = segmentWidth, + segmentPixelWidth = segmentPixelWidth, + centerPixel = centerPixel, + barColor = colorScheme.onSurface, ) - Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = null) - - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .sliderDrag(sliderState, totalSegments), - contentAlignment = Alignment.TopCenter, - ) { - val segmentWidth: Dp = maxWidth / totalSegments - val segmentPixelWidth: Float = constraints.maxWidth.toFloat() / totalSegments - val visibleSegmentCount = (totalSegments + 1) / 2 - - val minIndex = (sliderState.current - visibleSegmentCount).toInt().coerceAtLeast(sliderRange.first) - val maxIndex = (sliderState.current + visibleSegmentCount).toInt().coerceAtMost(sliderRange.last) - - val centerPixel = constraints.maxWidth / 2f - - for (index in minIndex..maxIndex) { - SpeedSliderSegment( - index = index, - currentValue = sliderState.current, - segmentWidth = segmentWidth, - segmentPixelWidth = segmentPixelWidth, - centerPixel = centerPixel, - barColor = colorScheme.onSurface, - ) - } - } + } } + } } @Composable private fun SpeedSliderSegment( - index: Int, - currentValue: Float, - segmentWidth: Dp, - segmentPixelWidth: Float, - centerPixel: Float, - barColor: Color, + index: Int, + currentValue: Float, + segmentWidth: Dp, + segmentPixelWidth: Float, + centerPixel: Float, + barColor: Color, ) { - val offset = (index - currentValue) * segmentPixelWidth - val alphaValue = calculateAlpha(offset, centerPixel) + val offset = (index - currentValue) * segmentPixelWidth + val alphaValue = calculateAlpha(offset, centerPixel) - Column( - modifier = Modifier - .width(segmentWidth) - .graphicsLayer(alpha = alphaValue, translationX = offset), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .width(barThickness) - .height(barLength) - .background(barColor), - ) - if (index % 5 == 0) { - Text( - text = String.format(Locale.US, "%.1f", index.toSpeed()), - style = typography.bodyMedium, - ) - } + Column( + modifier = + Modifier + .width(segmentWidth) + .graphicsLayer(alpha = alphaValue, translationX = offset), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .width(barThickness) + .height(barLength) + .background(barColor), + ) + if (index % 5 == 0) { + Text( + text = String.format(Locale.US, "%.1f", index.toSpeed()), + style = typography.bodyMedium, + ) } + } } -private fun calculateAlpha(offset: Float, centerPixel: Float): Float { - val factor = (offset / centerPixel).absoluteValue - return 1f - (1f - minAlpha) * factor +private fun calculateAlpha( + offset: Float, + centerPixel: Float, +): Float { + val factor = (offset / centerPixel).absoluteValue + return 1f - (1f - minAlpha) * factor } @SuppressLint("ReturnFromAwaitPointerEventScope", "MultipleAwaitPointerEventScopes") private fun Modifier.sliderDrag( - state: SpeedSliderState, - segments: Int, -): Modifier = pointerInput(state) { + state: SpeedSliderState, + segments: Int, +): Modifier = + pointerInput(state) { val decayAnimation = splineBasedDecay(this) coroutineScope { - while (isActive) { - val pointerId = awaitPointerEventScope { awaitFirstDown().id } - state.cancelAnimations() + while (isActive) { + val pointerId = awaitPointerEventScope { awaitFirstDown().id } + state.cancelAnimations() - val velocityTracker = VelocityTracker() - awaitPointerEventScope { - horizontalDrag(pointerId) { change -> - val deltaX = change.positionChange().x - val sliderStep = size.width / segments - val newSliderValue = state.current - deltaX / sliderStep - launch { state.snapTo(newSliderValue) } - velocityTracker.addPosition(change.uptimeMillis, change.position) - change.consume() - } - } - - val velocity = velocityTracker.calculateVelocity().x / segments - val targetValue = decayAnimation.calculateTargetValue(state.current, -velocity) - - launch { - state.animateDecayTo(targetValue) - state.snapToNearest() - } + val velocityTracker = VelocityTracker() + awaitPointerEventScope { + horizontalDrag(pointerId) { change -> + val deltaX = change.positionChange().x + val sliderStep = size.width / segments + val newSliderValue = state.current - deltaX / sliderStep + launch { state.snapTo(newSliderValue) } + velocityTracker.addPosition(change.uptimeMillis, change.position) + change.consume() + } } + + val velocity = velocityTracker.calculateVelocity().x / segments + val targetValue = decayAnimation.calculateTargetValue(state.current, -velocity) + + launch { + state.animateDecayTo(targetValue) + state.snapToNearest() + } + } } -} + } class SpeedSliderState( - current: Int, - val bounds: ClosedRange, - private val onUpdate: (Float) -> Unit, + current: Int, + val bounds: ClosedRange, + private val onUpdate: (Float) -> Unit, ) { - private val floatBounds = bounds.start.toFloat()..bounds.endInclusive.toFloat() - private val animState = Animatable(current.toFloat()) + private val floatBounds = bounds.start.toFloat()..bounds.endInclusive.toFloat() + private val animState = Animatable(current.toFloat()) - val current: Float - get() = animState.value + val current: Float + get() = animState.value - suspend fun cancelAnimations() { - animState.stop() - } + suspend fun cancelAnimations() { + animState.stop() + } - suspend fun snapTo(value: Float) { - val limitedValue = value.coerceIn(floatBounds) - animState.snapTo(limitedValue) - onUpdate(limitedValue.toInt().toSpeed()) - } + suspend fun snapTo(value: Float) { + val limitedValue = value.coerceIn(floatBounds) + animState.snapTo(limitedValue) + onUpdate(limitedValue.toInt().toSpeed()) + } - suspend fun snapToNearest() { - val target = animState.value.roundToInt().toFloat().coerceIn(floatBounds) - animState.animateTo(target, animationSpec = springSpec) - onUpdate(target.toInt().toSpeed()) - } + suspend fun snapToNearest() { + val target = + animState.value + .roundToInt() + .toFloat() + .coerceIn(floatBounds) + animState.animateTo(target, animationSpec = springSpec) + onUpdate(target.toInt().toSpeed()) + } - suspend fun animateDecayTo(target: Float) { - val initialVelocity = (target - current).coerceIn(-maxSpeed, maxSpeed) - animState.animateTo( - targetValue = target.coerceIn(floatBounds), - initialVelocity = initialVelocity, - animationSpec = springSpec, - ) - } + suspend fun animateDecayTo(target: Float) { + val initialVelocity = (target - current).coerceIn(-maxSpeed, maxSpeed) + animState.animateTo( + targetValue = target.coerceIn(floatBounds), + initialVelocity = initialVelocity, + animationSpec = springSpec, + ) + } - companion object { - private const val maxSpeed = 10f - private val springSpec = FloatSpringSpec( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow, - ) + companion object { + private const val maxSpeed = 10f + private val springSpec = + FloatSpringSpec( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow, + ) - fun saver(onUpdate: (Float) -> Unit) = Saver>( - save = { listOf(it.current.roundToInt(), it.bounds.start, it.bounds.endInclusive) }, - restore = { - SpeedSliderState( - current = it[0] as Int, - bounds = (it[1] as Int)..(it[2] as Int), - onUpdate = onUpdate, - ) - }, - ) - } + fun saver(onUpdate: (Float) -> Unit) = + Saver>( + save = { listOf(it.current.roundToInt(), it.bounds.start, it.bounds.endInclusive) }, + restore = { + SpeedSliderState( + current = it[0] as Int, + bounds = (it[1] as Int)..(it[2] as Int), + onUpdate = onUpdate, + ) + }, + ) + } } private fun Float.toSliderValue(): Int = (this * 10).roundToInt() + private fun Int.toSpeed(): Float = this / 10f private val barThickness = 2.dp diff --git a/app/src/main/java/org/grakovne/lissen/ui/activity/AppActivity.kt b/app/src/main/java/org/grakovne/lissen/ui/activity/AppActivity.kt index b7520b5d..a1315ef2 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/activity/AppActivity.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/activity/AppActivity.kt @@ -20,45 +20,45 @@ import javax.inject.Inject @AndroidEntryPoint class AppActivity : ComponentActivity() { + @Inject + lateinit var preferences: LissenSharedPreferences - @Inject - lateinit var preferences: LissenSharedPreferences + @Inject + lateinit var imageLoader: ImageLoader - @Inject - lateinit var imageLoader: ImageLoader + @Inject + lateinit var networkQualityService: NetworkQualityService - @Inject - lateinit var networkQualityService: NetworkQualityService + private lateinit var appNavigationService: AppNavigationService - private lateinit var appNavigationService: AppNavigationService + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() + setContent { + val colorScheme by preferences + .colorSchemeFlow + .collectAsState(initial = preferences.getColorScheme()) - setContent { - val colorScheme by preferences - .colorSchemeFlow - .collectAsState(initial = preferences.getColorScheme()) + LissenTheme(colorScheme) { + val navController = rememberNavController() + appNavigationService = AppNavigationService(navController) - LissenTheme(colorScheme) { - val navController = rememberNavController() - appNavigationService = AppNavigationService(navController) - - AppNavHost( - navController = navController, - navigationService = appNavigationService, - preferences = preferences, - imageLoader = imageLoader, - networkQualityService = networkQualityService, - appLaunchAction = getLaunchAction(intent), - ) - } - } + AppNavHost( + navController = navController, + navigationService = appNavigationService, + preferences = preferences, + imageLoader = imageLoader, + networkQualityService = networkQualityService, + appLaunchAction = getLaunchAction(intent), + ) + } } + } - private fun getLaunchAction(intent: Intent?) = when (intent?.action) { - "continue_playback" -> AppLaunchAction.CONTINUE_PLAYBACK - else -> AppLaunchAction.DEFAULT + private fun getLaunchAction(intent: Intent?) = + when (intent?.action) { + "continue_playback" -> AppLaunchAction.CONTINUE_PLAYBACK + else -> AppLaunchAction.DEFAULT } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt b/app/src/main/java/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt index 5e6dda7d..90d7914a 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt @@ -20,45 +20,46 @@ import com.valentinilk.shimmer.shimmer @Composable fun AsyncShimmeringImage( - imageRequest: ImageRequest, - imageLoader: ImageLoader, - contentDescription: String, - contentScale: ContentScale, - modifier: Modifier = Modifier, - error: Painter, - onLoadingStateChanged: (Boolean) -> Unit = {}, + imageRequest: ImageRequest, + imageLoader: ImageLoader, + contentDescription: String, + contentScale: ContentScale, + modifier: Modifier = Modifier, + error: Painter, + onLoadingStateChanged: (Boolean) -> Unit = {}, ) { - var isLoading by remember { mutableStateOf(true) } - onLoadingStateChanged(isLoading) + var isLoading by remember { mutableStateOf(true) } + onLoadingStateChanged(isLoading) - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - if (isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Gray) - .shimmer(), - ) - } - - AsyncImage( - model = imageRequest, - imageLoader = imageLoader, - contentDescription = contentDescription, - contentScale = contentScale, - modifier = Modifier.fillMaxSize(), - onSuccess = { - isLoading = false - onLoadingStateChanged(false) - }, - onError = { - isLoading = false - onLoadingStateChanged(false) - }, - error = error, - ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + if (isLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Gray) + .shimmer(), + ) } + + AsyncImage( + model = imageRequest, + imageLoader = imageLoader, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = Modifier.fillMaxSize(), + onSuccess = { + isLoading = false + onLoadingStateChanged(false) + }, + onError = { + isLoading = false + onLoadingStateChanged(false) + }, + error = error, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/components/BookCoverFetcher.kt b/app/src/main/java/org/grakovne/lissen/ui/components/BookCoverFetcher.kt index d4ef292c..3ccc92e8 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/components/BookCoverFetcher.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/components/BookCoverFetcher.kt @@ -20,57 +20,56 @@ import org.grakovne.lissen.content.LissenMediaProvider import javax.inject.Singleton class BookCoverFetcher( - private val mediaChannel: LissenMediaProvider, - private val uri: Uri, - private val context: Context, + private val mediaChannel: LissenMediaProvider, + private val uri: Uri, + private val context: Context, ) : Fetcher { + override suspend fun fetch(): FetchResult? = + when (val response = mediaChannel.fetchBookCover(uri.toString())) { + is ApiResult.Error -> null + is ApiResult.Success -> { + val stream = response.data + val source = stream.source().buffer() + val imageSource = ImageSource(source, context) - override suspend fun fetch(): FetchResult? = - when (val response = mediaChannel.fetchBookCover(uri.toString())) { - is ApiResult.Error -> null - is ApiResult.Success -> { - val stream = response.data - val source = stream.source().buffer() - val imageSource = ImageSource(source, context) - - SourceResult( - source = imageSource, - mimeType = null, - dataSource = coil.decode.DataSource.NETWORK, - ) - } - } + SourceResult( + source = imageSource, + mimeType = null, + dataSource = coil.decode.DataSource.NETWORK, + ) + } + } } class BookCoverFetcherFactory( - private val dataProvider: LissenMediaProvider, - private val context: Context, + private val dataProvider: LissenMediaProvider, + private val context: Context, ) : Fetcher.Factory { - - override fun create(data: Uri, options: Options, imageLoader: ImageLoader) = - BookCoverFetcher(dataProvider, data, context) + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ) = BookCoverFetcher(dataProvider, data, context) } @Module @InstallIn(SingletonComponent::class) object ImageLoaderModule { + @Singleton + @Provides + fun provideBookCoverFetcherFactory( + mediaChannel: LissenMediaProvider, + @ApplicationContext context: Context, + ): BookCoverFetcherFactory = BookCoverFetcherFactory(mediaChannel, context) - @Singleton - @Provides - fun provideBookCoverFetcherFactory( - mediaChannel: LissenMediaProvider, - @ApplicationContext context: Context, - ): BookCoverFetcherFactory = BookCoverFetcherFactory(mediaChannel, context) - - @Singleton - @Provides - fun provideCustomImageLoader( - @ApplicationContext context: Context, - bookCoverFetcherFactory: BookCoverFetcherFactory, - ): ImageLoader { - return ImageLoader - .Builder(context) - .components { add(bookCoverFetcherFactory) } - .build() - } + @Singleton + @Provides + fun provideCustomImageLoader( + @ApplicationContext context: Context, + bookCoverFetcherFactory: BookCoverFetcherFactory, + ): ImageLoader = + ImageLoader + .Builder(context) + .components { add(bookCoverFetcherFactory) } + .build() } diff --git a/app/src/main/java/org/grakovne/lissen/ui/components/ImageLoaderEntryPoint.kt b/app/src/main/java/org/grakovne/lissen/ui/components/ImageLoaderEntryPoint.kt index 14af8414..b42543b1 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/components/ImageLoaderEntryPoint.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/components/ImageLoaderEntryPoint.kt @@ -8,5 +8,5 @@ import dagger.hilt.components.SingletonComponent @EntryPoint @InstallIn(SingletonComponent::class) interface ImageLoaderEntryPoint { - fun getImageLoader(): ImageLoader + fun getImageLoader(): ImageLoader } diff --git a/app/src/main/java/org/grakovne/lissen/ui/extensions/AsyncExtensions.kt b/app/src/main/java/org/grakovne/lissen/ui/extensions/AsyncExtensions.kt index 186bc111..48f40fb6 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/extensions/AsyncExtensions.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/extensions/AsyncExtensions.kt @@ -6,16 +6,17 @@ import kotlinx.coroutines.delay import kotlin.system.measureTimeMillis suspend fun withMinimumTime( - minimumTimeMillis: Long, - block: suspend CoroutineScope.() -> T, + minimumTimeMillis: Long, + block: suspend CoroutineScope.() -> T, ): T { - var result: T - val elapsedTime = measureTimeMillis { - result = coroutineScope { block() } + var result: T + val elapsedTime = + measureTimeMillis { + result = coroutineScope { block() } } - val remainingTime = minimumTimeMillis - elapsedTime - if (remainingTime > 0) { - delay(remainingTime) - } - return result + val remainingTime = minimumTimeMillis - elapsedTime + if (remainingTime > 0) { + delay(remainingTime) + } + return result } diff --git a/app/src/main/java/org/grakovne/lissen/ui/extensions/TimeExtensions.kt b/app/src/main/java/org/grakovne/lissen/ui/extensions/TimeExtensions.kt index 7cb9a6a3..0361db8a 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/extensions/TimeExtensions.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/extensions/TimeExtensions.kt @@ -3,19 +3,19 @@ package org.grakovne.lissen.ui.extensions import java.util.Locale fun Int.formatLeadingMinutes(): String { - val minutes = this / 60 - val seconds = this % 60 + val minutes = this / 60 + val seconds = this % 60 - return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) } fun Int.formatFully(): String { - val hours = this / 3600 - val minutes = (this % 3600) / 60 - val seconds = this % 60 - return if (hours > 0) { - String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) - } else { - String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) - } + val hours = this / 3600 + val minutes = (this % 3600) / 60 + val seconds = this % 60 + return if (hours > 0) { + String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/Search.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/Search.kt index 881d79df..44933cbd 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/Search.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/Search.kt @@ -27,50 +27,52 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Search: ImageVector - get() { - if (_Search != null) { - return _Search!! - } - _Search = ImageVector.Builder( - name = "Search", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 24f, - viewportHeight = 24f, - ).apply { - path( - fill = null, - fillAlpha = 1.0f, - stroke = SolidColor(Color(0xFF000000)), - strokeAlpha = 1.0f, - strokeLineWidth = 2f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(19f, 11f) - arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11f, 19f) - arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3f, 11f) - arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 19f, 11f) - close() - } - path( - fill = null, - fillAlpha = 1.0f, - stroke = SolidColor(Color(0xFF000000)), - strokeAlpha = 1.0f, - strokeLineWidth = 2f, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(21f, 21f) - lineToRelative(-4.3f, -4.3f) - } - }.build() - return _Search!! + get() { + if (_Search != null) { + return _Search!! } + _Search = + ImageVector + .Builder( + name = "Search", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path( + fill = null, + fillAlpha = 1.0f, + stroke = SolidColor(Color(0xFF000000)), + strokeAlpha = 1.0f, + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(19f, 11f) + arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11f, 19f) + arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3f, 11f) + arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 19f, 11f) + close() + } + path( + fill = null, + fillAlpha = 1.0f, + stroke = SolidColor(Color(0xFF000000)), + strokeAlpha = 1.0f, + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(21f, 21f) + lineToRelative(-4.3f, -4.3f) + } + }.build() + return _Search!! + } private var _Search: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/TimerPlay.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/TimerPlay.kt index c104343c..a07d5a8c 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/TimerPlay.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/TimerPlay.kt @@ -33,69 +33,71 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val TimerPlay: ImageVector - get() { - if (_Timer_play != null) { - return _Timer_play!! - } - _Timer_play = ImageVector.Builder( - name = "Timer_play", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(360f, 120f) - verticalLineToRelative(-80f) - horizontalLineToRelative(240f) - verticalLineToRelative(80f) - close() - moveTo(480f, 880f) - quadToRelative(-74f, 0f, -139.5f, -28.5f) - reflectiveQuadTo(226f, 774f) - reflectiveQuadToRelative(-77.5f, -114.5f) - reflectiveQuadTo(120f, 520f) - reflectiveQuadToRelative(28.5f, -139.5f) - reflectiveQuadTo(226f, 266f) - reflectiveQuadToRelative(114.5f, -77.5f) - reflectiveQuadTo(480f, 160f) - quadToRelative(62f, 0f, 119f, 20f) - reflectiveQuadToRelative(107f, 58f) - lineToRelative(56f, -56f) - lineToRelative(56f, 56f) - lineToRelative(-56f, 56f) - quadToRelative(38f, 50f, 58f, 107f) - reflectiveQuadToRelative(20f, 119f) - quadToRelative(0f, 74f, -28.5f, 139.5f) - reflectiveQuadTo(734f, 774f) - reflectiveQuadToRelative(-114.5f, 77.5f) - reflectiveQuadTo(480f, 880f) - moveToRelative(0f, -80f) - quadToRelative(116f, 0f, 198f, -82f) - reflectiveQuadToRelative(82f, -198f) - reflectiveQuadToRelative(-82f, -198f) - reflectiveQuadToRelative(-198f, -82f) - reflectiveQuadToRelative(-198f, 82f) - reflectiveQuadToRelative(-82f, 198f) - reflectiveQuadToRelative(82f, 198f) - reflectiveQuadToRelative(198f, 82f) - moveToRelative(-80f, -120f) - lineToRelative(240f, -160f) - lineToRelative(-240f, -160f) - close() - } - }.build() - return _Timer_play!! + get() { + if (_Timer_play != null) { + return _Timer_play!! } + _Timer_play = + ImageVector + .Builder( + name = "Timer_play", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(360f, 120f) + verticalLineToRelative(-80f) + horizontalLineToRelative(240f) + verticalLineToRelative(80f) + close() + moveTo(480f, 880f) + quadToRelative(-74f, 0f, -139.5f, -28.5f) + reflectiveQuadTo(226f, 774f) + reflectiveQuadToRelative(-77.5f, -114.5f) + reflectiveQuadTo(120f, 520f) + reflectiveQuadToRelative(28.5f, -139.5f) + reflectiveQuadTo(226f, 266f) + reflectiveQuadToRelative(114.5f, -77.5f) + reflectiveQuadTo(480f, 160f) + quadToRelative(62f, 0f, 119f, 20f) + reflectiveQuadToRelative(107f, 58f) + lineToRelative(56f, -56f) + lineToRelative(56f, 56f) + lineToRelative(-56f, 56f) + quadToRelative(38f, 50f, 58f, 107f) + reflectiveQuadToRelative(20f, 119f) + quadToRelative(0f, 74f, -28.5f, 139.5f) + reflectiveQuadTo(734f, 774f) + reflectiveQuadToRelative(-114.5f, 77.5f) + reflectiveQuadTo(480f, 880f) + moveToRelative(0f, -80f) + quadToRelative(116f, 0f, 198f, -82f) + reflectiveQuadToRelative(82f, -198f) + reflectiveQuadToRelative(-82f, -198f) + reflectiveQuadToRelative(-198f, -82f) + reflectiveQuadToRelative(-198f, 82f) + reflectiveQuadToRelative(-82f, 198f) + reflectiveQuadToRelative(82f, 198f) + reflectiveQuadToRelative(198f, 82f) + moveToRelative(-80f, -120f) + lineToRelative(240f, -160f) + lineToRelative(-240f, -160f) + close() + } + }.build() + return _Timer_play!! + } private var _Timer_play: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader10.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader10.kt index b12fc9a4..86f818cc 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader10.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader10.kt @@ -27,59 +27,61 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Loader10: ImageVector - get() { - if (_undefined != null) { - return _undefined!! - } - _undefined = ImageVector.Builder( - name = "Clock_loader_10", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(480f, 880f) - quadToRelative(-83f, 0f, -156f, -31.5f) - reflectiveQuadTo(197f, 763f) - reflectiveQuadToRelative(-85.5f, -127f) - reflectiveQuadTo(80f, 480f) - reflectiveQuadToRelative(31.5f, -156f) - reflectiveQuadTo(197f, 197f) - reflectiveQuadToRelative(127f, -85.5f) - reflectiveQuadTo(480f, 80f) - reflectiveQuadToRelative(156f, 31.5f) - reflectiveQuadTo(763f, 197f) - reflectiveQuadToRelative(85.5f, 127f) - reflectiveQuadTo(880f, 480f) - reflectiveQuadToRelative(-31.5f, 156f) - reflectiveQuadTo(763f, 763f) - reflectiveQuadToRelative(-127f, 85.5f) - reflectiveQuadTo(480f, 880f) - moveToRelative(0f, -80f) - quadToRelative(134f, 0f, 227f, -93f) - reflectiveQuadToRelative(93f, -227f) - quadToRelative(0f, -64f, -24f, -123f) - reflectiveQuadToRelative(-69f, -104f) - lineTo(480f, 480f) - verticalLineToRelative(-320f) - quadToRelative(-134f, 0f, -227f, 93f) - reflectiveQuadToRelative(-93f, 227f) - reflectiveQuadToRelative(93f, 227f) - reflectiveQuadToRelative(227f, 93f) - } - }.build() - return _undefined!! + get() { + if (_undefined != null) { + return _undefined!! } + _undefined = + ImageVector + .Builder( + name = "Clock_loader_10", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 880f) + quadToRelative(-83f, 0f, -156f, -31.5f) + reflectiveQuadTo(197f, 763f) + reflectiveQuadToRelative(-85.5f, -127f) + reflectiveQuadTo(80f, 480f) + reflectiveQuadToRelative(31.5f, -156f) + reflectiveQuadTo(197f, 197f) + reflectiveQuadToRelative(127f, -85.5f) + reflectiveQuadTo(480f, 80f) + reflectiveQuadToRelative(156f, 31.5f) + reflectiveQuadTo(763f, 197f) + reflectiveQuadToRelative(85.5f, 127f) + reflectiveQuadTo(880f, 480f) + reflectiveQuadToRelative(-31.5f, 156f) + reflectiveQuadTo(763f, 763f) + reflectiveQuadToRelative(-127f, 85.5f) + reflectiveQuadTo(480f, 880f) + moveToRelative(0f, -80f) + quadToRelative(134f, 0f, 227f, -93f) + reflectiveQuadToRelative(93f, -227f) + quadToRelative(0f, -64f, -24f, -123f) + reflectiveQuadToRelative(-69f, -104f) + lineTo(480f, 480f) + verticalLineToRelative(-320f) + quadToRelative(-134f, 0f, -227f, 93f) + reflectiveQuadToRelative(-93f, 227f) + reflectiveQuadToRelative(93f, 227f) + reflectiveQuadToRelative(227f, 93f) + } + }.build() + return _undefined!! + } private var _undefined: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader20.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader20.kt index 3caf3e4a..26f0e9c3 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader20.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader20.kt @@ -27,57 +27,59 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Loader20: ImageVector - get() { - if (_undefined != null) { - return _undefined!! - } - _undefined = ImageVector.Builder( - name = "Clock_loader_20", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(480f, 880f) - quadToRelative(-83f, 0f, -156f, -31.5f) - reflectiveQuadTo(197f, 763f) - reflectiveQuadToRelative(-85.5f, -127f) - reflectiveQuadTo(80f, 480f) - reflectiveQuadToRelative(31.5f, -156f) - reflectiveQuadTo(197f, 197f) - reflectiveQuadToRelative(127f, -85.5f) - reflectiveQuadTo(480f, 80f) - reflectiveQuadToRelative(156f, 31.5f) - reflectiveQuadTo(763f, 197f) - reflectiveQuadToRelative(85.5f, 127f) - reflectiveQuadTo(880f, 480f) - reflectiveQuadToRelative(-31.5f, 156f) - reflectiveQuadTo(763f, 763f) - reflectiveQuadToRelative(-127f, 85.5f) - reflectiveQuadTo(480f, 880f) - moveToRelative(0f, -80f) - quadToRelative(134f, 0f, 227f, -93f) - reflectiveQuadToRelative(93f, -227f) - horizontalLineTo(480f) - verticalLineToRelative(-320f) - quadToRelative(-134f, 0f, -227f, 93f) - reflectiveQuadToRelative(-93f, 227f) - reflectiveQuadToRelative(93f, 227f) - reflectiveQuadToRelative(227f, 93f) - } - }.build() - return _undefined!! + get() { + if (_undefined != null) { + return _undefined!! } + _undefined = + ImageVector + .Builder( + name = "Clock_loader_20", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 880f) + quadToRelative(-83f, 0f, -156f, -31.5f) + reflectiveQuadTo(197f, 763f) + reflectiveQuadToRelative(-85.5f, -127f) + reflectiveQuadTo(80f, 480f) + reflectiveQuadToRelative(31.5f, -156f) + reflectiveQuadTo(197f, 197f) + reflectiveQuadToRelative(127f, -85.5f) + reflectiveQuadTo(480f, 80f) + reflectiveQuadToRelative(156f, 31.5f) + reflectiveQuadTo(763f, 197f) + reflectiveQuadToRelative(85.5f, 127f) + reflectiveQuadTo(880f, 480f) + reflectiveQuadToRelative(-31.5f, 156f) + reflectiveQuadTo(763f, 763f) + reflectiveQuadToRelative(-127f, 85.5f) + reflectiveQuadTo(480f, 880f) + moveToRelative(0f, -80f) + quadToRelative(134f, 0f, 227f, -93f) + reflectiveQuadToRelative(93f, -227f) + horizontalLineTo(480f) + verticalLineToRelative(-320f) + quadToRelative(-134f, 0f, -227f, 93f) + reflectiveQuadToRelative(-93f, 227f) + reflectiveQuadToRelative(93f, 227f) + reflectiveQuadToRelative(227f, 93f) + } + }.build() + return _undefined!! + } private var _undefined: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader40.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader40.kt index 2a265bee..6944665d 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader40.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader40.kt @@ -27,57 +27,59 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Loader40: ImageVector - get() { - if (_undefined != null) { - return _undefined!! - } - _undefined = ImageVector.Builder( - name = "Clock_loader_40", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(480f, 880f) - quadToRelative(-83f, 0f, -156f, -31.5f) - reflectiveQuadTo(197f, 763f) - reflectiveQuadToRelative(-85.5f, -127f) - reflectiveQuadTo(80f, 480f) - reflectiveQuadToRelative(31.5f, -156f) - reflectiveQuadTo(197f, 197f) - reflectiveQuadToRelative(127f, -85.5f) - reflectiveQuadTo(480f, 80f) - reflectiveQuadToRelative(156f, 31.5f) - reflectiveQuadTo(763f, 197f) - reflectiveQuadToRelative(85.5f, 127f) - reflectiveQuadTo(880f, 480f) - reflectiveQuadToRelative(-31.5f, 156f) - reflectiveQuadTo(763f, 763f) - reflectiveQuadToRelative(-127f, 85.5f) - reflectiveQuadTo(480f, 880f) - moveToRelative(0f, -80f) - quadToRelative(64f, 0f, 123f, -24f) - reflectiveQuadToRelative(104f, -69f) - lineTo(480f, 480f) - verticalLineToRelative(-320f) - quadToRelative(-134f, 0f, -227f, 93f) - reflectiveQuadToRelative(-93f, 227f) - reflectiveQuadToRelative(93f, 227f) - reflectiveQuadToRelative(227f, 93f) - } - }.build() - return _undefined!! + get() { + if (_undefined != null) { + return _undefined!! } + _undefined = + ImageVector + .Builder( + name = "Clock_loader_40", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 880f) + quadToRelative(-83f, 0f, -156f, -31.5f) + reflectiveQuadTo(197f, 763f) + reflectiveQuadToRelative(-85.5f, -127f) + reflectiveQuadTo(80f, 480f) + reflectiveQuadToRelative(31.5f, -156f) + reflectiveQuadTo(197f, 197f) + reflectiveQuadToRelative(127f, -85.5f) + reflectiveQuadTo(480f, 80f) + reflectiveQuadToRelative(156f, 31.5f) + reflectiveQuadTo(763f, 197f) + reflectiveQuadToRelative(85.5f, 127f) + reflectiveQuadTo(880f, 480f) + reflectiveQuadToRelative(-31.5f, 156f) + reflectiveQuadTo(763f, 763f) + reflectiveQuadToRelative(-127f, 85.5f) + reflectiveQuadTo(480f, 880f) + moveToRelative(0f, -80f) + quadToRelative(64f, 0f, 123f, -24f) + reflectiveQuadToRelative(104f, -69f) + lineTo(480f, 480f) + verticalLineToRelative(-320f) + quadToRelative(-134f, 0f, -227f, 93f) + reflectiveQuadToRelative(-93f, 227f) + reflectiveQuadToRelative(93f, 227f) + reflectiveQuadToRelative(227f, 93f) + } + }.build() + return _undefined!! + } private var _undefined: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader60.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader60.kt index ca248760..6443d662 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader60.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader60.kt @@ -27,55 +27,57 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Loader60: ImageVector - get() { - if (_undefined != null) { - return _undefined!! - } - _undefined = ImageVector.Builder( - name = "Clock_loader_60", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(480f, 880f) - quadToRelative(-83f, 0f, -156f, -31.5f) - reflectiveQuadTo(197f, 763f) - reflectiveQuadToRelative(-85.5f, -127f) - reflectiveQuadTo(80f, 480f) - reflectiveQuadToRelative(31.5f, -156f) - reflectiveQuadTo(197f, 197f) - reflectiveQuadToRelative(127f, -85.5f) - reflectiveQuadTo(480f, 80f) - reflectiveQuadToRelative(156f, 31.5f) - reflectiveQuadTo(763f, 197f) - reflectiveQuadToRelative(85.5f, 127f) - reflectiveQuadTo(880f, 480f) - reflectiveQuadToRelative(-31.5f, 156f) - reflectiveQuadTo(763f, 763f) - reflectiveQuadToRelative(-127f, 85.5f) - reflectiveQuadTo(480f, 880f) - moveTo(253f, 707f) - lineToRelative(227f, -227f) - verticalLineToRelative(-320f) - quadToRelative(-134f, 0f, -227f, 93f) - reflectiveQuadToRelative(-93f, 227f) - quadToRelative(0f, 64f, 24f, 123f) - reflectiveQuadToRelative(69f, 104f) - } - }.build() - return _undefined!! + get() { + if (_undefined != null) { + return _undefined!! } + _undefined = + ImageVector + .Builder( + name = "Clock_loader_60", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 880f) + quadToRelative(-83f, 0f, -156f, -31.5f) + reflectiveQuadTo(197f, 763f) + reflectiveQuadToRelative(-85.5f, -127f) + reflectiveQuadTo(80f, 480f) + reflectiveQuadToRelative(31.5f, -156f) + reflectiveQuadTo(197f, 197f) + reflectiveQuadToRelative(127f, -85.5f) + reflectiveQuadTo(480f, 80f) + reflectiveQuadToRelative(156f, 31.5f) + reflectiveQuadTo(763f, 197f) + reflectiveQuadToRelative(85.5f, 127f) + reflectiveQuadTo(880f, 480f) + reflectiveQuadToRelative(-31.5f, 156f) + reflectiveQuadTo(763f, 763f) + reflectiveQuadToRelative(-127f, 85.5f) + reflectiveQuadTo(480f, 880f) + moveTo(253f, 707f) + lineToRelative(227f, -227f) + verticalLineToRelative(-320f) + quadToRelative(-134f, 0f, -227f, 93f) + reflectiveQuadToRelative(-93f, 227f) + quadToRelative(0f, 64f, 24f, 123f) + reflectiveQuadToRelative(69f, 104f) + } + }.build() + return _undefined!! + } private var _undefined: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader80.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader80.kt index 61e68249..138e6a95 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader80.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader80.kt @@ -27,53 +27,55 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Loader80: ImageVector - get() { - if (_undefined != null) { - return _undefined!! - } - _undefined = ImageVector.Builder( - name = "Clock_loader_80", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(480f, 880f) - quadToRelative(-83f, 0f, -156f, -31.5f) - reflectiveQuadTo(197f, 763f) - reflectiveQuadToRelative(-85.5f, -127f) - reflectiveQuadTo(80f, 480f) - reflectiveQuadToRelative(31.5f, -156f) - reflectiveQuadTo(197f, 197f) - reflectiveQuadToRelative(127f, -85.5f) - reflectiveQuadTo(480f, 80f) - reflectiveQuadToRelative(156f, 31.5f) - reflectiveQuadTo(763f, 197f) - reflectiveQuadToRelative(85.5f, 127f) - reflectiveQuadTo(880f, 480f) - reflectiveQuadToRelative(-31.5f, 156f) - reflectiveQuadTo(763f, 763f) - reflectiveQuadToRelative(-127f, 85.5f) - reflectiveQuadTo(480f, 880f) - moveTo(160f, 480f) - horizontalLineToRelative(320f) - verticalLineToRelative(-320f) - quadToRelative(-134f, 0f, -227f, 93f) - reflectiveQuadToRelative(-93f, 227f) - } - }.build() - return _undefined!! + get() { + if (_undefined != null) { + return _undefined!! } + _undefined = + ImageVector + .Builder( + name = "Clock_loader_80", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 880f) + quadToRelative(-83f, 0f, -156f, -31.5f) + reflectiveQuadTo(197f, 763f) + reflectiveQuadToRelative(-85.5f, -127f) + reflectiveQuadTo(80f, 480f) + reflectiveQuadToRelative(31.5f, -156f) + reflectiveQuadTo(197f, 197f) + reflectiveQuadToRelative(127f, -85.5f) + reflectiveQuadTo(480f, 80f) + reflectiveQuadToRelative(156f, 31.5f) + reflectiveQuadTo(763f, 197f) + reflectiveQuadToRelative(85.5f, 127f) + reflectiveQuadTo(880f, 480f) + reflectiveQuadToRelative(-31.5f, 156f) + reflectiveQuadTo(763f, 763f) + reflectiveQuadToRelative(-127f, 85.5f) + reflectiveQuadTo(480f, 880f) + moveTo(160f, 480f) + horizontalLineToRelative(320f) + verticalLineToRelative(-320f) + quadToRelative(-134f, 0f, -227f, 93f) + reflectiveQuadToRelative(-93f, 227f) + } + }.build() + return _undefined!! + } private var _undefined: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader90.kt b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader90.kt index 2eb226d0..443ff6f1 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader90.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/icons/loader/Loader90.kt @@ -27,53 +27,55 @@ import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.unit.dp val Loader90: ImageVector - get() { - if (_undefined != null) { - return _undefined!! - } - _undefined = ImageVector.Builder( - name = "Clock_loader_90", - defaultWidth = 24.dp, - defaultHeight = 24.dp, - viewportWidth = 960f, - viewportHeight = 960f, - ).apply { - path( - fill = SolidColor(Color.Black), - fillAlpha = 1.0f, - stroke = null, - strokeAlpha = 1.0f, - strokeLineWidth = 1.0f, - strokeLineCap = StrokeCap.Butt, - strokeLineJoin = StrokeJoin.Miter, - strokeLineMiter = 1.0f, - pathFillType = PathFillType.NonZero, - ) { - moveTo(480f, 880f) - quadToRelative(-83f, 0f, -156f, -31.5f) - reflectiveQuadTo(197f, 763f) - reflectiveQuadToRelative(-85.5f, -127f) - reflectiveQuadTo(80f, 480f) - reflectiveQuadToRelative(31.5f, -156f) - reflectiveQuadTo(197f, 197f) - reflectiveQuadToRelative(127f, -85.5f) - reflectiveQuadTo(480f, 80f) - reflectiveQuadToRelative(156f, 31.5f) - reflectiveQuadTo(763f, 197f) - reflectiveQuadToRelative(85.5f, 127f) - reflectiveQuadTo(880f, 480f) - reflectiveQuadToRelative(-31.5f, 156f) - reflectiveQuadTo(763f, 763f) - reflectiveQuadToRelative(-127f, 85.5f) - reflectiveQuadTo(480f, 880f) - moveTo(253f, 253f) - lineToRelative(227f, 227f) - verticalLineToRelative(-320f) - quadToRelative(-64f, 0f, -123f, 24f) - reflectiveQuadToRelative(-104f, 69f) - } - }.build() - return _undefined!! + get() { + if (_undefined != null) { + return _undefined!! } + _undefined = + ImageVector + .Builder( + name = "Clock_loader_90", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(480f, 880f) + quadToRelative(-83f, 0f, -156f, -31.5f) + reflectiveQuadTo(197f, 763f) + reflectiveQuadToRelative(-85.5f, -127f) + reflectiveQuadTo(80f, 480f) + reflectiveQuadToRelative(31.5f, -156f) + reflectiveQuadTo(197f, 197f) + reflectiveQuadToRelative(127f, -85.5f) + reflectiveQuadTo(480f, 80f) + reflectiveQuadToRelative(156f, 31.5f) + reflectiveQuadTo(763f, 197f) + reflectiveQuadToRelative(85.5f, 127f) + reflectiveQuadTo(880f, 480f) + reflectiveQuadToRelative(-31.5f, 156f) + reflectiveQuadTo(763f, 763f) + reflectiveQuadToRelative(-127f, 85.5f) + reflectiveQuadTo(480f, 880f) + moveTo(253f, 253f) + lineToRelative(227f, 227f) + verticalLineToRelative(-320f) + quadToRelative(-64f, 0f, -123f, 24f) + reflectiveQuadToRelative(-104f, 69f) + } + }.build() + return _undefined!! + } private var _undefined: ImageVector? = null diff --git a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppLaunchAction.kt b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppLaunchAction.kt index f7608b2e..dcf68f5b 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppLaunchAction.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppLaunchAction.kt @@ -1,6 +1,6 @@ package org.grakovne.lissen.ui.navigation enum class AppLaunchAction { - CONTINUE_PLAYBACK, - DEFAULT, + CONTINUE_PLAYBACK, + DEFAULT, } diff --git a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt index d817e2d9..bbbe8dc1 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavHost.kt @@ -33,146 +33,164 @@ import org.grakovne.lissen.ui.screens.settings.advanced.SeekSettingsScreen @Composable @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") fun AppNavHost( - navController: NavHostController, - preferences: LissenSharedPreferences, - networkQualityService: NetworkQualityService, - navigationService: AppNavigationService, - imageLoader: ImageLoader, - appLaunchAction: AppLaunchAction, + navController: NavHostController, + preferences: LissenSharedPreferences, + networkQualityService: NetworkQualityService, + navigationService: AppNavigationService, + imageLoader: ImageLoader, + appLaunchAction: AppLaunchAction, ) { - val hasCredentials by remember { - mutableStateOf( - preferences.hasCredentials(), + val hasCredentials by remember { + mutableStateOf( + preferences.hasCredentials(), + ) + } + + val book = preferences.getPlayingBook() + + val startDestination = + when { + hasCredentials.not() -> + "login_screen" + appLaunchAction == AppLaunchAction.CONTINUE_PLAYBACK && book != null -> + "player_screen/${book.id}?bookTitle=${book.title}&bookSubtitle=${book.subtitle}&startInstantly=true" + else -> + "library_screen" + } + + val enterTransition: EnterTransition = + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = tween(), + ) + fadeIn(animationSpec = tween()) + + val exitTransition: ExitTransition = + slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = tween(), + ) + fadeOut(animationSpec = tween()) + + val popEnterTransition: EnterTransition = + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(), + ) + fadeIn(animationSpec = tween()) + + val popExitTransition: ExitTransition = + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = tween(), + ) + fadeOut(animationSpec = tween()) + + Scaffold(modifier = Modifier.fillMaxSize()) { _ -> + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable("library_screen") { + LibraryScreen( + navController = navigationService, + imageLoader = imageLoader, + networkQualityService = networkQualityService, ) + } + + composable( + route = "player_screen/{bookId}?bookTitle={bookTitle}&bookSubtitle={bookSubtitle}&startInstantly={startInstantly}", + arguments = + listOf( + navArgument("bookId") { type = NavType.StringType }, + navArgument("bookTitle") { + type = NavType.StringType + nullable = true + }, + navArgument("bookSubtitle") { + type = NavType.StringType + nullable = true + }, + navArgument("startInstantly") { + type = NavType.BoolType + nullable = false + }, + ), + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { navigationStack -> + val bookId = navigationStack.arguments?.getString("bookId") ?: return@composable + val bookTitle = navigationStack.arguments?.getString("bookTitle") ?: "" + val bookSubtitle = navigationStack.arguments?.getString("bookSubtitle") + val startInstantly = navigationStack.arguments?.getBoolean("startInstantly") + + PlayerScreen( + navController = navigationService, + imageLoader = imageLoader, + bookId = bookId, + bookTitle = bookTitle, + bookSubtitle = bookSubtitle, + playInstantly = startInstantly ?: false, + ) + } + + composable( + route = "login_screen", + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { + LoginScreen(navigationService) + } + + composable( + route = "settings_screen", + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { + SettingsScreen( + onBack = { + if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } + }, + navController = navigationService, + ) + } + + composable( + route = "settings_screen/custom_headers", + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { + CustomHeadersSettingsScreen( + onBack = { + if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } + }, + ) + } + + composable( + route = "settings_screen/seek_settings", + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + ) { + SeekSettingsScreen( + onBack = { + if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } + }, + ) + } } - - val book = preferences.getPlayingBook() - - val startDestination = when { - hasCredentials.not() -> "login_screen" - appLaunchAction == AppLaunchAction.CONTINUE_PLAYBACK && book != null -> "player_screen/${book.id}?bookTitle=${book.title}&bookSubtitle=${book.subtitle}&startInstantly=true" - else -> "library_screen" - } - - val enterTransition: EnterTransition = slideInHorizontally( - initialOffsetX = { it }, - animationSpec = tween(), - ) + fadeIn(animationSpec = tween()) - - val exitTransition: ExitTransition = slideOutHorizontally( - targetOffsetX = { -it }, - animationSpec = tween(), - ) + fadeOut(animationSpec = tween()) - - val popEnterTransition: EnterTransition = slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = tween(), - ) + fadeIn(animationSpec = tween()) - - val popExitTransition: ExitTransition = slideOutHorizontally( - targetOffsetX = { it }, - animationSpec = tween(), - ) + fadeOut(animationSpec = tween()) - - Scaffold(modifier = Modifier.fillMaxSize()) { _ -> - NavHost( - navController = navController, - startDestination = startDestination, - ) { - composable("library_screen") { - LibraryScreen( - navController = navigationService, - imageLoader = imageLoader, - networkQualityService = networkQualityService, - ) - } - - composable( - route = "player_screen/{bookId}?bookTitle={bookTitle}&bookSubtitle={bookSubtitle}&startInstantly={startInstantly}", - arguments = listOf( - navArgument("bookId") { type = NavType.StringType }, - navArgument("bookTitle") { type = NavType.StringType; nullable = true }, - navArgument("bookSubtitle") { type = NavType.StringType; nullable = true }, - navArgument("startInstantly") { type = NavType.BoolType; nullable = false }, - ), - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, - ) { navigationStack -> - val bookId = navigationStack.arguments?.getString("bookId") ?: return@composable - val bookTitle = navigationStack.arguments?.getString("bookTitle") ?: "" - val bookSubtitle = navigationStack.arguments?.getString("bookSubtitle") - val startInstantly = navigationStack.arguments?.getBoolean("startInstantly") - - PlayerScreen( - navController = navigationService, - imageLoader = imageLoader, - bookId = bookId, - bookTitle = bookTitle, - bookSubtitle = bookSubtitle, - playInstantly = startInstantly ?: false, - ) - } - - composable( - route = "login_screen", - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, - ) { - LoginScreen(navigationService) - } - - composable( - route = "settings_screen", - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, - ) { - SettingsScreen( - onBack = { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } - }, - navController = navigationService, - ) - } - - composable( - route = "settings_screen/custom_headers", - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, - ) { - CustomHeadersSettingsScreen( - onBack = { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } - }, - ) - } - - composable( - route = "settings_screen/seek_settings", - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, - ) { - SeekSettingsScreen( - onBack = { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } - }, - ) - } - } - } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt index 5c15b0f1..2b7912af 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt @@ -4,45 +4,48 @@ import android.net.Uri import androidx.navigation.NavHostController class AppNavigationService( - private val host: NavHostController, + private val host: NavHostController, ) { - fun showLibrary(clearHistory: Boolean = false) { - host.navigate(ROUTE_LIBRARY) { - launchSingleTop = true - popUpTo(host.graph.startDestinationId) { inclusive = clearHistory } - } + fun showLibrary(clearHistory: Boolean = false) { + host.navigate(ROUTE_LIBRARY) { + launchSingleTop = true + popUpTo(host.graph.startDestinationId) { inclusive = clearHistory } } + } - fun showPlayer( - bookId: String, - bookTitle: String, - bookSubtitle: String?, - startInstantly: Boolean = false, - ) { - val route = buildString { - append("$ROUTE_PLAYER/$bookId") - append("?bookTitle=${Uri.encode(bookTitle)}") - append("&bookSubtitle=${Uri.encode(bookSubtitle ?: "")}") - append("&startInstantly=$startInstantly") - } - host.navigate(route) { launchSingleTop = true } + fun showPlayer( + bookId: String, + bookTitle: String, + bookSubtitle: String?, + startInstantly: Boolean = false, + ) { + val route = + buildString { + append("$ROUTE_PLAYER/$bookId") + append("?bookTitle=${Uri.encode(bookTitle)}") + append("&bookSubtitle=${Uri.encode(bookSubtitle ?: "")}") + append("&startInstantly=$startInstantly") + } + host.navigate(route) { launchSingleTop = true } + } + + fun showSettings() = host.navigate(ROUTE_SETTINGS) + + fun showCustomHeadersSettings() = host.navigate("$ROUTE_SETTINGS/custom_headers") + + fun showSeekSettings() = host.navigate("$ROUTE_SETTINGS/seek_settings") + + fun showLogin() { + host.navigate(ROUTE_LOGIN) { + popUpTo(0) { inclusive = true } + launchSingleTop = true } + } - fun showSettings() = host.navigate(ROUTE_SETTINGS) - fun showCustomHeadersSettings() = host.navigate("$ROUTE_SETTINGS/custom_headers") - fun showSeekSettings() = host.navigate("$ROUTE_SETTINGS/seek_settings") - - fun showLogin() { - host.navigate(ROUTE_LOGIN) { - popUpTo(0) { inclusive = true } - launchSingleTop = true - } - } - - private companion object { - const val ROUTE_LIBRARY = "library_screen" - const val ROUTE_PLAYER = "player_screen" - const val ROUTE_SETTINGS = "settings_screen" - const val ROUTE_LOGIN = "login_screen" - } + private companion object { + const val ROUTE_LIBRARY = "library_screen" + const val ROUTE_PLAYER = "player_screen" + const val ROUTE_SETTINGS = "settings_screen" + const val ROUTE_LOGIN = "login_screen" + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/common/RequestNotificationPermissions.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/common/RequestNotificationPermissions.kt index be396992..13e6e206 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/common/RequestNotificationPermissions.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/common/RequestNotificationPermissions.kt @@ -12,23 +12,25 @@ import androidx.core.content.ContextCompat @Composable fun RequestNotificationPermissions() { - val context = LocalContext.current + val context = LocalContext.current - val permissionRequestLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { }, + val permissionRequestLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { }, ) - LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val permissionStatus = ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val permissionStatus = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) - when (permissionStatus == PackageManager.PERMISSION_GRANTED) { - true -> {} - false -> permissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } + when (permissionStatus == PackageManager.PERMISSION_GRANTED) { + true -> {} + false -> permissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt index a0c61952..2bed5f18 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/LibraryScreen.kt @@ -81,370 +81,386 @@ import org.grakovne.lissen.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun LibraryScreen( - navController: AppNavigationService, - libraryViewModel: LibraryViewModel = hiltViewModel(), - playerViewModel: PlayerViewModel = hiltViewModel(), - settingsViewModel: SettingsViewModel = hiltViewModel(), - cachingModelView: CachingModelView = hiltViewModel(), - imageLoader: ImageLoader, - networkQualityService: NetworkQualityService, + navController: AppNavigationService, + libraryViewModel: LibraryViewModel = hiltViewModel(), + playerViewModel: PlayerViewModel = hiltViewModel(), + settingsViewModel: SettingsViewModel = hiltViewModel(), + cachingModelView: CachingModelView = hiltViewModel(), + imageLoader: ImageLoader, + networkQualityService: NetworkQualityService, ) { - RequestNotificationPermissions() + RequestNotificationPermissions() - val coroutineScope = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() - val activity = LocalActivity.current - val recentBooks: List by libraryViewModel.recentBooks.observeAsState(emptyList()) + val activity = LocalActivity.current + val recentBooks: List by libraryViewModel.recentBooks.observeAsState(emptyList()) - var currentLibraryId by rememberSaveable { mutableStateOf("") } - var currentOrdering by rememberSaveable(stateSaver = LibraryOrderingConfiguration.saver) { - mutableStateOf(LibraryOrderingConfiguration.default) - } - var pullRefreshing by remember { mutableStateOf(false) } - val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false) - val searchRequested by libraryViewModel.searchRequested.observeAsState(false) - val preparingError by playerViewModel.preparingError.observeAsState(false) + var currentLibraryId by rememberSaveable { mutableStateOf("") } + var currentOrdering by rememberSaveable(stateSaver = LibraryOrderingConfiguration.saver) { + mutableStateOf(LibraryOrderingConfiguration.default) + } + var pullRefreshing by remember { mutableStateOf(false) } + val recentBookRefreshing by libraryViewModel.recentBookUpdating.observeAsState(false) + val searchRequested by libraryViewModel.searchRequested.observeAsState(false) + val preparingError by playerViewModel.preparingError.observeAsState(false) - val preferredLibrary by settingsViewModel.preferredLibrary.observeAsState() - val libraries by settingsViewModel.libraries.observeAsState(emptyList()) - var preferredLibraryExpanded by remember { mutableStateOf(false) } + val preferredLibrary by settingsViewModel.preferredLibrary.observeAsState() + val libraries by settingsViewModel.libraries.observeAsState(emptyList()) + var preferredLibraryExpanded by remember { mutableStateOf(false) } - val library = when (searchRequested) { - true -> libraryViewModel.searchPager.collectAsLazyPagingItems() - false -> libraryViewModel.libraryPager.collectAsLazyPagingItems() + val library = + when (searchRequested) { + true -> libraryViewModel.searchPager.collectAsLazyPagingItems() + false -> libraryViewModel.libraryPager.collectAsLazyPagingItems() } - BackHandler { - when (searchRequested) { - true -> libraryViewModel.dismissSearch() - false -> activity?.moveTaskToBack(true) + BackHandler { + when (searchRequested) { + true -> libraryViewModel.dismissSearch() + false -> activity?.moveTaskToBack(true) + } + } + + fun refreshContent(showPullRefreshing: Boolean) { + coroutineScope.launch { + if (showPullRefreshing) { + pullRefreshing = true + } + + val minimumTime = + when (showPullRefreshing) { + true -> 500L + false -> 0L } + + withMinimumTime(minimumTime) { + listOf( + async { settingsViewModel.fetchLibraries() }, + async { libraryViewModel.refreshLibrary() }, + async { libraryViewModel.fetchRecentListening() }, + ).awaitAll() + } + + pullRefreshing = false } + } - fun refreshContent(showPullRefreshing: Boolean) { - coroutineScope.launch { - if (showPullRefreshing) { - pullRefreshing = true - } + val isPlaceholderRequired by remember { + derivedStateOf { + if (searchRequested) { + return@derivedStateOf false + } - val minimumTime = when (showPullRefreshing) { - true -> 500L - false -> 0L - } - - withMinimumTime(minimumTime) { - listOf( - async { settingsViewModel.fetchLibraries() }, - async { libraryViewModel.refreshLibrary() }, - async { libraryViewModel.fetchRecentListening() }, - ).awaitAll() - } - - pullRefreshing = false - } + pullRefreshing || recentBookRefreshing || library.loadState.refresh is LoadState.Loading } + } - val isPlaceholderRequired by remember { - derivedStateOf { - if (searchRequested) { - return@derivedStateOf false - } - - pullRefreshing || recentBookRefreshing || library.loadState.refresh is LoadState.Loading - } + LaunchedEffect(preparingError) { + if (preparingError) { + playerViewModel.clearPlayingBook() } + } - LaunchedEffect(preparingError) { - if (preparingError) { - playerViewModel.clearPlayingBook() - } - } - - val pullRefreshState = rememberPullRefreshState( - refreshing = pullRefreshing, - onRefresh = { - refreshContent(showPullRefreshing = true) - }, + val pullRefreshState = + rememberPullRefreshState( + refreshing = pullRefreshing, + onRefresh = { + refreshContent(showPullRefreshing = true) + }, ) - val titleTextStyle = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - val titleHeightDp = with(LocalDensity.current) { titleTextStyle.lineHeight.toPx().toDp() } + val titleTextStyle = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + val titleHeightDp = with(LocalDensity.current) { titleTextStyle.lineHeight.toPx().toDp() } - val libraryListState = rememberLazyListState() + val libraryListState = rememberLazyListState() - val playingBook by playerViewModel.book.observeAsState() - val context = LocalContext.current + val playingBook by playerViewModel.book.observeAsState() + val context = LocalContext.current - fun isRecentVisible(): Boolean { - val fetchAvailable = networkQualityService.isNetworkAvailable() || cachingModelView.localCacheUsing() - val hasContent = recentBooks.isEmpty().not() - return !searchRequested && hasContent && fetchAvailable + fun isRecentVisible(): Boolean { + val fetchAvailable = networkQualityService.isNetworkAvailable() || cachingModelView.localCacheUsing() + val hasContent = recentBooks.isEmpty().not() + return !searchRequested && hasContent && fetchAvailable + } + + LaunchedEffect(Unit) { + val emptyContent = library.itemCount == 0 + val libraryChanged = currentLibraryId != settingsViewModel.fetchPreferredLibraryId() + val orderingChanged = currentOrdering != settingsViewModel.fetchLibraryOrdering() + + if (emptyContent || libraryChanged || orderingChanged) { + libraryViewModel.refreshRecentListening() + libraryViewModel.refreshLibrary() + + currentLibraryId = settingsViewModel.fetchPreferredLibraryId() + currentOrdering = settingsViewModel.fetchLibraryOrdering() } - LaunchedEffect(Unit) { - val emptyContent = library.itemCount == 0 - val libraryChanged = currentLibraryId != settingsViewModel.fetchPreferredLibraryId() - val orderingChanged = currentOrdering != settingsViewModel.fetchLibraryOrdering() + playerViewModel.recoverMiniPlayer() + settingsViewModel.fetchLibraries() + } - if (emptyContent || libraryChanged || orderingChanged) { - libraryViewModel.refreshRecentListening() - libraryViewModel.refreshLibrary() + LaunchedEffect(searchRequested) { + if (!searchRequested) { + libraryListState.scrollToItem(0) + } + } - currentLibraryId = settingsViewModel.fetchPreferredLibraryId() - currentOrdering = settingsViewModel.fetchLibraryOrdering() + fun provideLibraryTitle(): String { + val type = libraryViewModel.fetchPreferredLibraryType() + + return when (type) { + LibraryType.LIBRARY -> + libraryViewModel + .fetchPreferredLibraryTitle() + ?: context.getString(R.string.library_screen_library_title) + + LibraryType.PODCAST -> + libraryViewModel + .fetchPreferredLibraryTitle() + ?: context.getString(R.string.library_screen_podcast_title) + + LibraryType.UNKNOWN -> "" + } + } + + val navBarTitle by remember { + derivedStateOf { + val showRecent = isRecentVisible() + val recentBlockVisible = + libraryListState.layoutInfo.visibleItemsInfo + .firstOrNull() + ?.key == "recent_books" + + when { + isPlaceholderRequired -> context.getString(R.string.library_screen_continue_listening_title) + showRecent && recentBlockVisible -> context.getString(R.string.library_screen_continue_listening_title) + else -> provideLibraryTitle() + } + } + } + + Scaffold( + topBar = { + TopAppBar( + actions = { + AnimatedContent( + targetState = searchRequested, + label = "library_action_animation", + transitionSpec = { + fadeIn(animationSpec = keyframes { durationMillis = 150 }) togetherWith + fadeOut(animationSpec = keyframes { durationMillis = 150 }) + }, + ) { isSearchRequested -> + when (isSearchRequested) { + true -> + LibrarySearchActionComposable( + onSearchDismissed = { libraryViewModel.dismissSearch() }, + onSearchRequested = { libraryViewModel.updateSearch(it) }, + ) + + false -> + DefaultActionComposable( + navController = navController, + contentCachingModelView = cachingModelView, + playerViewModel = playerViewModel, + onContentRefreshing = { refreshContent(showPullRefreshing = false) }, + onSearchRequested = { libraryViewModel.requestSearch() }, + ) + } + } + }, + title = { + if (!searchRequested) { + Row( + modifier = + when (navBarTitle) { + provideLibraryTitle() -> + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { preferredLibraryExpanded = true } + .fillMaxWidth() + + else -> Modifier.fillMaxWidth() + }, + ) { + Text( + text = navBarTitle, + style = titleTextStyle, + maxLines = 1, + ) + + if (navBarTitle == provideLibraryTitle()) { + LibrarySwitchComposable { preferredLibraryExpanded = true } + } + } + } + }, + modifier = Modifier.systemBarsPadding(), + ) + }, + bottomBar = { + playingBook?.let { + Surface(shadowElevation = 4.dp) { + MiniPlayerComposable( + navController = navController, + book = it, + imageLoader = imageLoader, + playerViewModel = playerViewModel, + ) } - - playerViewModel.recoverMiniPlayer() - settingsViewModel.fetchLibraries() - } - - LaunchedEffect(searchRequested) { - if (!searchRequested) { - libraryListState.scrollToItem(0) - } - } - - fun provideLibraryTitle(): String { - val type = libraryViewModel.fetchPreferredLibraryType() - - return when (type) { - LibraryType.LIBRARY -> - libraryViewModel - .fetchPreferredLibraryTitle() - ?: context.getString(R.string.library_screen_library_title) - - LibraryType.PODCAST -> - libraryViewModel - .fetchPreferredLibraryTitle() - ?: context.getString(R.string.library_screen_podcast_title) - - LibraryType.UNKNOWN -> "" - } - } - - val navBarTitle by remember { - derivedStateOf { + } + }, + modifier = + Modifier + .systemBarsPadding() + .fillMaxSize(), + content = { innerPadding -> + Box( + modifier = + Modifier + .padding(innerPadding) + .testTag("libraryScreen") + .pullRefresh(pullRefreshState) + .fillMaxSize(), + ) { + LazyColumn( + state = libraryListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item(key = "recent_books") { val showRecent = isRecentVisible() - val recentBlockVisible = libraryListState.layoutInfo.visibleItemsInfo.firstOrNull()?.key == "recent_books" when { - isPlaceholderRequired -> context.getString(R.string.library_screen_continue_listening_title) - showRecent && recentBlockVisible -> context.getString(R.string.library_screen_continue_listening_title) - else -> provideLibraryTitle() + isPlaceholderRequired -> { + RecentBooksPlaceholderComposable( + libraryViewModel = libraryViewModel, + ) + } + + showRecent -> { + RecentBooksComposable( + navController = navController, + recentBooks = recentBooks, + imageLoader = imageLoader, + libraryViewModel = libraryViewModel, + ) + } } + + Spacer(modifier = Modifier.height(20.dp)) + } + + item(key = "library_title") { + if (!searchRequested && isRecentVisible()) { + AnimatedContent( + targetState = navBarTitle, + transitionSpec = { + fadeIn( + animationSpec = + tween(300), + ) togetherWith + fadeOut( + animationSpec = + tween( + 300, + ), + ) + }, + label = "library_header_fade", + ) { + when { + it == provideLibraryTitle() -> + Spacer( + modifier = + Modifier + .fillMaxWidth() + .height(titleHeightDp), + ) + + else -> { + if (isPlaceholderRequired.not()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { preferredLibraryExpanded = true } + .fillMaxWidth(), + ) { + Text( + style = titleTextStyle, + text = provideLibraryTitle(), + ) + + LibrarySwitchComposable { preferredLibraryExpanded = true } + } + } + } + } + } + } + } + + item(key = "library_spacer") { Spacer(modifier = Modifier.height(8.dp)) } + + when { + isPlaceholderRequired -> item { LibraryPlaceholderComposable() } + library.itemCount == 0 -> { + item { + LibraryFallbackComposable( + searchRequested = searchRequested, + contentCachingModelView = cachingModelView, + networkQualityService = networkQualityService, + libraryViewModel = libraryViewModel, + ) + } + } + + else -> + items(count = library.itemCount, key = { "library_item_$it" }) { + val book = library[it] ?: return@items + + BookComposable( + book = book, + imageLoader = imageLoader, + navController = navController, + ) + } + } } - } - Scaffold( - topBar = { - TopAppBar( - actions = { - AnimatedContent( - targetState = searchRequested, - label = "library_action_animation", - transitionSpec = { - fadeIn(animationSpec = keyframes { durationMillis = 150 }) togetherWith - fadeOut(animationSpec = keyframes { durationMillis = 150 }) - }, - ) { isSearchRequested -> - when (isSearchRequested) { - true -> LibrarySearchActionComposable( - onSearchDismissed = { libraryViewModel.dismissSearch() }, - onSearchRequested = { libraryViewModel.updateSearch(it) }, - ) + if (!searchRequested) { + PullRefreshIndicator( + refreshing = pullRefreshing, + state = pullRefreshState, + contentColor = colorScheme.primary, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + }, + ) - false -> DefaultActionComposable( - navController = navController, - contentCachingModelView = cachingModelView, - playerViewModel = playerViewModel, - onContentRefreshing = { refreshContent(showPullRefreshing = false) }, - onSearchRequested = { libraryViewModel.requestSearch() }, - ) - } - } - }, - title = { - if (!searchRequested) { - Row( - modifier = when (navBarTitle) { - provideLibraryTitle() -> - Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { preferredLibraryExpanded = true } - .fillMaxWidth() + if (preferredLibraryExpanded) { + PreferredLibrarySettingComposable( + libraries = libraries, + preferredLibrary = preferredLibrary, + onDismissRequest = { preferredLibraryExpanded = false }, + onItemSelected = { + settingsViewModel.preferLibrary(it) + currentLibraryId = settingsViewModel.fetchPreferredLibraryId() + refreshContent(false) + playerViewModel.clearPlayingBook() - else -> Modifier.fillMaxWidth() - }, - ) { - Text( - text = navBarTitle, - style = titleTextStyle, - maxLines = 1, - ) - - if (navBarTitle == provideLibraryTitle()) { - LibrarySwitchComposable { preferredLibraryExpanded = true } - } - } - } - }, - modifier = Modifier.systemBarsPadding(), - ) - }, - bottomBar = { - playingBook?.let { - Surface(shadowElevation = 4.dp) { - MiniPlayerComposable( - navController = navController, - book = it, - imageLoader = imageLoader, - playerViewModel = playerViewModel, - ) - } - } - }, - modifier = Modifier - .systemBarsPadding() - .fillMaxSize(), - content = { innerPadding -> - Box( - modifier = Modifier - .padding(innerPadding) - .testTag("libraryScreen") - .pullRefresh(pullRefreshState) - .fillMaxSize(), - ) { - LazyColumn( - state = libraryListState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - item(key = "recent_books") { - val showRecent = isRecentVisible() - - when { - isPlaceholderRequired -> { - RecentBooksPlaceholderComposable( - libraryViewModel = libraryViewModel, - ) - } - - showRecent -> { - RecentBooksComposable( - navController = navController, - recentBooks = recentBooks, - imageLoader = imageLoader, - libraryViewModel = libraryViewModel, - ) - } - } - - Spacer(modifier = Modifier.height(20.dp)) - } - - item(key = "library_title") { - if (!searchRequested && isRecentVisible()) { - AnimatedContent( - targetState = navBarTitle, - transitionSpec = { - fadeIn( - animationSpec = - tween(300), - ) togetherWith fadeOut( - animationSpec = tween( - 300, - ), - ) - }, - label = "library_header_fade", - ) { - when { - it == provideLibraryTitle() -> - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(titleHeightDp), - ) - - else -> { - if (isPlaceholderRequired.not()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { preferredLibraryExpanded = true } - .fillMaxWidth(), - ) { - Text( - style = titleTextStyle, - text = provideLibraryTitle(), - ) - - LibrarySwitchComposable { preferredLibraryExpanded = true } - } - } - } - } - } - } - } - - item(key = "library_spacer") { Spacer(modifier = Modifier.height(8.dp)) } - - when { - isPlaceholderRequired -> item { LibraryPlaceholderComposable() } - library.itemCount == 0 -> { - item { - LibraryFallbackComposable( - searchRequested = searchRequested, - contentCachingModelView = cachingModelView, - networkQualityService = networkQualityService, - libraryViewModel = libraryViewModel, - ) - } - } - - else -> items(count = library.itemCount, key = { "library_item_$it" }) { - val book = library[it] ?: return@items - - BookComposable( - book = book, - imageLoader = imageLoader, - navController = navController, - ) - } - } - } - - if (!searchRequested) { - PullRefreshIndicator( - refreshing = pullRefreshing, - state = pullRefreshState, - contentColor = colorScheme.primary, - modifier = Modifier.align(Alignment.TopCenter), - ) - } - } - }, + preferredLibraryExpanded = false + }, ) - - if (preferredLibraryExpanded) { - PreferredLibrarySettingComposable( - libraries = libraries, - preferredLibrary = preferredLibrary, - onDismissRequest = { preferredLibraryExpanded = false }, - onItemSelected = { - settingsViewModel.preferLibrary(it) - currentLibraryId = settingsViewModel.fetchPreferredLibraryId() - refreshContent(false) - playerViewModel.clearPlayingBook() - - preferredLibraryExpanded = false - }, - ) - } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/PreferredLibrarySettingComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/PreferredLibrarySettingComposable.kt index d46499ea..dd7ad7ab 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/PreferredLibrarySettingComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/PreferredLibrarySettingComposable.kt @@ -12,28 +12,30 @@ import org.grakovne.lissen.ui.screens.settings.composable.CommonSettingsItemComp @Composable fun PreferredLibrarySettingComposable( - libraries: List, - preferredLibrary: Library?, - onDismissRequest: () -> Unit, - onItemSelected: (Library) -> Unit, + libraries: List, + preferredLibrary: Library?, + onDismissRequest: () -> Unit, + onItemSelected: (Library) -> Unit, ) { - CommonSettingsItemComposable( - items = libraries.map { CommonSettingsItem(it.id, it.title, it.type.provideIcon()) }, - selectedItem = preferredLibrary?.let { CommonSettingsItem(it.id, it.title, it.type.provideIcon()) }, - onDismissRequest = { onDismissRequest() }, - onItemSelected = { item -> - val selectedItem = libraries.find { it.id == item.id } - ?: return@CommonSettingsItemComposable + CommonSettingsItemComposable( + items = libraries.map { CommonSettingsItem(it.id, it.title, it.type.provideIcon()) }, + selectedItem = preferredLibrary?.let { CommonSettingsItem(it.id, it.title, it.type.provideIcon()) }, + onDismissRequest = { onDismissRequest() }, + onItemSelected = { item -> + val selectedItem = + libraries.find { it.id == item.id } + ?: return@CommonSettingsItemComposable - if (selectedItem != preferredLibrary) { - onItemSelected(selectedItem) - } - }, - ) + if (selectedItem != preferredLibrary) { + onItemSelected(selectedItem) + } + }, + ) } -fun LibraryType.provideIcon() = when (this) { +fun LibraryType.provideIcon() = + when (this) { LibraryType.LIBRARY -> Icons.Outlined.Book LibraryType.PODCAST -> Icons.Outlined.Podcasts LibraryType.UNKNOWN -> Icons.Outlined.NotInterested -} + } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/BookComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/BookComposable.kt index 2701ed14..98bed8ba 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/BookComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/BookComposable.kt @@ -34,86 +34,93 @@ import org.grakovne.lissen.ui.navigation.AppNavigationService @Composable fun BookComposable( - book: Book, - imageLoader: ImageLoader, - navController: AppNavigationService, + book: Book, + imageLoader: ImageLoader, + navController: AppNavigationService, ) { - val context = LocalContext.current + val context = LocalContext.current - val imageRequest = remember(book.id) { - ImageRequest.Builder(context) - .data(book.id) - .size(coil.size.Size.ORIGINAL) - .build() + val imageRequest = + remember(book.id) { + ImageRequest + .Builder(context) + .data(book.id) + .size(coil.size.Size.ORIGINAL) + .build() } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { navController.showPlayer(book.id, book.title, book.subtitle) } - .testTag("bookItem_${book.id}") - .padding(horizontal = 4.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { navController.showPlayer(book.id, book.title, book.subtitle) } + .testTag("bookItem_${book.id}") + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncShimmeringImage( + imageRequest = imageRequest, + imageLoader = imageLoader, + contentDescription = "${book.title} cover", + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .size(64.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)), + error = painterResource(R.drawable.cover_fallback), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), ) { - AsyncShimmeringImage( - imageRequest = imageRequest, - imageLoader = imageLoader, - contentDescription = "${book.title} cover", - contentScale = ContentScale.FillBounds, - modifier = Modifier - .size(64.dp) - .aspectRatio(1f) - .clip(RoundedCornerShape(4.dp)), - error = painterResource(R.drawable.cover_fallback), + Column { + Text( + text = book.title, + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - Spacer(modifier = Modifier.width(16.dp)) - - Column( - modifier = Modifier.weight(1f), - ) { - Column { - Text( - text = book.title, - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onBackground, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - if (null != book.series?.takeIf { it.isNotBlank() } || null != book.author) { - Spacer(modifier = Modifier.height(2.dp)) - } - } - - book.author?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - book - .series - ?.takeIf { it.isNotBlank() } - ?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + if (null != book.series?.takeIf { it.isNotBlank() } || null != book.author) { + Spacer(modifier = Modifier.height(2.dp)) } + } - Spacer(modifier = Modifier.width(16.dp)) + book.author?.let { + Text( + text = it, + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + book + .series + ?.takeIf { it.isNotBlank() } + ?.let { + Text( + text = it, + style = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } + + Spacer(modifier = Modifier.width(16.dp)) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/DefaultActionComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/DefaultActionComposable.kt index 39f3b81a..ff048d2d 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/DefaultActionComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/DefaultActionComposable.kt @@ -38,101 +38,106 @@ import org.grakovne.lissen.viewmodel.PlayerViewModel @Composable fun DefaultActionComposable( - navController: AppNavigationService, - contentCachingModelView: CachingModelView, - playerViewModel: PlayerViewModel, - onContentRefreshing: (Boolean) -> Unit, - onSearchRequested: () -> Unit, + navController: AppNavigationService, + contentCachingModelView: CachingModelView, + playerViewModel: PlayerViewModel, + onContentRefreshing: (Boolean) -> Unit, + onSearchRequested: () -> Unit, ) { - var navigationItemSelected by remember { mutableStateOf(false) } - val coroutineScope = rememberCoroutineScope() + var navigationItemSelected by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() - Row { - IconButton( - onClick = { onSearchRequested() }, - modifier = Modifier.offset(x = 4.dp), - ) { - Icon( - imageVector = Search, - contentDescription = null, - ) - } - IconButton(onClick = { - navigationItemSelected = true - }) { - Icon( - imageVector = Icons.Outlined.MoreVert, - contentDescription = "Menu", - ) - } - } - - DropdownMenu( - expanded = navigationItemSelected, - onDismissRequest = { navigationItemSelected = false }, - modifier = Modifier - .background(colorScheme.background) - .padding(4.dp), + Row { + IconButton( + onClick = { onSearchRequested() }, + modifier = Modifier.offset(x = 4.dp), ) { - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = when (contentCachingModelView.localCacheUsing()) { - true -> Icons.Outlined.Cloud - else -> Icons.Outlined.CloudOff - }, - contentDescription = null, - ) - }, - text = { - Text( - text = when (contentCachingModelView.localCacheUsing()) { - true -> stringResource(R.string.disable_offline) - else -> stringResource(R.string.enable_offline) - }, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(start = 8.dp), - ) - }, - onClick = { - navigationItemSelected = false - - coroutineScope.launch { - withFrameNanos { } - - CoroutineScope(Dispatchers.IO).launch { - contentCachingModelView.toggleCacheForce() - playerViewModel.book.value?.let { playerViewModel.preparePlayback(it.id) } - onContentRefreshing(false) - } - } - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) - - DropdownMenuItem( - leadingIcon = { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = null, - ) - }, - text = { - Text( - stringResource(R.string.library_screen_preferences_menu_item), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(start = 8.dp), - ) - }, - onClick = { - navigationItemSelected = false - navController.showSettings() - }, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) + Icon( + imageVector = Search, + contentDescription = null, + ) } + IconButton(onClick = { + navigationItemSelected = true + }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = "Menu", + ) + } + } + + DropdownMenu( + expanded = navigationItemSelected, + onDismissRequest = { navigationItemSelected = false }, + modifier = + Modifier + .background(colorScheme.background) + .padding(4.dp), + ) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = + when (contentCachingModelView.localCacheUsing()) { + true -> Icons.Outlined.Cloud + else -> Icons.Outlined.CloudOff + }, + contentDescription = null, + ) + }, + text = { + Text( + text = + when (contentCachingModelView.localCacheUsing()) { + true -> stringResource(R.string.disable_offline) + else -> stringResource(R.string.enable_offline) + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + }, + onClick = { + navigationItemSelected = false + + coroutineScope.launch { + withFrameNanos { } + + CoroutineScope(Dispatchers.IO).launch { + contentCachingModelView.toggleCacheForce() + playerViewModel.book.value?.let { playerViewModel.preparePlayback(it.id) } + onContentRefreshing(false) + } + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + ) + }, + text = { + Text( + stringResource(R.string.library_screen_preferences_menu_item), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + }, + onClick = { + navigationItemSelected = false + navController.showSettings() + }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt index ac7233c4..d87e09d2 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySearchActionComposable.kt @@ -31,80 +31,84 @@ import org.grakovne.lissen.R @Composable fun LibrarySearchActionComposable( - onSearchDismissed: () -> Unit, - onSearchRequested: (String) -> Unit, + onSearchDismissed: () -> Unit, + onSearchRequested: (String) -> Unit, ) { - val focusRequester = remember { FocusRequester() } - val searchText = remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val searchText = remember { mutableStateOf("") } - fun updateSearchText(text: String) { - searchText.value = text - onSearchRequested(searchText.value) - } + fun updateSearchText(text: String) { + searchText.value = text + onSearchRequested(searchText.value) + } - LaunchedEffect(Unit) { - focusRequester.requestFocus() + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 8.dp) + .height(40.dp), + ) { + IconButton( + modifier = Modifier.height(36.dp), + onClick = { onSearchDismissed() }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + ) } Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 4.dp, end = 8.dp) - .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .weight(1f) + .height(36.dp) + .background(colorScheme.surfaceContainer, RoundedCornerShape(36.dp)) + .padding(start = 16.dp, end = 4.dp), ) { + BasicTextField( + value = searchText.value, + onValueChange = { updateSearchText(it) }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Search, + ), + decorationBox = { innerTextField -> + if (searchText.value.isEmpty()) { + Text( + text = stringResource(R.string.library_search_hint), + color = colorScheme.onSurfaceVariant, + style = typography.bodyLarge, + ) + } + innerTextField() + }, + ) + + if (searchText.value.isNotEmpty()) { IconButton( - modifier = Modifier.height(36.dp), - onClick = { onSearchDismissed() }, + modifier = Modifier.height(36.dp), + onClick = { updateSearchText("") }, ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .weight(1f) - .height(36.dp) - .background(colorScheme.surfaceContainer, RoundedCornerShape(36.dp)) - .padding(start = 16.dp, end = 4.dp), - ) { - BasicTextField( - value = searchText.value, - onValueChange = { updateSearchText(it) }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester), - textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Search, - ), - decorationBox = { innerTextField -> - if (searchText.value.isEmpty()) { - Text( - text = stringResource(R.string.library_search_hint), - color = colorScheme.onSurfaceVariant, - style = typography.bodyLarge, - ) - } - innerTextField() - }, - ) - - if (searchText.value.isNotEmpty()) { - IconButton( - modifier = Modifier.height(36.dp), - onClick = { updateSearchText("") }, - ) { - Icon( - imageVector = Icons.Outlined.Clear, - contentDescription = "Clear", - ) - } - } + Icon( + imageVector = Icons.Outlined.Clear, + contentDescription = "Clear", + ) } + } } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySwitchComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySwitchComposable.kt index 8151a1fa..855251c4 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySwitchComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySwitchComposable.kt @@ -13,16 +13,15 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp @Composable -fun LibrarySwitchComposable( - onclick: () -> Unit, -) { - Spacer(modifier = Modifier.width(4.dp)) +fun LibrarySwitchComposable(onclick: () -> Unit) { + Spacer(modifier = Modifier.width(4.dp)) - Icon( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .clickable { onclick() }, - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - ) + Icon( + modifier = + Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { onclick() }, + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt index ef54fc6a..39c23989 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/MiniPlayerComposable.kt @@ -59,161 +59,172 @@ import org.grakovne.lissen.viewmodel.PlayerViewModel @Composable fun MiniPlayerComposable( - navController: AppNavigationService, - book: DetailedItem, - imageLoader: ImageLoader, - playerViewModel: PlayerViewModel, + navController: AppNavigationService, + book: DetailedItem, + imageLoader: ImageLoader, + playerViewModel: PlayerViewModel, ) { - val view: View = LocalView.current + val view: View = LocalView.current - val isPlaying: Boolean by playerViewModel.isPlaying.observeAsState(false) - var backgroundVisible by remember { mutableStateOf(true) } + val isPlaying: Boolean by playerViewModel.isPlaying.observeAsState(false) + var backgroundVisible by remember { mutableStateOf(true) } - val dismissState = rememberSwipeToDismissBoxState( - positionalThreshold = { it * 0.2f }, - confirmValueChange = { newValue: SwipeToDismissBoxValue -> - val dismissing = when (newValue) { - SwipeToDismissBoxValue.EndToStart, - SwipeToDismissBoxValue.StartToEnd, - -> true - else -> false - } + val dismissState = + rememberSwipeToDismissBoxState( + positionalThreshold = { it * 0.2f }, + confirmValueChange = { newValue: SwipeToDismissBoxValue -> + val dismissing = + when (newValue) { + SwipeToDismissBoxValue.EndToStart, + SwipeToDismissBoxValue.StartToEnd, + -> true + else -> false + } - if (dismissing) { - hapticAction(view) { - backgroundVisible = false - playerViewModel.clearPlayingBook() - } - } + if (dismissing) { + hapticAction(view) { + backgroundVisible = false + playerViewModel.clearPlayingBook() + } + } - dismissing - }, + dismissing + }, ) - SwipeToDismissBox( - state = dismissState, - backgroundContent = { - Row( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 12.dp), - horizontalArrangement = when (dismissState.targetValue) { - SwipeToDismissBoxValue.StartToEnd -> Arrangement.Start - SwipeToDismissBoxValue.EndToStart -> Arrangement.End - SwipeToDismissBoxValue.Settled -> Arrangement.Center - }, - verticalAlignment = Alignment.CenterVertically, - ) { - AnimatedVisibility( - visible = backgroundVisible, - exit = fadeOut(animationSpec = tween(300)), - ) { - CloseActionBackground() - } - } - }, - ) { + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + Row( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + horizontalArrangement = + when (dismissState.targetValue) { + SwipeToDismissBoxValue.StartToEnd -> Arrangement.Start + SwipeToDismissBoxValue.EndToStart -> Arrangement.End + SwipeToDismissBoxValue.Settled -> Arrangement.Center + }, + verticalAlignment = Alignment.CenterVertically, + ) { AnimatedVisibility( - visible = backgroundVisible, - exit = fadeOut(animationSpec = tween(300)), + visible = backgroundVisible, + exit = fadeOut(animationSpec = tween(300)), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(colorScheme.tertiaryContainer) - .clickable { navController.showPlayer(book.id, book.title, book.subtitle) } - .padding(horizontal = 20.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val context = LocalContext.current - val imageRequest = remember(book.id) { - ImageRequest.Builder(context) - .data(book.id) - .size(coil.size.Size.ORIGINAL) - .build() - } - - AsyncShimmeringImage( - imageRequest = imageRequest, - imageLoader = imageLoader, - contentDescription = "${book.title} cover", - contentScale = ContentScale.FillBounds, - modifier = Modifier - .size(48.dp) - .aspectRatio(1f) - .clip(RoundedCornerShape(4.dp)), - error = painterResource(R.drawable.cover_fallback), - ) - - Spacer(modifier = Modifier.width(16.dp)) - - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = book.title, - style = typography.bodyMedium.copy( - fontWeight = FontWeight.SemiBold, - color = colorScheme.onBackground, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - book.author?.let { - Text( - text = it, - style = typography.bodyMedium.copy( - color = colorScheme.onBackground.copy(alpha = 0.6f), - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Row { - IconButton( - onClick = { hapticAction(view) { playerViewModel.togglePlayPause() } }, - ) { - Icon( - imageVector = if (isPlaying) Icons.Outlined.PauseCircleOutline else Icons.Outlined.PlayCircle, - contentDescription = if (isPlaying) "Pause" else "Play", - modifier = Modifier.size(34.dp), - ) - } - } - } - } + CloseActionBackground() } + } + }, + ) { + AnimatedVisibility( + visible = backgroundVisible, + exit = fadeOut(animationSpec = tween(300)), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(colorScheme.tertiaryContainer) + .clickable { navController.showPlayer(book.id, book.title, book.subtitle) } + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val context = LocalContext.current + val imageRequest = + remember(book.id) { + ImageRequest + .Builder(context) + .data(book.id) + .size(coil.size.Size.ORIGINAL) + .build() + } + + AsyncShimmeringImage( + imageRequest = imageRequest, + imageLoader = imageLoader, + contentDescription = "${book.title} cover", + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .size(48.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)), + error = painterResource(R.drawable.cover_fallback), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = book.title, + style = + typography.bodyMedium.copy( + fontWeight = FontWeight.SemiBold, + color = colorScheme.onBackground, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + book.author?.let { + Text( + text = it, + style = + typography.bodyMedium.copy( + color = colorScheme.onBackground.copy(alpha = 0.6f), + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Row { + IconButton( + onClick = { hapticAction(view) { playerViewModel.togglePlayPause() } }, + ) { + Icon( + imageVector = if (isPlaying) Icons.Outlined.PauseCircleOutline else Icons.Outlined.PlayCircle, + contentDescription = if (isPlaying) "Pause" else "Play", + modifier = Modifier.size(34.dp), + ) + } + } + } + } } + } } @Composable fun CloseActionBackground() { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .width(80.dp) - .padding(vertical = 8.dp), - ) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.mini_player_action_close), - tint = colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .width(80.dp) + .padding(vertical = 8.dp), + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(R.string.mini_player_action_close), + tint = colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource(R.string.mini_player_action_close), - style = typography.labelSmall, - color = colorScheme.onSurface, - ) - } + Text( + text = stringResource(R.string.mini_player_action_close), + style = typography.labelSmall, + color = colorScheme.onSurface, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/RecentBooksComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/RecentBooksComposable.kt index 5f2ad059..0a422490 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/RecentBooksComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/RecentBooksComposable.kt @@ -48,160 +48,169 @@ import org.grakovne.lissen.viewmodel.LibraryViewModel @Composable fun RecentBooksComposable( - navController: AppNavigationService, - recentBooks: List, - imageLoader: ImageLoader, - modifier: Modifier = Modifier, - libraryViewModel: LibraryViewModel, + navController: AppNavigationService, + recentBooks: List, + imageLoader: ImageLoader, + modifier: Modifier = Modifier, + libraryViewModel: LibraryViewModel, ) { - val configuration = LocalConfiguration.current - val screenWidth = remember { configuration.screenWidthDp.dp } + val configuration = LocalConfiguration.current + val screenWidth = remember { configuration.screenWidthDp.dp } - val itemsVisible = 2.3f - val spacing = 16.dp - val totalSpacing = spacing * (itemsVisible + 1) - val itemWidth = (screenWidth - totalSpacing) / itemsVisible + val itemsVisible = 2.3f + val spacing = 16.dp + val totalSpacing = spacing * (itemsVisible + 1) + val itemWidth = (screenWidth - totalSpacing) / itemsVisible - Row( - modifier = modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - recentBooks - .forEach { book -> - RecentBookItemComposable( - book = book, - width = itemWidth, - imageLoader = imageLoader, - navController = navController, - libraryViewModel = libraryViewModel, - ) - } - } + Row( + modifier = + modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + recentBooks + .forEach { book -> + RecentBookItemComposable( + book = book, + width = itemWidth, + imageLoader = imageLoader, + navController = navController, + libraryViewModel = libraryViewModel, + ) + } + } } @Composable fun RecentBookItemComposable( - navController: AppNavigationService, - book: RecentBook, - width: Dp, - imageLoader: ImageLoader, - libraryViewModel: LibraryViewModel, + navController: AppNavigationService, + book: RecentBook, + width: Dp, + imageLoader: ImageLoader, + libraryViewModel: LibraryViewModel, ) { + Column( + modifier = + Modifier + .width(width) + .clickable { navController.showPlayer(book.id, book.title, book.subtitle) }, + ) { + val context = LocalContext.current + var coverLoading by remember { mutableStateOf(true) } + + val imageRequest = + remember(book.id) { + ImageRequest + .Builder(context) + .data(book.id) + .crossfade(300) + .build() + } + Column( - modifier = Modifier - .width(width) - .clickable { navController.showPlayer(book.id, book.title, book.subtitle) }, + modifier = Modifier.fillMaxWidth(), ) { - val context = LocalContext.current - var coverLoading by remember { mutableStateOf(true) } + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .aspectRatio(1f), + ) { + AsyncShimmeringImage( + imageRequest = imageRequest, + imageLoader = imageLoader, + contentDescription = "${book.title} cover", + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)), + error = painterResource(R.drawable.cover_fallback), + onLoadingStateChanged = { coverLoading = it }, + ) + } - val imageRequest = remember(book.id) { - ImageRequest - .Builder(context) - .data(book.id) - .crossfade(300) - .build() - } - - Column( - modifier = Modifier.fillMaxWidth(), + if (libraryViewModel.fetchPreferredLibraryType() == LibraryType.LIBRARY) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, ) { + Box( + modifier = + Modifier + .weight(1f) + .height(2.dp), + ) { Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .aspectRatio(1f), - ) { - AsyncShimmeringImage( - imageRequest = imageRequest, - imageLoader = imageLoader, - contentDescription = "${book.title} cover", - contentScale = ContentScale.FillBounds, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)), - error = painterResource(R.drawable.cover_fallback), - onLoadingStateChanged = { coverLoading = it }, - ) - } - - if (libraryViewModel.fetchPreferredLibraryType() == LibraryType.LIBRARY) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .weight(1f) - .height(2.dp), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(8.dp)) - .background(Color.Gray.copy(alpha = 0.4f)), - ) - Box( - modifier = Modifier - .fillMaxWidth(calculateProgress(book)) - .clip(RoundedCornerShape(8.dp)) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.primary), - ) - } - - Text( - text = "${(calculateProgress(book) * 100).toInt()}%", - fontSize = typography.bodySmall.fontSize, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(start = 12.dp), - ) - } - } else { - Spacer(modifier = Modifier.height(8.dp)) - } - } - - Column(modifier = Modifier.padding(horizontal = 4.dp)) { - Text( - text = book.title, - style = typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(Color.Gray.copy(alpha = 0.4f)), ) + Box( + modifier = + Modifier + .fillMaxWidth(calculateProgress(book)) + .clip(RoundedCornerShape(8.dp)) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary), + ) + } - Spacer(modifier = Modifier.height(2.dp)) - - book.subtitle?.let { - Text( - text = it, - style = typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - book.author?.let { - Text( - text = it, - style = typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + Text( + text = "${(calculateProgress(book) * 100).toInt()}%", + fontSize = typography.bodySmall.fontSize, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) } + } else { + Spacer(modifier = Modifier.height(8.dp)) + } } + + Column(modifier = Modifier.padding(horizontal = 4.dp)) { + Text( + text = book.title, + style = typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + book.subtitle?.let { + Text( + text = it, + style = + typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + book.author?.let { + Text( + text = it, + style = + typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } -private fun calculateProgress(book: RecentBook): Float { - return book.listenedPercentage?.div(100.0f) ?: 0.0f -} +private fun calculateProgress(book: RecentBook): Float = book.listenedPercentage?.div(100.0f) ?: 0.0f diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt index 135b8c96..b0f42a10 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/fallback/LibraryFallbackComposable.kt @@ -31,69 +31,74 @@ import org.grakovne.lissen.viewmodel.LibraryViewModel @Composable fun LibraryFallbackComposable( - searchRequested: Boolean, - contentCachingModelView: CachingModelView, - libraryViewModel: LibraryViewModel, - networkQualityService: NetworkQualityService, + searchRequested: Boolean, + contentCachingModelView: CachingModelView, + libraryViewModel: LibraryViewModel, + networkQualityService: NetworkQualityService, ) { - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp - Box( - modifier = Modifier - .fillMaxWidth() - .height(screenHeight / 2), - contentAlignment = Alignment.Center, + Box( + modifier = + Modifier + .fillMaxWidth() + .height(screenHeight / 2), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val hasNetwork = networkQualityService.isNetworkAvailable() - val isLocalCache = contentCachingModelView.localCacheUsing() + val hasNetwork = networkQualityService.isNetworkAvailable() + val isLocalCache = contentCachingModelView.localCacheUsing() - val text = when { - searchRequested -> null - isLocalCache -> when (libraryViewModel.fetchPreferredLibraryType()) { - LibraryType.PODCAST -> stringResource(R.string.the_offline_podcasts_is_empty) - LibraryType.LIBRARY -> stringResource(R.string.the_offline_library_is_empty) - else -> null - } - hasNetwork.not() -> stringResource(R.string.no_internet_connection) - else -> stringResource(R.string.the_library_is_empty) - } - - val icon = when { - searchRequested -> null - isLocalCache -> Icons.AutoMirrored.Filled.LibraryBooks - hasNetwork.not() -> Icons.Filled.WifiOff - else -> Icons.AutoMirrored.Filled.LibraryBooks - } - - icon?.let { - Box( - modifier = Modifier - .size(120.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = it, - contentDescription = "Library placeholder", - tint = Color.White, - modifier = Modifier.size(64.dp), - ) - } - } - - text?.let { - Text( - textAlign = TextAlign.Center, - text = it, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(top = 36.dp), - ) + val text = + when { + searchRequested -> null + isLocalCache -> + when (libraryViewModel.fetchPreferredLibraryType()) { + LibraryType.PODCAST -> stringResource(R.string.the_offline_podcasts_is_empty) + LibraryType.LIBRARY -> stringResource(R.string.the_offline_library_is_empty) + else -> null } + hasNetwork.not() -> stringResource(R.string.no_internet_connection) + else -> stringResource(R.string.the_library_is_empty) } + + val icon = + when { + searchRequested -> null + isLocalCache -> Icons.AutoMirrored.Filled.LibraryBooks + hasNetwork.not() -> Icons.Filled.WifiOff + else -> Icons.AutoMirrored.Filled.LibraryBooks + } + + icon?.let { + Box( + modifier = + Modifier + .size(120.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = it, + contentDescription = "Library placeholder", + tint = Color.White, + modifier = Modifier.size(64.dp), + ) + } + } + + text?.let { + Text( + textAlign = TextAlign.Center, + text = it, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 36.dp), + ) + } } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt index 3482ebf5..cb89172b 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt @@ -21,57 +21,59 @@ import androidx.compose.ui.unit.dp import com.valentinilk.shimmer.shimmer @Composable -fun LibraryPlaceholderComposable( - itemCount: Int = 15, -) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - repeat(itemCount) { LibraryItemPlaceholderComposable() } - } +fun LibraryPlaceholderComposable(itemCount: Int = 15) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + repeat(itemCount) { LibraryItemPlaceholderComposable() } + } } @Composable fun LibraryItemPlaceholderComposable() { - Row( - modifier = Modifier + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = + Modifier + .size(64.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + .shimmer() + .background(Color.Gray), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Box( + modifier = + Modifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(64.dp) - .aspectRatio(1f) - .clip(RoundedCornerShape(4.dp)) - .shimmer() - .background(Color.Gray), - ) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .shimmer() + .background(Color.Gray), + ) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - .shimmer() - .background(Color.Gray), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Box( - modifier = Modifier - .fillMaxWidth(0.6f) - .height(12.dp) - .clip(RoundedCornerShape(4.dp)) - .shimmer() - .background(Color.Gray), - ) - } - - Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = + Modifier + .fillMaxWidth(0.6f) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .shimmer() + .background(Color.Gray), + ) } + + Spacer(modifier = Modifier.width(16.dp)) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt index a9df2683..e5f3cde4 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/RecentBooksPlaceholderComposable.kt @@ -28,75 +28,79 @@ import org.grakovne.lissen.viewmodel.LibraryViewModel @Composable fun RecentBooksPlaceholderComposable( - itemCount: Int = 5, - libraryViewModel: LibraryViewModel, + itemCount: Int = 5, + libraryViewModel: LibraryViewModel, ) { - val configuration = LocalConfiguration.current - val screenWidth = remember { configuration.screenWidthDp.dp } + val configuration = LocalConfiguration.current + val screenWidth = remember { configuration.screenWidthDp.dp } - val itemsVisible = 2.3f - val spacing = 16.dp - val totalSpacing = spacing * (itemsVisible + 1) - val itemWidth = (screenWidth - totalSpacing) / itemsVisible + val itemsVisible = 2.3f + val spacing = 16.dp + val totalSpacing = spacing * (itemsVisible + 1) + val itemWidth = (screenWidth - totalSpacing) / itemsVisible - LazyRow( - contentPadding = PaddingValues(horizontal = 4.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth(), - ) { - items(itemCount) { - RecentBookItemComposable( - width = itemWidth, - libraryViewModel = libraryViewModel, - ) - } + LazyRow( + contentPadding = PaddingValues(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + items(itemCount) { + RecentBookItemComposable( + width = itemWidth, + libraryViewModel = libraryViewModel, + ) } + } } @Composable fun RecentBookItemComposable( - width: Dp, - libraryViewModel: LibraryViewModel, + width: Dp, + libraryViewModel: LibraryViewModel, ) { - Column( - modifier = Modifier - .width(width), - ) { - Spacer( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .shimmer() - .background(Color.Gray), - ) + Column( + modifier = + Modifier + .width(width), + ) { + Spacer( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .shimmer() + .background(Color.Gray), + ) - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(14.dp)) - Column(modifier = Modifier.padding(horizontal = 4.dp)) { - Text( - color = Color.Transparent, - text = "Crime and Punishment. Novel", - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .shimmer() - .background(Color.Gray), - ) + Column(modifier = Modifier.padding(horizontal = 4.dp)) { + Text( + color = Color.Transparent, + text = "Crime and Punishment. Novel", + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .shimmer() + .background(Color.Gray), + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Text( - color = Color.Transparent, - text = "Fyodor Dostoevsky", - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .shimmer() - .background(Color.Gray), - ) - } + Text( + color = Color.Transparent, + text = "Fyodor Dostoevsky", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .shimmer() + .background(Color.Gray), + ) } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt index e50d7510..ab959118 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt @@ -7,46 +7,46 @@ import org.grakovne.lissen.domain.Book import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences class LibraryDefaultPagingSource( - private val preferences: LissenSharedPreferences, - private val mediaChannel: LissenMediaProvider, + private val preferences: LissenSharedPreferences, + private val mediaChannel: LissenMediaProvider, ) : PagingSource() { + override fun getRefreshKey(state: PagingState) = + state + .anchorPosition + ?.let { anchorPosition -> + state + .closestPageToPosition(anchorPosition) + ?.prevKey + ?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } - override fun getRefreshKey(state: PagingState) = state - .anchorPosition - ?.let { anchorPosition -> - state - .closestPageToPosition(anchorPosition) - ?.prevKey - ?.plus(1) - ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) - } + override suspend fun load(params: LoadParams): LoadResult { + val libraryId = + preferences + .getPreferredLibrary() + ?.id + ?: return LoadResult.Page(emptyList(), null, null) - override suspend fun load(params: LoadParams): LoadResult { - val libraryId = preferences - .getPreferredLibrary() - ?.id - ?: return LoadResult.Page(emptyList(), null, null) + return mediaChannel + .fetchBooks( + libraryId = libraryId, + pageSize = params.loadSize, + pageNumber = params.key ?: 0, + ).fold( + onSuccess = { result -> + val nextPage = if (result.items.isEmpty()) null else result.currentPage + 1 + val prevKey = if (result.currentPage == 0) null else result.currentPage - 1 - return mediaChannel - .fetchBooks( - libraryId = libraryId, - pageSize = params.loadSize, - pageNumber = params.key ?: 0, - ) - .fold( - onSuccess = { result -> - val nextPage = if (result.items.isEmpty()) null else result.currentPage + 1 - val prevKey = if (result.currentPage == 0) null else result.currentPage - 1 - - LoadResult.Page( - data = result.items, - prevKey = prevKey, - nextKey = nextPage, - ) - }, - onFailure = { - LoadResult.Page(emptyList(), null, null) - }, - ) - } + LoadResult.Page( + data = result.items, + prevKey = prevKey, + nextKey = nextPage, + ) + }, + onFailure = { + LoadResult.Page(emptyList(), null, null) + }, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt index ff721061..10d7e714 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt @@ -7,29 +7,29 @@ import org.grakovne.lissen.domain.Book import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences class LibrarySearchPagingSource( - private val preferences: LissenSharedPreferences, - private val mediaChannel: LissenMediaProvider, - private val searchToken: String, - private val limit: Int, + private val preferences: LissenSharedPreferences, + private val mediaChannel: LissenMediaProvider, + private val searchToken: String, + private val limit: Int, ) : PagingSource() { + override fun getRefreshKey(state: PagingState) = null - override fun getRefreshKey(state: PagingState) = null + override suspend fun load(params: LoadParams): LoadResult { + val libraryId = + preferences + .getPreferredLibrary() + ?.id + ?: return LoadResult.Page(emptyList(), null, null) - override suspend fun load(params: LoadParams): LoadResult { - val libraryId = preferences - .getPreferredLibrary() - ?.id - ?: return LoadResult.Page(emptyList(), null, null) - - if (searchToken.isBlank()) { - return LoadResult.Page(emptyList(), null, null) - } - - return mediaChannel - .searchBooks(libraryId, searchToken, limit) - .fold( - onSuccess = { LoadResult.Page(it, null, null) }, - onFailure = { LoadResult.Page(emptyList(), null, null) }, - ) + if (searchToken.isBlank()) { + return LoadResult.Page(emptyList(), null, null) } + + return mediaChannel + .searchBooks(libraryId, searchToken, limit) + .fold( + onSuccess = { LoadResult.Page(it, null, null) }, + onFailure = { LoadResult.Page(emptyList(), null, null) }, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt index 13aab072..3803e5a3 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/login/LoginScreen.kt @@ -69,227 +69,250 @@ import org.grakovne.lissen.viewmodel.LoginViewModel.LoginState @OptIn(FlowPreview::class) @Composable fun LoginScreen( - navController: AppNavigationService, - viewModel: LoginViewModel = hiltViewModel(), + navController: AppNavigationService, + viewModel: LoginViewModel = hiltViewModel(), ) { - val loginState by viewModel.loginState.collectAsState() + val loginState by viewModel.loginState.collectAsState() - val host by viewModel.host.observeAsState("") - val username by viewModel.username.observeAsState("") - val password by viewModel.password.observeAsState("") + val host by viewModel.host.observeAsState("") + val username by viewModel.username.observeAsState("") + val password by viewModel.password.observeAsState("") - val authMethods by viewModel.authMethods.observeAsState(emptyList()) + val authMethods by viewModel.authMethods.observeAsState(emptyList()) - var showPassword by remember { mutableStateOf(false) } + var showPassword by remember { mutableStateOf(false) } - val context = LocalContext.current + val context = LocalContext.current - LaunchedEffect(loginState) { - if (loginState is LoginState.Loading) { - return@LaunchedEffect - } - - withMinimumTime(300) { - Log.d(TAG, "Tried to log in with result $loginState and possible error is $loginState") - } - - when (loginState) { - is LoginState.Success -> navController.showLibrary(clearHistory = true) - is LoginState.Error -> { - val message = (loginState as LoginState.Error).message - - message.let { Toast.makeText(context, it.makeText(context), LENGTH_SHORT).show() } - } - - else -> {} - } - viewModel.readyToLogin() + LaunchedEffect(loginState) { + if (loginState is LoginState.Loading) { + return@LaunchedEffect } - LaunchedEffect(Unit) { - snapshotFlow { host } - .debounce(150) - .collect { viewModel.updateAuthMethods() } + withMinimumTime(300) { + Log.d(TAG, "Tried to log in with result $loginState and possible error is $loginState") } - Scaffold( - modifier = Modifier - .systemBarsPadding() + + when (loginState) { + is LoginState.Success -> navController.showLibrary(clearHistory = true) + is LoginState.Error -> { + val message = (loginState as LoginState.Error).message + + message.let { Toast.makeText(context, it.makeText(context), LENGTH_SHORT).show() } + } + + else -> {} + } + viewModel.readyToLogin() + } + + LaunchedEffect(Unit) { + snapshotFlow { host } + .debounce(150) + .collect { viewModel.updateAuthMethods() } + } + Scaffold( + modifier = + Modifier + .systemBarsPadding() + .fillMaxSize(), + content = { innerPadding -> + Box( + modifier = + Modifier + .padding(innerPadding) .fillMaxSize(), - content = { innerPadding -> - Box( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - ) { - Column( - modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth(0.8f) - .imePadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.login_screen_title), - style = TextStyle( - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 1.5.sp, - textAlign = TextAlign.Center, - ), - modifier = Modifier.padding(vertical = 32.dp), - ) + ) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .fillMaxWidth(0.8f) + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.login_screen_title), + style = + TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.5.sp, + textAlign = TextAlign.Center, + ), + modifier = Modifier.padding(vertical = 32.dp), + ) - OutlinedTextField( - value = host, - onValueChange = { viewModel.setHost(it.trim()) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), - label = { Text(stringResource(R.string.login_screen_server_url_input)) }, - shape = RoundedCornerShape(16.dp), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .testTag("hostInput"), - ) + OutlinedTextField( + value = host, + onValueChange = { viewModel.setHost(it.trim()) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + label = { Text(stringResource(R.string.login_screen_server_url_input)) }, + shape = RoundedCornerShape(16.dp), + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .testTag("hostInput"), + ) - OutlinedTextField( - value = username, - onValueChange = { viewModel.setUsername(it.trim()) }, - label = { Text(stringResource(R.string.login_screen_login_input)) }, - shape = RoundedCornerShape(16.dp), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp) - .testTag("usernameInput"), - ) + OutlinedTextField( + value = username, + onValueChange = { viewModel.setUsername(it.trim()) }, + label = { Text(stringResource(R.string.login_screen_login_input)) }, + shape = RoundedCornerShape(16.dp), + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .testTag("usernameInput"), + ) - OutlinedTextField( - value = password, - visualTransformation = if (!showPassword) PasswordVisualTransformation() else VisualTransformation.None, - onValueChange = { viewModel.setPassword(it) }, - trailingIcon = { - IconButton( - onClick = { showPassword = !showPassword }, - ) { - Icon( - imageVector = if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = stringResource(R.string.login_screen_show_password_hint), - ) - } - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - label = { Text(stringResource(R.string.login_screen_password_input)) }, - shape = RoundedCornerShape(16.dp), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .testTag("passwordInput"), - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 32.dp), - ) { - Button( - onClick = { viewModel.login() }, - modifier = Modifier - .weight(1f) - .testTag("loginButton"), - shape = RoundedCornerShape( - topStart = 16.dp, - bottomStart = 16.dp, - topEnd = 0.dp, - bottomEnd = 0.dp, - ), - ) { - Spacer(modifier = Modifier.width(28.dp)) - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.login_screen_connect_button_text), - fontSize = 16.sp, - ) - } - } - - Spacer(modifier = Modifier.width(1.dp)) - - Button( - onClick = { - navController.showSettings() - }, - modifier = Modifier.width(56.dp), - shape = RoundedCornerShape( - topStart = 0.dp, - bottomStart = 0.dp, - topEnd = 16.dp, - bottomEnd = 16.dp, - ), - contentPadding = PaddingValues(0.dp), - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings", - modifier = Modifier.size(24.dp), - ) - } - } - - val isEnabled = authMethods.contains(AuthMethod.O_AUTH) - - TextButton( - onClick = { viewModel.startOAuth() }, - enabled = isEnabled, - colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.onSurface), - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - ) { - Text( - text = if (isEnabled) stringResource(R.string.login_screen_open_id_button) else "", - style = typography.bodyMedium.copy( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - letterSpacing = 0.6.sp, - color = if (isEnabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0f), - ), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - - CircularProgressIndicator( - color = colorScheme.primary, - strokeWidth = 4.dp, - modifier = Modifier - .padding(vertical = 20.dp) - .alpha(if (loginState !is LoginState.Idle) 1f else 0f), - ) - } - - Text( - modifier = Modifier - .align(Alignment.BottomCenter) - .alpha(0.6f) - .padding(bottom = 32.dp), - text = stringResource(R.string.audiobookshelf_server_is_required), - style = typography.bodySmall.copy( - fontSize = 10.sp, - fontWeight = FontWeight.Normal, - color = colorScheme.onBackground, - letterSpacing = 0.6.sp, - lineHeight = 32.sp, - ), - textAlign = TextAlign.Center, + OutlinedTextField( + value = password, + visualTransformation = if (!showPassword) PasswordVisualTransformation() else VisualTransformation.None, + onValueChange = { viewModel.setPassword(it) }, + trailingIcon = { + IconButton( + onClick = { showPassword = !showPassword }, + ) { + Icon( + imageVector = if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = stringResource(R.string.login_screen_show_password_hint), ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + label = { Text(stringResource(R.string.login_screen_password_input)) }, + shape = RoundedCornerShape(16.dp), + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .testTag("passwordInput"), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp), + ) { + Button( + onClick = { viewModel.login() }, + modifier = + Modifier + .weight(1f) + .testTag("loginButton"), + shape = + RoundedCornerShape( + topStart = 16.dp, + bottomStart = 16.dp, + topEnd = 0.dp, + bottomEnd = 0.dp, + ), + ) { + Spacer(modifier = Modifier.width(28.dp)) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.login_screen_connect_button_text), + fontSize = 16.sp, + ) + } } - }, - ) + + Spacer(modifier = Modifier.width(1.dp)) + + Button( + onClick = { + navController.showSettings() + }, + modifier = Modifier.width(56.dp), + shape = + RoundedCornerShape( + topStart = 0.dp, + bottomStart = 0.dp, + topEnd = 16.dp, + bottomEnd = 16.dp, + ), + contentPadding = PaddingValues(0.dp), + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + modifier = Modifier.size(24.dp), + ) + } + } + + val isEnabled = authMethods.contains(AuthMethod.O_AUTH) + + TextButton( + onClick = { viewModel.startOAuth() }, + enabled = isEnabled, + colors = ButtonDefaults.textButtonColors(contentColor = colorScheme.onSurface), + modifier = + Modifier + .fillMaxWidth() + .padding(top = 12.dp), + ) { + Text( + text = if (isEnabled) stringResource(R.string.login_screen_open_id_button) else "", + style = + typography.bodyMedium.copy( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.6.sp, + color = + if (isEnabled) { + colorScheme.primary + } else { + colorScheme.onSurface.copy( + alpha = 0f, + ) + }, + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + CircularProgressIndicator( + color = colorScheme.primary, + strokeWidth = 4.dp, + modifier = + Modifier + .padding(vertical = 20.dp) + .alpha(if (loginState !is LoginState.Idle) 1f else 0f), + ) + } + + Text( + modifier = + Modifier + .align(Alignment.BottomCenter) + .alpha(0.6f) + .padding(bottom = 32.dp), + text = stringResource(R.string.audiobookshelf_server_is_required), + style = + typography.bodySmall.copy( + fontSize = 10.sp, + fontWeight = FontWeight.Normal, + color = colorScheme.onBackground, + letterSpacing = 0.6.sp, + lineHeight = 32.sp, + ), + textAlign = TextAlign.Center, + ) + } + }, + ) } private const val TAG: String = "LoginScreen" diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt index 7de48903..7c99c4dc 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt @@ -28,69 +28,71 @@ import androidx.compose.ui.unit.dp import org.grakovne.lissen.R @Composable -fun ChapterSearchActionComposable( - onSearchRequested: (String) -> Unit, -) { - val focusRequester = remember { FocusRequester() } - val searchText = remember { mutableStateOf("") } +fun ChapterSearchActionComposable(onSearchRequested: (String) -> Unit) { + val focusRequester = remember { FocusRequester() } + val searchText = remember { mutableStateOf("") } - fun updateSearchText(text: String) { - searchText.value = text - onSearchRequested(searchText.value) - } + fun updateSearchText(text: String) { + searchText.value = text + onSearchRequested(searchText.value) + } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 48.dp, end = 8.dp) + .height(40.dp), + ) { Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(start = 48.dp, end = 8.dp) - .height(40.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .weight(1f) + .height(36.dp) + .background(colorScheme.surfaceContainer, RoundedCornerShape(36.dp)) + .padding(start = 16.dp, end = 4.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .weight(1f) - .height(36.dp) - .background(colorScheme.surfaceContainer, RoundedCornerShape(36.dp)) - .padding(start = 16.dp, end = 4.dp), - ) { - BasicTextField( - value = searchText.value, - onValueChange = { updateSearchText(it) }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester), - textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Search, - ), - decorationBox = { innerTextField -> - if (searchText.value.isEmpty()) { - Text( - text = stringResource(R.string.chapter_search_hint), - color = colorScheme.onSurfaceVariant, - style = typography.bodyLarge, - ) - } - innerTextField() - }, + BasicTextField( + value = searchText.value, + onValueChange = { updateSearchText(it) }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Search, + ), + decorationBox = { innerTextField -> + if (searchText.value.isEmpty()) { + Text( + text = stringResource(R.string.chapter_search_hint), + color = colorScheme.onSurfaceVariant, + style = typography.bodyLarge, ) + } + innerTextField() + }, + ) - if (searchText.value.isNotEmpty()) { - IconButton( - modifier = Modifier.height(36.dp), - onClick = { updateSearchText("") }, - ) { - Icon( - imageVector = Icons.Outlined.Clear, - contentDescription = "Clear", - ) - } - } + if (searchText.value.isNotEmpty()) { + IconButton( + modifier = Modifier.height(36.dp), + onClick = { updateSearchText("") }, + ) { + Icon( + imageVector = Icons.Outlined.Clear, + contentDescription = "Clear", + ) } + } } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt index 80747683..c3342d8e 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/PlayerScreen.kt @@ -70,255 +70,259 @@ import org.grakovne.lissen.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlayerScreen( - navController: AppNavigationService, - imageLoader: ImageLoader, - bookId: String, - bookTitle: String, - bookSubtitle: String?, - playInstantly: Boolean, + navController: AppNavigationService, + imageLoader: ImageLoader, + bookId: String, + bookTitle: String, + bookSubtitle: String?, + playInstantly: Boolean, ) { - val context = LocalContext.current + val context = LocalContext.current - val cachingModelView: CachingModelView = hiltViewModel() - val playerViewModel: PlayerViewModel = hiltViewModel() - val libraryViewModel: LibraryViewModel = hiltViewModel() - val settingsViewModel: SettingsViewModel = hiltViewModel() + val cachingModelView: CachingModelView = hiltViewModel() + val playerViewModel: PlayerViewModel = hiltViewModel() + val libraryViewModel: LibraryViewModel = hiltViewModel() + val settingsViewModel: SettingsViewModel = hiltViewModel() - val titleTextStyle = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + val titleTextStyle = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - val playingBook by playerViewModel.book.observeAsState() - val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false) - val playingQueueExpanded by playerViewModel.playingQueueExpanded.observeAsState(false) - val searchRequested by playerViewModel.searchRequested.observeAsState(false) + val playingBook by playerViewModel.book.observeAsState() + val isPlaybackReady by playerViewModel.isPlaybackReady.observeAsState(false) + val playingQueueExpanded by playerViewModel.playingQueueExpanded.observeAsState(false) + val searchRequested by playerViewModel.searchRequested.observeAsState(false) - var itemDetailsSelected by remember { mutableStateOf(false) } + var itemDetailsSelected by remember { mutableStateOf(false) } - val screenTitle = when (playingQueueExpanded) { - true -> provideNowPlayingTitle(libraryViewModel.fetchPreferredLibraryType(), context) - false -> stringResource(R.string.player_screen_title) + val screenTitle = + when (playingQueueExpanded) { + true -> provideNowPlayingTitle(libraryViewModel.fetchPreferredLibraryType(), context) + false -> stringResource(R.string.player_screen_title) } - fun stepBack() { - when { - searchRequested -> playerViewModel.dismissSearch() - playingQueueExpanded -> playerViewModel.collapsePlayingQueue() - else -> navController.showLibrary(clearHistory = true) - } + fun stepBack() { + when { + searchRequested -> playerViewModel.dismissSearch() + playingQueueExpanded -> playerViewModel.collapsePlayingQueue() + else -> navController.showLibrary(clearHistory = true) } + } - BackHandler(enabled = searchRequested || playingQueueExpanded || playInstantly) { - stepBack() + BackHandler(enabled = searchRequested || playingQueueExpanded || playInstantly) { + stepBack() + } + + LaunchedEffect(Unit) { + bookId + .takeIf { playingItemChanged(it, playingBook) || cachePolicyChanged(cachingModelView, playingBook) } + ?.let { playerViewModel.preparePlayback(it) } + + if (playInstantly) { + playerViewModel.prepareAndPlay() } + } - LaunchedEffect(Unit) { - bookId - .takeIf { playingItemChanged(it, playingBook) || cachePolicyChanged(cachingModelView, playingBook) } - ?.let { playerViewModel.preparePlayback(it) } - - if (playInstantly) { - playerViewModel.prepareAndPlay() - } + LaunchedEffect(playingQueueExpanded) { + if (playingQueueExpanded.not()) { + playerViewModel.dismissSearch() } + } - LaunchedEffect(playingQueueExpanded) { - if (playingQueueExpanded.not()) { - playerViewModel.dismissSearch() - } - } + Scaffold( + topBar = { + TopAppBar( + actions = { + if (playingQueueExpanded) { + AnimatedContent( + targetState = searchRequested, + label = "library_action_animation", + transitionSpec = { + fadeIn(animationSpec = keyframes { durationMillis = 150 }) togetherWith + fadeOut(animationSpec = keyframes { durationMillis = 150 }) + }, + ) { isSearchRequested -> + when (isSearchRequested) { + true -> + ChapterSearchActionComposable( + onSearchRequested = { playerViewModel.updateSearch(it) }, + ) - Scaffold( - topBar = { - TopAppBar( - actions = { - if (playingQueueExpanded) { - AnimatedContent( - targetState = searchRequested, - label = "library_action_animation", - transitionSpec = { - fadeIn(animationSpec = keyframes { durationMillis = 150 }) togetherWith - fadeOut(animationSpec = keyframes { durationMillis = 150 }) - }, - ) { isSearchRequested -> - when (isSearchRequested) { - true -> ChapterSearchActionComposable( - onSearchRequested = { playerViewModel.updateSearch(it) }, - ) - - false -> Row { - IconButton( - onClick = { playerViewModel.requestSearch() }, - modifier = Modifier.padding(end = 4.dp), - ) { - Icon( - imageVector = Search, - contentDescription = null, - ) - } - } - } - } - } else { - Row { - IconButton( - onClick = { itemDetailsSelected = true }, - modifier = Modifier.padding(end = 4.dp), - ) { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = null, - ) - } - } - } - }, - title = { - Text( - text = screenTitle, - style = titleTextStyle, - color = colorScheme.onSurface, - maxLines = 1, - modifier = Modifier.fillMaxWidth(), - ) - }, - navigationIcon = { - IconButton(onClick = { stepBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - tint = colorScheme.onSurface, - ) - } - }, - ) - }, - bottomBar = { - playingBook - ?.let { - NavigationBarComposable( - book = it, - playerViewModel = playerViewModel, - contentCachingModelView = cachingModelView, - navController = navController, - libraryType = libraryViewModel.fetchPreferredLibraryType(), - ) - } - }, - modifier = Modifier.systemBarsPadding(), - content = { innerPadding -> - Column( - modifier = Modifier - .testTag("playerScreen") - .padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AnimatedVisibility( - visible = playingQueueExpanded.not(), - enter = expandVertically(animationSpec = tween(400)), - exit = shrinkVertically(animationSpec = tween(400)), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, + false -> + Row { + IconButton( + onClick = { playerViewModel.requestSearch() }, + modifier = Modifier.padding(end = 4.dp), ) { - if (!isPlaybackReady) { - TrackDetailsPlaceholderComposable(bookTitle, bookSubtitle) - } else { - TrackDetailsComposable( - viewModel = playerViewModel, - imageLoader = imageLoader, - libraryViewModel = libraryViewModel, - ) - } - - if (!isPlaybackReady) { - TrackControlPlaceholderComposable( - modifier = Modifier, - settingsViewModel = settingsViewModel, - ) - } else { - TrackControlComposable( - viewModel = playerViewModel, - modifier = Modifier, - settingsViewModel = settingsViewModel, - ) - } + Icon( + imageVector = Search, + contentDescription = null, + ) } - } - - Spacer(modifier = Modifier.height(6.dp)) - - when { - isPlaybackReady.not() -> { - PlayingQueuePlaceholderComposable( - libraryViewModel = libraryViewModel, - modifier = Modifier, - ) - } - - playingBook?.chapters.isNullOrEmpty() -> { - PlayingQueueFallbackComposable( - libraryViewModel = libraryViewModel, - modifier = Modifier, - ) - } - - else -> { - PlayingQueueComposable( - libraryViewModel = libraryViewModel, - viewModel = playerViewModel, - modifier = Modifier, - ) - } - } + } + } } + } else { + Row { + IconButton( + onClick = { itemDetailsSelected = true }, + modifier = Modifier.padding(end = 4.dp), + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + ) + } + } + } }, - ) + title = { + Text( + text = screenTitle, + style = titleTextStyle, + color = colorScheme.onSurface, + maxLines = 1, + modifier = Modifier.fillMaxWidth(), + ) + }, + navigationIcon = { + IconButton(onClick = { stepBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = colorScheme.onSurface, + ) + } + }, + ) + }, + bottomBar = { + playingBook + ?.let { + NavigationBarComposable( + book = it, + playerViewModel = playerViewModel, + contentCachingModelView = cachingModelView, + navController = navController, + libraryType = libraryViewModel.fetchPreferredLibraryType(), + ) + } + }, + modifier = Modifier.systemBarsPadding(), + content = { innerPadding -> + Column( + modifier = + Modifier + .testTag("playerScreen") + .padding(innerPadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedVisibility( + visible = playingQueueExpanded.not(), + enter = expandVertically(animationSpec = tween(400)), + exit = shrinkVertically(animationSpec = tween(400)), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!isPlaybackReady) { + TrackDetailsPlaceholderComposable(bookTitle, bookSubtitle) + } else { + TrackDetailsComposable( + viewModel = playerViewModel, + imageLoader = imageLoader, + libraryViewModel = libraryViewModel, + ) + } - if (itemDetailsSelected) { - MediaDetailComposable( - playingBook = playingBook, - onDismissRequest = { itemDetailsSelected = false }, - ) - } + if (!isPlaybackReady) { + TrackControlPlaceholderComposable( + modifier = Modifier, + settingsViewModel = settingsViewModel, + ) + } else { + TrackControlComposable( + viewModel = playerViewModel, + modifier = Modifier, + settingsViewModel = settingsViewModel, + ) + } + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + when { + isPlaybackReady.not() -> { + PlayingQueuePlaceholderComposable( + libraryViewModel = libraryViewModel, + modifier = Modifier, + ) + } + + playingBook?.chapters.isNullOrEmpty() -> { + PlayingQueueFallbackComposable( + libraryViewModel = libraryViewModel, + modifier = Modifier, + ) + } + + else -> { + PlayingQueueComposable( + libraryViewModel = libraryViewModel, + viewModel = playerViewModel, + modifier = Modifier, + ) + } + } + } + }, + ) + + if (itemDetailsSelected) { + MediaDetailComposable( + playingBook = playingBook, + onDismissRequest = { itemDetailsSelected = false }, + ) + } } @Composable fun InfoRow( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, - textValue: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + textValue: String, ) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "$label: ", - style = typography.bodyMedium, - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = textValue, - style = typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "$label: ", + style = typography.bodyMedium, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = textValue, + style = typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } private fun playingItemChanged( - item: String, - playingBook: DetailedItem?, + item: String, + playingBook: DetailedItem?, ) = item != playingBook?.id private fun cachePolicyChanged( - cachingModelView: CachingModelView, - playingBook: DetailedItem?, + cachingModelView: CachingModelView, + playingBook: DetailedItem?, ) = cachingModelView.localCacheUsing() != playingBook?.localProvided diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt index 21e3e002..b88955f7 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/DownloadsComposable.kt @@ -33,132 +33,161 @@ import org.grakovne.lissen.domain.NumberItemDownloadOption @OptIn(ExperimentalMaterial3Api::class) @Composable fun DownloadsComposable( - isForceCache: Boolean, - libraryType: LibraryType, - hasCachedEpisodes: Boolean, - onRequestedDownload: (DownloadOption) -> Unit, - onRequestedDrop: () -> Unit, - onDismissRequest: () -> Unit, + isForceCache: Boolean, + libraryType: LibraryType, + hasCachedEpisodes: Boolean, + onRequestedDownload: (DownloadOption) -> Unit, + onRequestedDrop: () -> Unit, + onDismissRequest: () -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - ModalBottomSheet( - containerColor = colorScheme.background, - onDismissRequest = onDismissRequest, - content = { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = when (libraryType) { - LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) - LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) - LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) - }, - style = typography.bodyLarge, - ) + ModalBottomSheet( + containerColor = colorScheme.background, + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = + when (libraryType) { + LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_book) + LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_podcast) + LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_unknown) + }, + style = typography.bodyLarge, + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(DownloadOptions) { index, item -> - ListItem( - headlineContent = { - Row { - Text( - text = item.makeText(context, libraryType), - style = typography.bodyMedium, - color = when (isForceCache) { - true -> colorScheme.onBackground.copy(alpha = 0.4f) - false -> colorScheme.onBackground - }, - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - if (isForceCache.not()) { - onRequestedDownload(item) - onDismissRequest() - } - }, - ) - if (index < DownloadOptions.size - 1) { - HorizontalDivider() - } - } - - if (hasCachedEpisodes) { - item { - HorizontalDivider() - - ListItem( - headlineContent = { - Row { - Text( - text = when (libraryType) { - LibraryType.LIBRARY -> stringResource(R.string.downloads_menu_download_option_clear_chapters) - LibraryType.PODCAST -> stringResource(R.string.downloads_menu_download_option_clear_episodes) - LibraryType.UNKNOWN -> stringResource(R.string.downloads_menu_download_option_clear_items) - }, - color = colorScheme.error, - style = typography.bodyMedium, - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - onRequestedDrop() - onDismissRequest() - }, - ) - } - } + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(DownloadOptions) { index, item -> + ListItem( + headlineContent = { + Row { + Text( + text = item.makeText(context, libraryType), + style = typography.bodyMedium, + color = + when (isForceCache) { + true -> colorScheme.onBackground.copy(alpha = 0.4f) + false -> colorScheme.onBackground + }, + ) } + }, + modifier = + Modifier + .fillMaxWidth() + .clickable { + if (isForceCache.not()) { + onRequestedDownload(item) + onDismissRequest() + } + }, + ) + if (index < DownloadOptions.size - 1) { + HorizontalDivider() } - }, - ) + } + + if (hasCachedEpisodes) { + item { + HorizontalDivider() + + ListItem( + headlineContent = { + Row { + Text( + text = + when (libraryType) { + LibraryType.LIBRARY -> + stringResource( + R.string.downloads_menu_download_option_clear_chapters, + ) + LibraryType.PODCAST -> + stringResource( + R.string.downloads_menu_download_option_clear_episodes, + ) + LibraryType.UNKNOWN -> + stringResource( + R.string.downloads_menu_download_option_clear_items, + ) + }, + color = colorScheme.error, + style = typography.bodyMedium, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onRequestedDrop() + onDismissRequest() + }, + ) + } + } + } + } + }, + ) } -private val DownloadOptions = listOf( +private val DownloadOptions = + listOf( CurrentItemDownloadOption, NumberItemDownloadOption(5), NumberItemDownloadOption(10), NumberItemDownloadOption(20), AllItemsDownloadOption, -) + ) fun DownloadOption.makeText( - context: Context, - libraryType: LibraryType, -): String = when (this) { + context: Context, + libraryType: LibraryType, +): String = + when (this) { CurrentItemDownloadOption -> { - when (libraryType) { - LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_current_chapter) - LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_current_episode) - LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_current_item) - } + when (libraryType) { + LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_current_chapter) + LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_current_episode) + LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_current_item) + } } AllItemsDownloadOption -> { - when (libraryType) { - LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_entire_book) - LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_entire_podcast) - LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_entire_item) - } + when (libraryType) { + LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_entire_book) + LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_entire_podcast) + LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_entire_item) + } } is NumberItemDownloadOption -> { - when (libraryType) { - LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_next_chapters, itemsNumber) - LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_next_episodes, itemsNumber) - LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_next_items, itemsNumber) - } + when (libraryType) { + LibraryType.LIBRARY -> + context.getString( + R.string.downloads_menu_download_option_next_chapters, + itemsNumber, + ) + LibraryType.PODCAST -> + context.getString( + R.string.downloads_menu_download_option_next_episodes, + itemsNumber, + ) + LibraryType.UNKNOWN -> + context.getString( + R.string.downloads_menu_download_option_next_items, + itemsNumber, + ) + } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/MediaDetailComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/MediaDetailComposable.kt index 00dd58cf..8d33e056 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/MediaDetailComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/MediaDetailComposable.kt @@ -38,144 +38,150 @@ import org.grakovne.lissen.ui.screens.player.InfoRow @OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaDetailComposable( - playingBook: DetailedItem?, - onDismissRequest: () -> Unit, + playingBook: DetailedItem?, + onDismissRequest: () -> Unit, ) { - ModalBottomSheet( - onDismissRequest = onDismissRequest, - containerColor = colorScheme.surface, + ModalBottomSheet( + onDismissRequest = onDismissRequest, + containerColor = colorScheme.surface, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp, horizontal = 4.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(vertical = 16.dp, horizontal = 4.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - val seriesValue = playingBook - ?.series - ?.takeIf { it.isNotEmpty() } - ?.joinToString(", ") { series -> - buildString { - append(series.name) - series.serialNumber - ?.takeIf(String::isNotBlank) - ?.let { append(" #$it") } - } - } - - playingBook - ?.title - ?.takeIf { it.isNotEmpty() } - ?.let { - Text( - text = it, - style = typography.titleLarge.copy( - fontWeight = FontWeight.Bold, - ), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = colorScheme.onSurface, - modifier = when (seriesValue?.isNotBlank()) { - true -> Modifier - else -> Modifier.padding(bottom = 8.dp) - }, - ) - } - - seriesValue - ?.let { - Text( - text = it, - style = typography.titleSmall, - color = colorScheme.onBackground.copy(alpha = 0.6f), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(vertical = 4.dp), - ) - } - - playingBook - ?.author - ?.takeIf { it.isNotEmpty() } - ?.let { - InfoRow( - icon = Icons.Outlined.Person, - label = stringResource(R.string.playing_item_details_author), - textValue = it, - ) - } - - playingBook - ?.narrator - ?.takeIf { it.isNotEmpty() } - ?.let { - InfoRow( - icon = Icons.Outlined.MicNone, - label = stringResource(R.string.playing_item_details_narrator), - textValue = it, - ) - } - - playingBook - ?.chapters - ?.sumOf { it.duration } - ?.let { - InfoRow( - icon = Icons.Filled.AvTimer, - label = stringResource(R.string.playing_item_details_duration), - textValue = it.toInt().formatFully(), - ) - } - - playingBook - ?.publisher - ?.takeIf { it.isNotEmpty() } - ?.let { - InfoRow( - icon = Icons.Outlined.Business, - label = stringResource(R.string.playing_item_details_publisher), - textValue = it, - ) - } - - playingBook - ?.year - ?.takeIf { it.isNotEmpty() } - ?.let { - InfoRow( - icon = Icons.Outlined.CalendarMonth, - label = stringResource(R.string.playing_item_details_year), - textValue = it, - ) - } + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + val seriesValue = + playingBook + ?.series + ?.takeIf { it.isNotEmpty() } + ?.joinToString(", ") { series -> + buildString { + append(series.name) + series.serialNumber + ?.takeIf(String::isNotBlank) + ?.let { append(" #$it") } + } } - playingBook - ?.abstract - ?.takeIf { it.isNotEmpty() } - ?.let { - HorizontalDivider( - modifier = Modifier - .padding(vertical = 16.dp, horizontal = 16.dp) - .alpha(0.2f), - ) + playingBook + ?.title + ?.takeIf { it.isNotEmpty() } + ?.let { + Text( + text = it, + style = + typography.titleLarge.copy( + fontWeight = FontWeight.Bold, + ), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = colorScheme.onSurface, + modifier = + when (seriesValue?.isNotBlank()) { + true -> Modifier + else -> Modifier.padding(bottom = 8.dp) + }, + ) + } - val html = (playingBook.abstract).replace("\n", "
") - val spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) + seriesValue + ?.let { + Text( + text = it, + style = typography.titleSmall, + color = colorScheme.onBackground.copy(alpha = 0.6f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(vertical = 4.dp), + ) + } - Text( - text = spanned.toString(), - style = typography.bodyMedium.copy(lineHeight = 22.sp), - color = colorScheme.onSurface, - textAlign = TextAlign.Justify, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } + playingBook + ?.author + ?.takeIf { it.isNotEmpty() } + ?.let { + InfoRow( + icon = Icons.Outlined.Person, + label = stringResource(R.string.playing_item_details_author), + textValue = it, + ) + } + + playingBook + ?.narrator + ?.takeIf { it.isNotEmpty() } + ?.let { + InfoRow( + icon = Icons.Outlined.MicNone, + label = stringResource(R.string.playing_item_details_narrator), + textValue = it, + ) + } + + playingBook + ?.chapters + ?.sumOf { it.duration } + ?.let { + InfoRow( + icon = Icons.Filled.AvTimer, + label = stringResource(R.string.playing_item_details_duration), + textValue = it.toInt().formatFully(), + ) + } + + playingBook + ?.publisher + ?.takeIf { it.isNotEmpty() } + ?.let { + InfoRow( + icon = Icons.Outlined.Business, + label = stringResource(R.string.playing_item_details_publisher), + textValue = it, + ) + } + + playingBook + ?.year + ?.takeIf { it.isNotEmpty() } + ?.let { + InfoRow( + icon = Icons.Outlined.CalendarMonth, + label = stringResource(R.string.playing_item_details_year), + textValue = it, + ) + } + } + + playingBook + ?.abstract + ?.takeIf { it.isNotEmpty() } + ?.let { + HorizontalDivider( + modifier = + Modifier + .padding(vertical = 16.dp, horizontal = 16.dp) + .alpha(0.2f), + ) + + val html = (playingBook.abstract).replace("\n", "
") + val spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY) + + Text( + text = spanned.toString(), + style = typography.bodyMedium.copy(lineHeight = 22.sp), + color = colorScheme.onSurface, + textAlign = TextAlign.Justify, + modifier = Modifier.padding(horizontal = 16.dp), + ) } } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt index 7cd7692c..0459aef8 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/NavigationBarComposable.kt @@ -49,207 +49,212 @@ import org.grakovne.lissen.viewmodel.PlayerViewModel @Composable fun NavigationBarComposable( - book: DetailedItem, - playerViewModel: PlayerViewModel, - contentCachingModelView: CachingModelView, - navController: AppNavigationService, - modifier: Modifier = Modifier, - libraryType: LibraryType, + book: DetailedItem, + playerViewModel: PlayerViewModel, + contentCachingModelView: CachingModelView, + navController: AppNavigationService, + modifier: Modifier = Modifier, + libraryType: LibraryType, ) { - val cacheProgress: CacheState by contentCachingModelView.getProgress(book.id).collectAsState() - val timerOption by playerViewModel.timerOption.observeAsState(null) - val playbackSpeed by playerViewModel.playbackSpeed.observeAsState(1f) - val playingQueueExpanded by playerViewModel.playingQueueExpanded.observeAsState(false) + val cacheProgress: CacheState by contentCachingModelView.getProgress(book.id).collectAsState() + val timerOption by playerViewModel.timerOption.observeAsState(null) + val playbackSpeed by playerViewModel.playbackSpeed.observeAsState(1f) + val playingQueueExpanded by playerViewModel.playingQueueExpanded.observeAsState(false) - val isMetadataCached by contentCachingModelView.provideCacheState(book.id).observeAsState(false) + val isMetadataCached by contentCachingModelView.provideCacheState(book.id).observeAsState(false) - var playbackSpeedExpanded by remember { mutableStateOf(false) } - var timerExpanded by remember { mutableStateOf(false) } - var downloadsExpanded by remember { mutableStateOf(false) } + var playbackSpeedExpanded by remember { mutableStateOf(false) } + var timerExpanded by remember { mutableStateOf(false) } + var downloadsExpanded by remember { mutableStateOf(false) } - Surface( - shadowElevation = 4.dp, - modifier = modifier.height(64.dp), + Surface( + shadowElevation = 4.dp, + modifier = modifier.height(64.dp), + ) { + NavigationBar( + containerColor = Color.Transparent, + contentColor = colorScheme.onBackground, + modifier = Modifier.fillMaxWidth(), ) { - NavigationBar( - containerColor = Color.Transparent, - contentColor = colorScheme.onBackground, - modifier = Modifier.fillMaxWidth(), - ) { - val iconSize = 24.dp - val labelStyle = typography.labelSmall.copy(fontSize = 10.sp) + val iconSize = 24.dp + val labelStyle = typography.labelSmall.copy(fontSize = 10.sp) - NavigationBarItem( - icon = { - Icon( - Icons.AutoMirrored.Rounded.QueueMusic, - contentDescription = stringResource(R.string.player_screen_chapter_list_navigation), - modifier = Modifier.size(iconSize), - ) - }, - label = { - Text( - text = stringResource(R.string.player_screen_chapter_list_navigation), - style = labelStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - selected = playingQueueExpanded, - onClick = { playerViewModel.togglePlayingQueue() }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = colorScheme.primary, - indicatorColor = colorScheme.surfaceContainer, - ), - ) + NavigationBarItem( + icon = { + Icon( + Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = stringResource(R.string.player_screen_chapter_list_navigation), + modifier = Modifier.size(iconSize), + ) + }, + label = { + Text( + text = stringResource(R.string.player_screen_chapter_list_navigation), + style = labelStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = playingQueueExpanded, + onClick = { playerViewModel.togglePlayingQueue() }, + colors = + NavigationBarItemDefaults.colors( + selectedIconColor = colorScheme.primary, + indicatorColor = colorScheme.surfaceContainer, + ), + ) - NavigationBarItem( - icon = { - Icon( - imageVector = provideCachingStateIcon( - cacheState = cacheProgress, - hasCached = isMetadataCached, - ), - contentDescription = stringResource(R.string.player_screen_downloads_navigation), - modifier = Modifier.size(iconSize), - ) - }, - label = { - Text( - text = stringResource(R.string.player_screen_downloads_navigation), - style = labelStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - selected = false, - onClick = { downloadsExpanded = true }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = colorScheme.primary, - indicatorColor = colorScheme.surfaceContainer, - ), - ) + NavigationBarItem( + icon = { + Icon( + imageVector = + provideCachingStateIcon( + cacheState = cacheProgress, + hasCached = isMetadataCached, + ), + contentDescription = stringResource(R.string.player_screen_downloads_navigation), + modifier = Modifier.size(iconSize), + ) + }, + label = { + Text( + text = stringResource(R.string.player_screen_downloads_navigation), + style = labelStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = false, + onClick = { downloadsExpanded = true }, + colors = + NavigationBarItemDefaults.colors( + selectedIconColor = colorScheme.primary, + indicatorColor = colorScheme.surfaceContainer, + ), + ) - NavigationBarItem( - icon = { - Icon( - Icons.Outlined.SlowMotionVideo, - contentDescription = stringResource(R.string.player_screen_playback_speed_navigation), - modifier = Modifier.size(iconSize), - ) - }, - label = { - Text( - text = stringResource(R.string.player_screen_playback_speed_navigation), - style = labelStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - selected = false, - onClick = { playbackSpeedExpanded = true }, - enabled = true, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = colorScheme.primary, - indicatorColor = colorScheme.surfaceContainer, - ), - ) + NavigationBarItem( + icon = { + Icon( + Icons.Outlined.SlowMotionVideo, + contentDescription = stringResource(R.string.player_screen_playback_speed_navigation), + modifier = Modifier.size(iconSize), + ) + }, + label = { + Text( + text = stringResource(R.string.player_screen_playback_speed_navigation), + style = labelStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = false, + onClick = { playbackSpeedExpanded = true }, + enabled = true, + colors = + NavigationBarItemDefaults.colors( + selectedIconColor = colorScheme.primary, + indicatorColor = colorScheme.surfaceContainer, + ), + ) - NavigationBarItem( - icon = { - Icon( - when (timerOption) { - null -> Icons.Outlined.Timer - else -> TimerPlay - }, - contentDescription = stringResource(R.string.player_screen_timer_navigation), - modifier = Modifier.size(iconSize), - ) - }, - label = { - Text( - text = stringResource(R.string.player_screen_timer_navigation), - style = labelStyle, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - selected = false, - onClick = { timerExpanded = true }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = colorScheme.primary, - indicatorColor = colorScheme.surfaceContainer, - ), - ) + NavigationBarItem( + icon = { + Icon( + when (timerOption) { + null -> Icons.Outlined.Timer + else -> TimerPlay + }, + contentDescription = stringResource(R.string.player_screen_timer_navigation), + modifier = Modifier.size(iconSize), + ) + }, + label = { + Text( + text = stringResource(R.string.player_screen_timer_navigation), + style = labelStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + selected = false, + onClick = { timerExpanded = true }, + colors = + NavigationBarItemDefaults.colors( + selectedIconColor = colorScheme.primary, + indicatorColor = colorScheme.surfaceContainer, + ), + ) - if (playbackSpeedExpanded) { - PlaybackSpeedComposable( - currentSpeed = playbackSpeed, - onSpeedChange = { playerViewModel.setPlaybackSpeed(it) }, - onDismissRequest = { playbackSpeedExpanded = false }, + if (playbackSpeedExpanded) { + PlaybackSpeedComposable( + currentSpeed = playbackSpeed, + onSpeedChange = { playerViewModel.setPlaybackSpeed(it) }, + onDismissRequest = { playbackSpeedExpanded = false }, + ) + } + + if (timerExpanded) { + TimerComposable( + currentOption = timerOption, + onOptionSelected = { playerViewModel.setTimer(it) }, + onDismissRequest = { timerExpanded = false }, + ) + } + + if (downloadsExpanded) { + DownloadsComposable( + libraryType = libraryType, + hasCachedEpisodes = isMetadataCached, + isForceCache = contentCachingModelView.localCacheUsing(), + onRequestedDownload = { option -> + playerViewModel.book.value?.let { + contentCachingModelView + .cache( + mediaItemId = it.id, + currentPosition = playerViewModel.totalPosition.value ?: 0.0, + option = option, ) } + }, + onRequestedDrop = { + playerViewModel + .book + .value + ?.let { + contentCachingModelView.dropCache(it.id) - if (timerExpanded) { - TimerComposable( - currentOption = timerOption, - onOptionSelected = { playerViewModel.setTimer(it) }, - onDismissRequest = { timerExpanded = false }, - ) - } - - if (downloadsExpanded) { - DownloadsComposable( - libraryType = libraryType, - hasCachedEpisodes = isMetadataCached, - isForceCache = contentCachingModelView.localCacheUsing(), - onRequestedDownload = { option -> - playerViewModel.book.value?.let { - contentCachingModelView - .cache( - mediaItemId = it.id, - currentPosition = playerViewModel.totalPosition.value ?: 0.0, - option = option, - ) - } - }, - onRequestedDrop = { - playerViewModel - .book - .value - ?.let { - contentCachingModelView.dropCache(it.id) - - playerViewModel.clearPlayingBook() - navController.showLibrary(true) - } - }, - onDismissRequest = { downloadsExpanded = false }, - ) - } - } + playerViewModel.clearPlayingBook() + navController.showLibrary(true) + } + }, + onDismissRequest = { downloadsExpanded = false }, + ) + } } + } } private fun provideCachingStateIcon( - hasCached: Boolean, - cacheState: CacheState, + hasCached: Boolean, + cacheState: CacheState, ): ImageVector { - if (cacheState.status is CacheStatus.Caching) { - return when { - cacheState.progress < 1.0 / 6 -> Loader10 - cacheState.progress < 2.0 / 6 -> Loader20 - cacheState.progress < 3.0 / 6 -> Loader40 - cacheState.progress < 4.0 / 6 -> Loader60 - cacheState.progress < 5.0 / 6 -> Loader80 - else -> Loader90 - } + if (cacheState.status is CacheStatus.Caching) { + return when { + cacheState.progress < 1.0 / 6 -> Loader10 + cacheState.progress < 2.0 / 6 -> Loader20 + cacheState.progress < 3.0 / 6 -> Loader40 + cacheState.progress < 4.0 / 6 -> Loader60 + cacheState.progress < 5.0 / 6 -> Loader80 + else -> Loader90 } + } - return when (hasCached) { - true -> cachedIcon - else -> defaultIcon - } + return when (hasCached) { + true -> cachedIcon + else -> defaultIcon + } } private val cachedIcon = Icons.Outlined.CloudDone diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt index b2b1e05a..67ff2dac 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaybackSpeedComposable.kt @@ -36,75 +36,95 @@ import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaybackSpeedComposable( - currentSpeed: Float, - onSpeedChange: (Float) -> Unit, - onDismissRequest: () -> Unit, + currentSpeed: Float, + onSpeedChange: (Float) -> Unit, + onDismissRequest: () -> Unit, ) { - val view: View = LocalView.current - var selectedPlaybackSpeed by remember { mutableFloatStateOf(currentSpeed) } + val view: View = LocalView.current + var selectedPlaybackSpeed by remember { mutableFloatStateOf(currentSpeed) } - ModalBottomSheet( - containerColor = colorScheme.background, - onDismissRequest = onDismissRequest, - content = { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.playback_speed_title), - style = typography.bodyLarge, - ) + ModalBottomSheet( + containerColor = colorScheme.background, + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.playback_speed_title), + style = typography.bodyLarge, + ) - PlaybackSpeedSlider( - speed = selectedPlaybackSpeed, - speedRange = (0.5f..3f), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - onSpeedUpdate = { - selectedPlaybackSpeed = it - onSpeedChange(it) - }, - ) + PlaybackSpeedSlider( + speed = selectedPlaybackSpeed, + speedRange = (0.5f..3f), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + onSpeedUpdate = { + selectedPlaybackSpeed = it + onSpeedChange(it) + }, + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - playbackSpeedPresets.forEach { value -> - FilledTonalButton( - onClick = { - hapticAction(view) { - selectedPlaybackSpeed = value - onSpeedChange(value) - } - }, - modifier = Modifier.size(56.dp), - shape = CircleShape, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = if (selectedPlaybackSpeed == value) colorScheme.primary else colorScheme.surfaceContainer, - contentColor = if (selectedPlaybackSpeed == value) colorScheme.onPrimary else colorScheme.onSurfaceVariant, - ), - contentPadding = PaddingValues(0.dp), - ) { - Text( - text = String.format(Locale.US, "%.1f", value), - style = if (selectedPlaybackSpeed == value) { - typography.labelMedium.copy(fontWeight = FontWeight.Bold) - } else typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + playbackSpeedPresets.forEach { value -> + FilledTonalButton( + onClick = { + hapticAction(view) { + selectedPlaybackSpeed = value + onSpeedChange(value) } + }, + modifier = Modifier.size(56.dp), + shape = CircleShape, + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = + if (selectedPlaybackSpeed == + value + ) { + colorScheme.primary + } else { + colorScheme.surfaceContainer + }, + contentColor = + if (selectedPlaybackSpeed == + value + ) { + colorScheme.onPrimary + } else { + colorScheme.onSurfaceVariant + }, + ), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = String.format(Locale.US, "%.1f", value), + style = + if (selectedPlaybackSpeed == value) { + typography.labelMedium.copy(fontWeight = FontWeight.Bold) + } else { + typography.labelMedium + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - }, - ) + } + } + } + }, + ) } private val playbackSpeedPresets = listOf(1f, 1.2f, 1.5f, 2f, 3f) diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt index 86968b70..79ace784 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlayingQueueComposable.kt @@ -53,186 +53,188 @@ import org.grakovne.lissen.viewmodel.PlayerViewModel @Composable fun PlayingQueueComposable( - libraryViewModel: LibraryViewModel, - viewModel: PlayerViewModel, - modifier: Modifier = Modifier, + libraryViewModel: LibraryViewModel, + viewModel: PlayerViewModel, + modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() - val book by viewModel.book.observeAsState() - val searchToken by viewModel.searchToken.observeAsState("") + val book by viewModel.book.observeAsState() + val searchToken by viewModel.searchToken.observeAsState("") - val showingChapters by remember { - derivedStateOf { - when (searchToken.isEmpty()) { - true -> - book - ?.chapters - ?: emptyList() + val showingChapters by remember { + derivedStateOf { + when (searchToken.isEmpty()) { + true -> + book + ?.chapters + ?: emptyList() - false -> - book - ?.chapters - ?.filter { it.title.lowercase().contains(searchToken.lowercase()) } - ?: emptyList() - } - } + false -> + book + ?.chapters + ?.filter { it.title.lowercase().contains(searchToken.lowercase()) } + ?: emptyList() + } } + } - val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) - val currentTrackId by remember { - derivedStateOf { - book?.chapters?.getOrNull(currentTrackIndex) - } + val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) + val currentTrackId by remember { + derivedStateOf { + book?.chapters?.getOrNull(currentTrackIndex) } + } - val playbackReady by viewModel.isPlaybackReady.observeAsState(false) - val playingQueueExpanded by viewModel.playingQueueExpanded.observeAsState(false) + val playbackReady by viewModel.isPlaybackReady.observeAsState(false) + val playingQueueExpanded by viewModel.playingQueueExpanded.observeAsState(false) - val density = LocalDensity.current + val density = LocalDensity.current - var collapsedPlayingQueueHeight by remember { mutableIntStateOf(0) } - val isFlinging = remember { mutableStateOf(false) } + var collapsedPlayingQueueHeight by remember { mutableIntStateOf(0) } + val isFlinging = remember { mutableStateOf(false) } - val expandFlingThreshold = - remember { ViewConfiguration.get(context).scaledMinimumFlingVelocity.toFloat() * 2 } + val expandFlingThreshold = + remember { ViewConfiguration.get(context).scaledMinimumFlingVelocity.toFloat() * 2 } - val collapseFlingThreshold = - remember { ViewConfiguration.get(context).scaledMaximumFlingVelocity.toFloat() * 0.2 } + val collapseFlingThreshold = + remember { ViewConfiguration.get(context).scaledMaximumFlingVelocity.toFloat() * 0.2 } - val listState = rememberLazyListState() + val listState = rememberLazyListState() - val fontSize by animateFloatAsState( - targetValue = typography.titleMedium.fontSize.value * 1.25f, - animationSpec = tween(durationMillis = 500), - label = "playing_queue_font_size", + val fontSize by animateFloatAsState( + targetValue = typography.titleMedium.fontSize.value * 1.25f, + animationSpec = tween(durationMillis = 500), + label = "playing_queue_font_size", + ) + + LaunchedEffect(currentTrackIndex) { + awaitFrame() + scrollPlayingQueue( + currentTrackIndex = currentTrackIndex, + listState = listState, + playbackReady = playbackReady, + animate = true, + playingQueueExpanded = playingQueueExpanded, ) + } - LaunchedEffect(currentTrackIndex) { - awaitFrame() - scrollPlayingQueue( - currentTrackIndex = currentTrackIndex, - listState = listState, - playbackReady = playbackReady, - animate = true, - playingQueueExpanded = playingQueueExpanded, - ) + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + if (playingQueueExpanded.not()) { + Text( + text = provideNowPlayingTitle(libraryViewModel.fetchPreferredLibraryType(), context), + fontSize = fontSize.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 6.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) } - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - ) { - if (playingQueueExpanded.not()) { - Text( - text = provideNowPlayingTitle(libraryViewModel.fetchPreferredLibraryType(), context), - fontSize = fontSize.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 6.dp), - ) - - Spacer(modifier = Modifier.height(12.dp)) - } - - LazyColumn( - contentPadding = when (playingQueueExpanded) { - true -> PaddingValues(bottom = 12.dp) - false -> PaddingValues(bottom = with(density) { collapsedPlayingQueueHeight.toDp() }) - }, - modifier = Modifier - .fillMaxHeight() - .scrollable( - state = rememberScrollState(), - orientation = Orientation.Vertical, - enabled = playingQueueExpanded, - ) - .onGloballyPositioned { - if (collapsedPlayingQueueHeight == 0) { - collapsedPlayingQueueHeight = it.size.height - } - } - .onSizeChanged { intSize -> - if (intSize.height != collapsedPlayingQueueHeight) { - coroutineScope.launch { - awaitFrame() - scrollPlayingQueue( - currentTrackIndex = currentTrackIndex, - listState = listState, - playbackReady = playbackReady, - animate = false, - playingQueueExpanded = playingQueueExpanded, - ) - } - } - } - .nestedScroll(object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource, - ): Offset { - return if (playingQueueExpanded) Offset.Zero else available - } - - override suspend fun onPreFling(available: Velocity): Velocity { - if (available.y < -expandFlingThreshold && !playingQueueExpanded) { - isFlinging.value = true - viewModel.expandPlayingQueue() - return available - } - - if (available.y > collapseFlingThreshold && playingQueueExpanded) { - isFlinging.value = true - viewModel.collapsePlayingQueue() - return available - } - isFlinging.value = false - return available - } - }), - state = listState, - ) { - itemsIndexed(showingChapters) { index, chapter -> - PlaylistItemComposable( - track = chapter, - onClick = { viewModel.setChapter(chapter) }, - isSelected = chapter.id == currentTrackId?.id, - modifier = Modifier.wrapContentWidth(), - ) - - if (index < showingChapters.size - 1) { - HorizontalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(start = 24.dp) - .padding(vertical = 8.dp), - ) - } + LazyColumn( + contentPadding = + when (playingQueueExpanded) { + true -> PaddingValues(bottom = 12.dp) + false -> PaddingValues(bottom = with(density) { collapsedPlayingQueueHeight.toDp() }) + }, + modifier = + Modifier + .fillMaxHeight() + .scrollable( + state = rememberScrollState(), + orientation = Orientation.Vertical, + enabled = playingQueueExpanded, + ).onGloballyPositioned { + if (collapsedPlayingQueueHeight == 0) { + collapsedPlayingQueueHeight = it.size.height } + }.onSizeChanged { intSize -> + if (intSize.height != collapsedPlayingQueueHeight) { + coroutineScope.launch { + awaitFrame() + scrollPlayingQueue( + currentTrackIndex = currentTrackIndex, + listState = listState, + playbackReady = playbackReady, + animate = false, + playingQueueExpanded = playingQueueExpanded, + ) + } + } + }.nestedScroll( + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = if (playingQueueExpanded) Offset.Zero else available + + override suspend fun onPreFling(available: Velocity): Velocity { + if (available.y < -expandFlingThreshold && !playingQueueExpanded) { + isFlinging.value = true + viewModel.expandPlayingQueue() + return available + } + + if (available.y > collapseFlingThreshold && playingQueueExpanded) { + isFlinging.value = true + viewModel.collapsePlayingQueue() + return available + } + isFlinging.value = false + return available + } + }, + ), + state = listState, + ) { + itemsIndexed(showingChapters) { index, chapter -> + PlaylistItemComposable( + track = chapter, + onClick = { viewModel.setChapter(chapter) }, + isSelected = chapter.id == currentTrackId?.id, + modifier = Modifier.wrapContentWidth(), + ) + + if (index < showingChapters.size - 1) { + HorizontalDivider( + thickness = 1.dp, + modifier = + Modifier + .padding(start = 24.dp) + .padding(vertical = 8.dp), + ) } + } } + } } private suspend fun scrollPlayingQueue( - currentTrackIndex: Int, - listState: LazyListState, - playbackReady: Boolean, - animate: Boolean, - playingQueueExpanded: Boolean, + currentTrackIndex: Int, + listState: LazyListState, + playbackReady: Boolean, + animate: Boolean, + playingQueueExpanded: Boolean, ) { - if (playingQueueExpanded) { - return + if (playingQueueExpanded) { + return + } + + val targetIndex = + when (currentTrackIndex > 0) { + true -> currentTrackIndex - 1 + false -> 0 } - val targetIndex = when (currentTrackIndex > 0) { - true -> currentTrackIndex - 1 - false -> 0 - } - - when (animate && playbackReady) { - true -> listState.animateScrollToItem(targetIndex) - false -> listState.scrollToItem(targetIndex) - } + when (animate && playbackReady) { + true -> listState.animateScrollToItem(targetIndex) + false -> listState.scrollToItem(targetIndex) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt index fd11d6df..d4dbae78 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/PlaylistItemComposable.kt @@ -29,61 +29,65 @@ import org.grakovne.lissen.ui.extensions.formatLeadingMinutes @Composable fun PlaylistItemComposable( - track: PlayingChapter, - isSelected: Boolean, - onClick: () -> Unit, - modifier: Modifier, + track: PlayingChapter, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .padding(start = 6.dp) - .padding(end = 4.dp) - .clickable( - onClick = onClick, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ), - ) { - when { - isSelected -> - Icon( - imageVector = Icons.Outlined.Audiotrack, - contentDescription = stringResource(R.string.player_screen_library_playing_title), - modifier = Modifier.size(16.dp), - ) - - track.podcastEpisodeState == BookChapterState.FINISHED -> Icon( - imageVector = Icons.Outlined.Check, - contentDescription = stringResource(R.string.player_screen_library_playing_title), - modifier = Modifier.size(16.dp), - ) - - else -> Spacer(modifier = Modifier.size(16.dp)) - } - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = track.title, - style = MaterialTheme.typography.titleSmall, - color = when (track.available) { - true -> colorScheme.onBackground - false -> colorScheme.onBackground.copy(alpha = 0.4f) - }, - overflow = TextOverflow.Ellipsis, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.weight(1f), + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .padding(start = 6.dp) + .padding(end = 4.dp) + .clickable( + onClick = onClick, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), + ) { + when { + isSelected -> + Icon( + imageVector = Icons.Outlined.Audiotrack, + contentDescription = stringResource(R.string.player_screen_library_playing_title), + modifier = Modifier.size(16.dp), ) - Text( - text = track.duration.toInt().formatLeadingMinutes(), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 8.dp), - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = when (track.available) { - true -> colorScheme.onBackground.copy(alpha = 0.6f) - false -> colorScheme.onBackground.copy(alpha = 0.4f) - }, + + track.podcastEpisodeState == BookChapterState.FINISHED -> + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = stringResource(R.string.player_screen_library_playing_title), + modifier = Modifier.size(16.dp), ) + + else -> Spacer(modifier = Modifier.size(16.dp)) } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = track.title, + style = MaterialTheme.typography.titleSmall, + color = + when (track.available) { + true -> colorScheme.onBackground + false -> colorScheme.onBackground.copy(alpha = 0.4f) + }, + overflow = TextOverflow.Ellipsis, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.weight(1f), + ) + Text( + text = track.duration.toInt().formatLeadingMinutes(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 8.dp), + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = + when (track.available) { + true -> colorScheme.onBackground.copy(alpha = 0.6f) + false -> colorScheme.onBackground.copy(alpha = 0.4f) + }, + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt index 934f8c58..fa3108cb 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TimerComposable.kt @@ -35,100 +35,105 @@ import org.grakovne.lissen.domain.TimerOption @OptIn(ExperimentalMaterial3Api::class) @Composable fun TimerComposable( - currentOption: TimerOption?, - onOptionSelected: (TimerOption?) -> Unit, - onDismissRequest: () -> Unit, + currentOption: TimerOption?, + onOptionSelected: (TimerOption?) -> Unit, + onDismissRequest: () -> Unit, ) { - val context = LocalContext.current + val context = LocalContext.current - ModalBottomSheet( - containerColor = colorScheme.background, - onDismissRequest = onDismissRequest, - content = { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.timer_title), - style = typography.bodyLarge, - ) + ModalBottomSheet( + containerColor = colorScheme.background, + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.timer_title), + style = typography.bodyLarge, + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(TimerOptions) { index, item -> - ListItem( - headlineContent = { - Row { - Text( - text = item.makeText(context), - style = typography.bodyMedium, - ) - } - }, - trailingContent = { - if (item == currentOption) { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - onOptionSelected(item) - onDismissRequest() - }, - ) - if (index < TimerOptions.size - 1) { - HorizontalDivider() - } - } - - if (currentOption != null) { - item { - HorizontalDivider() - - ListItem( - headlineContent = { - Row { - Text( - text = stringResource(R.string.timer_option_disable_timer), - color = colorScheme.error, - style = typography.bodyMedium, - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable { - onOptionSelected(null) - onDismissRequest() - }, - ) - } - } + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(TimerOptions) { index, item -> + ListItem( + headlineContent = { + Row { + Text( + text = item.makeText(context), + style = typography.bodyMedium, + ) } + }, + trailingContent = { + if (item == currentOption) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onOptionSelected(item) + onDismissRequest() + }, + ) + if (index < TimerOptions.size - 1) { + HorizontalDivider() } - }, - ) + } + + if (currentOption != null) { + item { + HorizontalDivider() + + ListItem( + headlineContent = { + Row { + Text( + text = stringResource(R.string.timer_option_disable_timer), + color = colorScheme.error, + style = typography.bodyMedium, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onOptionSelected(null) + onDismissRequest() + }, + ) + } + } + } + } + }, + ) } -private val TimerOptions = listOf( +private val TimerOptions = + listOf( DurationTimerOption(10), DurationTimerOption(15), DurationTimerOption(30), DurationTimerOption(60), CurrentEpisodeTimerOption, -) + ) -fun TimerOption.makeText(context: Context): String = when (this) { +fun TimerOption.makeText(context: Context): String = + when (this) { CurrentEpisodeTimerOption -> context.getString(R.string.timer_option_after_current_episode) is DurationTimerOption -> context.getString(R.string.timer_option_after_minutes, this.duration) -} + } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt index 74419fdb..968d4da0 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackControlComposable.kt @@ -44,160 +44,168 @@ import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable fun TrackControlComposable( - viewModel: PlayerViewModel, - settingsViewModel: SettingsViewModel, - modifier: Modifier = Modifier, + viewModel: PlayerViewModel, + settingsViewModel: SettingsViewModel, + modifier: Modifier = Modifier, ) { - val isPlaying by viewModel.isPlaying.observeAsState(false) - val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) - val currentTrackPosition by viewModel.currentChapterPosition.observeAsState(0.0) - val currentTrackDuration by viewModel.currentChapterDuration.observeAsState(0.0) + val isPlaying by viewModel.isPlaying.observeAsState(false) + val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) + val currentTrackPosition by viewModel.currentChapterPosition.observeAsState(0.0) + val currentTrackDuration by viewModel.currentChapterDuration.observeAsState(0.0) - val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) + val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) - val book by viewModel.book.observeAsState() - val chapters = book?.chapters ?: emptyList() + val book by viewModel.book.observeAsState() + val chapters = book?.chapters ?: emptyList() - val view: View = LocalView.current + val view: View = LocalView.current - var sliderPosition by remember { mutableDoubleStateOf(0.0) } - var isDragging by remember { mutableStateOf(false) } + var sliderPosition by remember { mutableDoubleStateOf(0.0) } + var isDragging by remember { mutableStateOf(false) } - LaunchedEffect(currentTrackPosition, currentTrackIndex, currentTrackDuration) { - if (!isDragging) { - sliderPosition = currentTrackPosition - } + LaunchedEffect(currentTrackPosition, currentTrackIndex, currentTrackDuration) { + if (!isDragging) { + sliderPosition = currentTrackPosition } + } + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + ) { Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), + modifier = Modifier.fillMaxWidth(), ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Slider( - value = sliderPosition.toFloat(), - onValueChange = { newPosition -> - isDragging = true - sliderPosition = newPosition.toDouble() - }, - onValueChangeFinished = { - isDragging = false - viewModel.seekTo(sliderPosition) - }, - valueRange = 0f..currentTrackDuration.toFloat(), - colors = SliderDefaults.colors( - thumbColor = colorScheme.primary, - activeTrackColor = colorScheme.primary, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - - Box( - modifier = Modifier.fillMaxWidth(), - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .offset(y = (-4).dp) - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = sliderPosition.toInt().formatLeadingMinutes(), - style = typography.bodySmall, - color = colorScheme.onBackground.copy(alpha = 0.6f), - ) - Text( - text = maxOf(0.0, currentTrackDuration - sliderPosition) - .toInt() - .formatLeadingMinutes(), - style = typography.bodySmall, - color = colorScheme.onBackground.copy(alpha = 0.6f), - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp) - .align(Alignment.BottomCenter), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - hapticAction(view) { viewModel.previousTrack() } - }, - enabled = true, - ) { - Icon( - imageVector = Icons.Rounded.SkipPrevious, - contentDescription = "Previous Track", - tint = colorScheme.onBackground, - modifier = Modifier.size(36.dp), - ) - } - - IconButton( - onClick = { hapticAction(view) { viewModel.rewind() } }, - ) { - Icon( - imageVector = provideReplayIcon(seekTime), - contentDescription = "Rewind", - tint = colorScheme.onBackground, - modifier = Modifier.size(48.dp), - ) - } - - IconButton( - onClick = { hapticAction(view) { viewModel.togglePlayPause() } }, - modifier = Modifier.size(72.dp), - ) { - Icon( - imageVector = if (isPlaying) Icons.Rounded.PauseCircleFilled else Icons.Rounded.PlayCircleFilled, - contentDescription = "Play / Pause", - tint = colorScheme.primary, - modifier = Modifier.fillMaxSize(), - ) - } - - IconButton( - onClick = { hapticAction(view) { viewModel.forward() } }, - ) { - Icon( - imageVector = provideForwardIcon(seekTime), - contentDescription = "Forward", - tint = colorScheme.onBackground, - modifier = Modifier.size(48.dp), - ) - } - - IconButton( - onClick = { - if (currentTrackIndex < chapters.size - 1) { - hapticAction(view) { viewModel.nextTrack() } - } - }, - enabled = currentTrackIndex < chapters.size - 1, - ) { - Icon( - imageVector = Icons.Rounded.SkipNext, - contentDescription = "Next Track", - tint = if (currentTrackIndex < chapters.size - 1) { - colorScheme.onBackground - } else colorScheme.onBackground.copy( - alpha = 0.3f, - ), - modifier = Modifier.size(36.dp), - ) - } - } - } + Slider( + value = sliderPosition.toFloat(), + onValueChange = { newPosition -> + isDragging = true + sliderPosition = newPosition.toDouble() + }, + onValueChangeFinished = { + isDragging = false + viewModel.seekTo(sliderPosition) + }, + valueRange = 0f..currentTrackDuration.toFloat(), + colors = + SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) } + + Box( + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .offset(y = (-4).dp) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = sliderPosition.toInt().formatLeadingMinutes(), + style = typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.6f), + ) + Text( + text = + maxOf(0.0, currentTrackDuration - sliderPosition) + .toInt() + .formatLeadingMinutes(), + style = typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + hapticAction(view) { viewModel.previousTrack() } + }, + enabled = true, + ) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = "Previous Track", + tint = colorScheme.onBackground, + modifier = Modifier.size(36.dp), + ) + } + + IconButton( + onClick = { hapticAction(view) { viewModel.rewind() } }, + ) { + Icon( + imageVector = provideReplayIcon(seekTime), + contentDescription = "Rewind", + tint = colorScheme.onBackground, + modifier = Modifier.size(48.dp), + ) + } + + IconButton( + onClick = { hapticAction(view) { viewModel.togglePlayPause() } }, + modifier = Modifier.size(72.dp), + ) { + Icon( + imageVector = if (isPlaying) Icons.Rounded.PauseCircleFilled else Icons.Rounded.PlayCircleFilled, + contentDescription = "Play / Pause", + tint = colorScheme.primary, + modifier = Modifier.fillMaxSize(), + ) + } + + IconButton( + onClick = { hapticAction(view) { viewModel.forward() } }, + ) { + Icon( + imageVector = provideForwardIcon(seekTime), + contentDescription = "Forward", + tint = colorScheme.onBackground, + modifier = Modifier.size(48.dp), + ) + } + + IconButton( + onClick = { + if (currentTrackIndex < chapters.size - 1) { + hapticAction(view) { viewModel.nextTrack() } + } + }, + enabled = currentTrackIndex < chapters.size - 1, + ) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Next Track", + tint = + if (currentTrackIndex < chapters.size - 1) { + colorScheme.onBackground + } else { + colorScheme.onBackground.copy( + alpha = 0.3f, + ) + }, + modifier = Modifier.size(36.dp), + ) + } + } + } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt index c928529c..7dbad948 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/TrackDetailsComposable.kt @@ -38,115 +38,125 @@ import org.grakovne.lissen.viewmodel.PlayerViewModel @Composable fun TrackDetailsComposable( - libraryViewModel: LibraryViewModel, - viewModel: PlayerViewModel, - modifier: Modifier = Modifier, - imageLoader: ImageLoader, + libraryViewModel: LibraryViewModel, + viewModel: PlayerViewModel, + modifier: Modifier = Modifier, + imageLoader: ImageLoader, ) { - val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) - val book by viewModel.book.observeAsState() + val currentTrackIndex by viewModel.currentChapterIndex.observeAsState(0) + val book by viewModel.book.observeAsState() - val context = LocalContext.current + val context = LocalContext.current - val imageRequest = remember(book?.id) { - ImageRequest.Builder(context) - .data(book?.id) - .size(coil.size.Size.ORIGINAL) - .build() + val imageRequest = + remember(book?.id) { + ImageRequest + .Builder(context) + .data(book?.id) + .size(coil.size.Size.ORIGINAL) + .build() } - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp - val maxImageHeight = screenHeight * 0.33f + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val maxImageHeight = screenHeight * 0.33f - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier, - ) { - AsyncShimmeringImage( - imageRequest = imageRequest, - imageLoader = imageLoader, - contentDescription = "${book?.title} cover", - contentScale = ContentScale.FillBounds, - modifier = Modifier - .heightIn(max = maxImageHeight) - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)), - error = painterResource(R.drawable.cover_fallback), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + AsyncShimmeringImage( + imageRequest = imageRequest, + imageLoader = imageLoader, + contentDescription = "${book?.title} cover", + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .heightIn(max = maxImageHeight) + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)), + error = painterResource(R.drawable.cover_fallback), + ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = book?.title.orEmpty(), + style = typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onBackground, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(2.dp)) + + book + ?.subtitle + ?.takeIf { it.isNotBlank() } + ?.let { Text( - text = book?.title.orEmpty(), - style = typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onBackground, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + text = it, + style = typography.bodyMedium, + color = colorScheme.onBackground.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(2.dp)) + } - book - ?.subtitle - ?.takeIf { it.isNotBlank() } - ?.let { - Text( - text = it, - style = typography.bodyMedium, - color = colorScheme.onBackground.copy(alpha = 0.6f), - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - - Spacer(modifier = Modifier.height(2.dp)) - } - - Text( - text = provideChapterNumberTitle( - currentTrackIndex = currentTrackIndex, - book = book, - libraryType = libraryViewModel.fetchPreferredLibraryType(), - context = context, - ), - style = typography.bodyMedium, - color = colorScheme.onBackground.copy(alpha = 0.6f), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } + Text( + text = + provideChapterNumberTitle( + currentTrackIndex = currentTrackIndex, + book = book, + libraryType = libraryViewModel.fetchPreferredLibraryType(), + context = context, + ), + style = typography.bodyMedium, + color = colorScheme.onBackground.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } } private fun provideChapterNumberTitle( - currentTrackIndex: Int, - book: DetailedItem?, - libraryType: LibraryType, - context: Context, -): String = when (libraryType) { - LibraryType.LIBRARY -> context.getString( + currentTrackIndex: Int, + book: DetailedItem?, + libraryType: LibraryType, + context: Context, +): String = + when (libraryType) { + LibraryType.LIBRARY -> + context.getString( R.string.player_screen_now_playing_title_chapter_of, currentTrackIndex + 1, book?.chapters?.size ?: "?", - ) + ) - LibraryType.PODCAST -> context.getString( + LibraryType.PODCAST -> + context.getString( R.string.player_screen_now_playing_title_podcast_of, currentTrackIndex + 1, book?.chapters?.size ?: "?", - ) + ) - LibraryType.UNKNOWN -> context.getString( + LibraryType.UNKNOWN -> + context.getString( R.string.player_screen_now_playing_title_item_of, currentTrackIndex + 1, book?.chapters?.size ?: "?", - ) -} + ) + } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/common/ProvideNowPlayingTitle.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/common/ProvideNowPlayingTitle.kt index 4fe8207a..7ca06cd1 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/common/ProvideNowPlayingTitle.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/common/ProvideNowPlayingTitle.kt @@ -5,10 +5,10 @@ import org.grakovne.lissen.R import org.grakovne.lissen.channel.common.LibraryType fun provideNowPlayingTitle( - libraryType: LibraryType, - context: Context, + libraryType: LibraryType, + context: Context, ) = when (libraryType) { - LibraryType.LIBRARY -> context.getString(R.string.player_screen_library_playing_title) - LibraryType.PODCAST -> context.getString(R.string.player_screen_podcast_playing_title) - LibraryType.UNKNOWN -> context.getString(R.string.player_screen_items_playing_title) + LibraryType.LIBRARY -> context.getString(R.string.player_screen_library_playing_title) + LibraryType.PODCAST -> context.getString(R.string.player_screen_podcast_playing_title) + LibraryType.UNKNOWN -> context.getString(R.string.player_screen_items_playing_title) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/fallback/PlayingQueueFallbackComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/fallback/PlayingQueueFallbackComposable.kt index 017a691d..f0d20eb2 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/fallback/PlayingQueueFallbackComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/fallback/PlayingQueueFallbackComposable.kt @@ -19,24 +19,26 @@ import org.grakovne.lissen.viewmodel.LibraryViewModel @Composable fun PlayingQueueFallbackComposable( - modifier: Modifier = Modifier, - libraryViewModel: LibraryViewModel, + modifier: Modifier = Modifier, + libraryViewModel: LibraryViewModel, ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - textAlign = TextAlign.Center, - text = when (libraryViewModel.fetchPreferredLibraryType()) { - LibraryType.LIBRARY -> stringResource(R.string.chapters_list_empty) - LibraryType.PODCAST -> stringResource(R.string.episodes_list_empty) - LibraryType.UNKNOWN -> stringResource(R.string.items_list_empty) - }, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp), - ) - } + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + textAlign = TextAlign.Center, + text = + when (libraryViewModel.fetchPreferredLibraryType()) { + LibraryType.LIBRARY -> stringResource(R.string.chapters_list_empty) + LibraryType.PODCAST -> stringResource(R.string.episodes_list_empty) + LibraryType.UNKNOWN -> stringResource(R.string.items_list_empty) + }, + style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp), + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/PlayingQueuePlaceholderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/PlayingQueuePlaceholderComposable.kt index efb19318..610da5b8 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/PlayingQueuePlaceholderComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/PlayingQueuePlaceholderComposable.kt @@ -26,45 +26,47 @@ import org.grakovne.lissen.viewmodel.LibraryViewModel @Composable fun PlayingQueuePlaceholderComposable( - libraryViewModel: LibraryViewModel, - modifier: Modifier = Modifier, + libraryViewModel: LibraryViewModel, + modifier: Modifier = Modifier, ) { - val context = LocalContext.current + val context = LocalContext.current - Column(modifier = modifier.padding(horizontal = 16.dp)) { - Text( - text = provideNowPlayingTitle(libraryViewModel.fetchPreferredLibraryType(), context), - fontSize = typography.titleMedium.fontSize * 1.25f, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 6.dp), + Column(modifier = modifier.padding(horizontal = 16.dp)) { + Text( + text = provideNowPlayingTitle(libraryViewModel.fetchPreferredLibraryType(), context), + fontSize = typography.titleMedium.fontSize * 1.25f, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 6.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LazyColumn( + modifier = + Modifier + .fillMaxWidth(), + ) { + items(10) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(36.dp) + .clip(RoundedCornerShape(8.dp)) + .shimmer() + .background(Color.Gray), ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(8.dp)) - LazyColumn( - modifier = Modifier - .fillMaxWidth(), - ) { - items(10) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(36.dp) - .clip(RoundedCornerShape(8.dp)) - .shimmer() - .background(Color.Gray), - ) + HorizontalDivider( + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 4.dp), + ) - Spacer(Modifier.height(8.dp)) - - HorizontalDivider( - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 4.dp), - ) - - Spacer(Modifier.height(8.dp)) - } - } + Spacer(Modifier.height(8.dp)) + } } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackControlPlaceholderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackControlPlaceholderComposable.kt index 05c0a12f..681c8170 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackControlPlaceholderComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackControlPlaceholderComposable.kt @@ -34,118 +34,122 @@ import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable fun TrackControlPlaceholderComposable( - settingsViewModel: SettingsViewModel, - modifier: Modifier = Modifier, + settingsViewModel: SettingsViewModel, + modifier: Modifier = Modifier, ) { - val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) + val seekTime by settingsViewModel.seekTime.observeAsState(SeekTime.Default) + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + ) { Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), + modifier = Modifier.fillMaxWidth(), ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Slider( - value = 0f, - onValueChange = {}, - onValueChangeFinished = {}, - valueRange = 0f..1f, - colors = SliderDefaults.colors( - thumbColor = colorScheme.primary, - activeTrackColor = colorScheme.primary, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - - Box( - modifier = Modifier.fillMaxWidth(), - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .offset(y = (-4).dp) - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = 0.formatLeadingMinutes(), - style = typography.bodySmall, - color = colorScheme.onBackground.copy(alpha = 0.6f), - ) - Text( - text = 0.formatLeadingMinutes(), - style = typography.bodySmall, - color = colorScheme.onBackground.copy(alpha = 0.6f), - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 6.dp) - .align(Alignment.BottomCenter), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = {}, - enabled = true, - ) { - Icon( - imageVector = Icons.Rounded.SkipPrevious, - contentDescription = "Previous Track", - tint = colorScheme.onBackground, - modifier = Modifier.size(36.dp), - ) - } - - IconButton(onClick = {}) { - Icon( - imageVector = provideReplayIcon(seekTime), - contentDescription = "Rewind", - tint = colorScheme.onBackground, - modifier = Modifier.size(48.dp), - ) - } - - IconButton( - onClick = {}, - modifier = Modifier.size(72.dp), - ) { - Icon( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = "Play / Pause", - tint = colorScheme.primary, - modifier = Modifier.fillMaxSize(), - ) - } - - IconButton(onClick = {}) { - Icon( - imageVector = provideForwardIcon(seekTime), - contentDescription = "Forward", - tint = colorScheme.onBackground, - modifier = Modifier.size(48.dp), - ) - } - - IconButton( - onClick = {}, - enabled = false, - ) { - Icon( - imageVector = Icons.Rounded.SkipNext, - contentDescription = "Next Track", - tint = colorScheme.onBackground.copy(alpha = 0.3f), - modifier = Modifier.size(36.dp), - ) - } - } - } + Slider( + value = 0f, + onValueChange = {}, + onValueChangeFinished = {}, + valueRange = 0f..1f, + colors = + SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + ), + modifier = Modifier.fillMaxWidth(), + ) } + + Box( + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .offset(y = (-4).dp) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = 0.formatLeadingMinutes(), + style = typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.6f), + ) + Text( + text = 0.formatLeadingMinutes(), + style = typography.bodySmall, + color = colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = {}, + enabled = true, + ) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = "Previous Track", + tint = colorScheme.onBackground, + modifier = Modifier.size(36.dp), + ) + } + + IconButton(onClick = {}) { + Icon( + imageVector = provideReplayIcon(seekTime), + contentDescription = "Rewind", + tint = colorScheme.onBackground, + modifier = Modifier.size(48.dp), + ) + } + + IconButton( + onClick = {}, + modifier = Modifier.size(72.dp), + ) { + Icon( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = "Play / Pause", + tint = colorScheme.primary, + modifier = Modifier.fillMaxSize(), + ) + } + + IconButton(onClick = {}) { + Icon( + imageVector = provideForwardIcon(seekTime), + contentDescription = "Forward", + tint = colorScheme.onBackground, + modifier = Modifier.size(48.dp), + ) + } + + IconButton( + onClick = {}, + enabled = false, + ) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Next Track", + tint = colorScheme.onBackground.copy(alpha = 0.3f), + modifier = Modifier.size(36.dp), + ) + } + } + } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt index 51f4d1fc..cc2e2721 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt @@ -29,69 +29,72 @@ import org.grakovne.lissen.R @Composable fun TrackDetailsPlaceholderComposable( - bookTitle: String, - bookSubtitle: String?, - modifier: Modifier = Modifier, + bookTitle: String, + bookSubtitle: String?, + modifier: Modifier = Modifier, ) { - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp - val maxImageHeight = screenHeight * 0.33f + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val maxImageHeight = screenHeight * 0.33f - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier, - ) { - Box( - modifier = Modifier - .heightIn(max = maxImageHeight) - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .shimmer() - .background(Color.Gray), - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Box( + modifier = + Modifier + .heightIn(max = maxImageHeight) + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .shimmer() + .background(Color.Gray), + ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = bookTitle, + style = typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onBackground, + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(2.dp)) + + bookSubtitle + ?.takeIf { it.isNotBlank() } + ?.let { Text( - text = bookTitle, - style = typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onBackground, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - modifier = Modifier.padding(horizontal = 16.dp), + text = it, + style = typography.bodyMedium, + color = colorScheme.onBackground.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(2.dp)) + } - bookSubtitle - ?.takeIf { it.isNotBlank() } - ?.let { - Text( - text = it, - style = typography.bodyMedium, - color = colorScheme.onBackground.copy(alpha = 0.6f), - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - - Spacer(modifier = Modifier.height(2.dp)) - } - - Text( - text = stringResource(R.string.player_screen_now_playing_title_chapter_of, 100, "1000"), - style = typography.bodyMedium, - color = Color.Transparent, - textAlign = TextAlign.Center, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .shimmer() - .background(Color.Gray), - ) - } + Text( + text = stringResource(R.string.player_screen_now_playing_title_chapter_of, 100, "1000"), + style = typography.bodyMedium, + color = Color.Transparent, + textAlign = TextAlign.Center, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .shimmer() + .background(Color.Gray), + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt index 05d2bd2f..a6648dd7 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/SettingsScreen.kt @@ -40,74 +40,77 @@ import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @OptIn(ExperimentalMaterial3Api::class) fun SettingsScreen( - onBack: () -> Unit, - navController: AppNavigationService, + onBack: () -> Unit, + navController: AppNavigationService, ) { - val viewModel: SettingsViewModel = hiltViewModel() - val host by viewModel.host.observeAsState("") + val viewModel: SettingsViewModel = hiltViewModel() + val host by viewModel.host.observeAsState("") - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - tint = colorScheme.onSurface, - ) - } - }, + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.settings_screen_title), + style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = colorScheme.onSurface, + ) + }, + navigationIcon = { + IconButton(onClick = { onBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = colorScheme.onSurface, ) + } }, - modifier = Modifier - .systemBarsPadding() - .fillMaxHeight(), - content = { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (host?.isNotEmpty() == true) { - ServerSettingsComposable(navController, viewModel) - } + ) + }, + modifier = + Modifier + .systemBarsPadding() + .fillMaxHeight(), + content = { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (host?.isNotEmpty() == true) { + ServerSettingsComposable(navController, viewModel) + } - ColorSchemeSettingsComposable(viewModel) + ColorSchemeSettingsComposable(viewModel) - LibraryOrderingSettingsComposable(viewModel) + LibraryOrderingSettingsComposable(viewModel) - AdvancedSettingsItemComposable( - title = stringResource(R.string.settings_screen_seek_time_title), - description = stringResource(R.string.settings_screen_seek_time_hint), - onclick = { navController.showSeekSettings() }, - ) + AdvancedSettingsItemComposable( + title = stringResource(R.string.settings_screen_seek_time_title), + description = stringResource(R.string.settings_screen_seek_time_hint), + onclick = { navController.showSeekSettings() }, + ) - AdvancedSettingsItemComposable( - title = stringResource(R.string.settings_screen_custom_headers_title), - description = stringResource(R.string.settings_screen_custom_header_hint), - onclick = { navController.showCustomHeadersSettings() }, - ) + AdvancedSettingsItemComposable( + title = stringResource(R.string.settings_screen_custom_headers_title), + description = stringResource(R.string.settings_screen_custom_header_hint), + onclick = { navController.showCustomHeadersSettings() }, + ) - GitHubLinkComposable() - } - AdditionalComposable() - } - }, - ) + GitHubLinkComposable() + } + AdditionalComposable() + } + }, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt index e844decd..6e7143f5 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt @@ -26,54 +26,56 @@ import org.grakovne.lissen.domain.connection.ServerRequestHeader.Companion.clean @Composable fun CustomHeaderComposable( - header: ServerRequestHeader, - onChanged: (ServerRequestHeader) -> Unit, - onDelete: (ServerRequestHeader) -> Unit, + header: ServerRequestHeader, + onChanged: (ServerRequestHeader) -> Unit, + onDelete: (ServerRequestHeader) -> Unit, ) { - Card( - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 16.dp), + Card( + shape = RoundedCornerShape(12.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 16.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(colorScheme.background), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(colorScheme.background), - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier.weight(1f), - ) { - OutlinedTextField( - value = header.name, - onValueChange = { onChanged(header.copy(name = it, value = header.value).clean()) }, - label = { Text(stringResource(R.string.custom_header_hint_name)) }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), - ) + Column( + modifier = Modifier.weight(1f), + ) { + OutlinedTextField( + value = header.name, + onValueChange = { onChanged(header.copy(name = it, value = header.value).clean()) }, + label = { Text(stringResource(R.string.custom_header_hint_name)) }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + ) - OutlinedTextField( - value = header.value, - onValueChange = { onChanged(header.copy(name = header.name, value = it).clean()) }, - label = { Text(stringResource(R.string.custom_header_hint_value)) }, - singleLine = true, - shape = RoundedCornerShape(16.dp), - modifier = Modifier.fillMaxWidth(), - ) - } + OutlinedTextField( + value = header.value, + onValueChange = { onChanged(header.copy(name = header.name, value = it).clean()) }, + label = { Text(stringResource(R.string.custom_header_hint_value)) }, + singleLine = true, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) + } - IconButton( - onClick = { onDelete(header) }, - ) { - Icon( - imageVector = Icons.Default.DeleteOutline, - contentDescription = "Delete from cache", - tint = colorScheme.error, - modifier = Modifier.size(32.dp), - ) - } - } + IconButton( + onClick = { onDelete(header) }, + ) { + Icon( + imageVector = Icons.Default.DeleteOutline, + contentDescription = "Delete from cache", + tint = colorScheme.error, + modifier = Modifier.size(32.dp), + ) + } } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt index dc377a58..4876da6d 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeadersSettingsScreen.kt @@ -41,110 +41,112 @@ import kotlin.math.max @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CustomHeadersSettingsScreen( - onBack: () -> Unit, -) { - val settingsViewModel: SettingsViewModel = hiltViewModel() - val headers = settingsViewModel.customHeaders.observeAsState(emptyList()) +fun CustomHeadersSettingsScreen(onBack: () -> Unit) { + val settingsViewModel: SettingsViewModel = hiltViewModel() + val headers = settingsViewModel.customHeaders.observeAsState(emptyList()) - val fabHeight = 56.dp - val additionalPadding = 16.dp + val fabHeight = 56.dp + val additionalPadding = 16.dp - val state = rememberLazyListState() - val coroutineScope = rememberCoroutineScope() + val state = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.custom_headers_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { - onBack() - }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - tint = colorScheme.onSurface, - ) - } - }, + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.custom_headers_title), + style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = colorScheme.onSurface, + ) + }, + navigationIcon = { + IconButton(onClick = { + onBack() + }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = colorScheme.onSurface, ) + } }, - modifier = Modifier - .systemBarsPadding() - .fillMaxHeight(), - content = { innerPadding -> - LazyColumn( - state = state, - contentPadding = PaddingValues( - top = innerPadding.calculateTopPadding(), - bottom = innerPadding.calculateBottomPadding() + fabHeight + additionalPadding, - ), - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val customHeaders = when (headers.value.isEmpty()) { - true -> listOf(ServerRequestHeader.empty()) - false -> headers.value - } + ) + }, + modifier = + Modifier + .systemBarsPadding() + .fillMaxHeight(), + content = { innerPadding -> + LazyColumn( + state = state, + contentPadding = + PaddingValues( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding() + fabHeight + additionalPadding, + ), + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val customHeaders = + when (headers.value.isEmpty()) { + true -> listOf(ServerRequestHeader.empty()) + false -> headers.value + } - itemsIndexed(customHeaders) { index, header -> - CustomHeaderComposable( - header = header, - onChanged = { newPair -> - val updatedList = customHeaders.toMutableList() - updatedList[index] = newPair + itemsIndexed(customHeaders) { index, header -> + CustomHeaderComposable( + header = header, + onChanged = { newPair -> + val updatedList = customHeaders.toMutableList() + updatedList[index] = newPair - settingsViewModel.updateCustomHeaders(updatedList) - }, - onDelete = { pair -> - val updatedList = customHeaders.toMutableList() - updatedList.remove(pair) + settingsViewModel.updateCustomHeaders(updatedList) + }, + onDelete = { pair -> + val updatedList = customHeaders.toMutableList() + updatedList.remove(pair) - if (updatedList.isEmpty()) { - updatedList.add(ServerRequestHeader.empty()) - } + if (updatedList.isEmpty()) { + updatedList.add(ServerRequestHeader.empty()) + } - settingsViewModel.updateCustomHeaders(updatedList) - }, - ) + settingsViewModel.updateCustomHeaders(updatedList) + }, + ) - if (index < customHeaders.size - 1) { - HorizontalDivider( - modifier = Modifier - .height(1.dp) - .padding(horizontal = 24.dp), - ) - } - } - } + if (index < customHeaders.size - 1) { + HorizontalDivider( + modifier = + Modifier + .height(1.dp) + .padding(horizontal = 24.dp), + ) + } + } + } + }, + floatingActionButtonPosition = FabPosition.Center, + floatingActionButton = { + FloatingActionButton( + containerColor = colorScheme.primary, + shape = CircleShape, + onClick = { + val updatedList = headers.value.toMutableList() + updatedList.add(ServerRequestHeader.empty()) + settingsViewModel.updateCustomHeaders(updatedList) + + coroutineScope.launch { + state.scrollToItem(max(0, updatedList.size - 1)) + } }, - floatingActionButtonPosition = FabPosition.Center, - floatingActionButton = { - FloatingActionButton( - containerColor = colorScheme.primary, - shape = CircleShape, - onClick = { - val updatedList = headers.value.toMutableList() - updatedList.add(ServerRequestHeader.empty()) - settingsViewModel.updateCustomHeaders(updatedList) - - coroutineScope.launch { - state.scrollToItem(max(0, updatedList.size - 1)) - } - }, - ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "Add", - ) - } - }, - ) + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add", + ) + } + }, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt index 021a2ae1..710c290d 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/SeekSettingsScreen.kt @@ -45,163 +45,164 @@ import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable @OptIn(ExperimentalMaterial3Api::class) -fun SeekSettingsScreen( - onBack: () -> Unit, -) { - val context = LocalContext.current - val viewModel: SettingsViewModel = hiltViewModel() +fun SeekSettingsScreen(onBack: () -> Unit) { + val context = LocalContext.current + val viewModel: SettingsViewModel = hiltViewModel() - val preferredSeekTime by viewModel.seekTime.observeAsState() - val rewindOnPause by viewModel.rewindOnPause.observeAsState(RewindOnPauseTime.Default) + val preferredSeekTime by viewModel.seekTime.observeAsState() + val rewindOnPause by viewModel.rewindOnPause.observeAsState(RewindOnPauseTime.Default) - var rewindTimeExpanded by remember { mutableStateOf(false) } - var forwardTimeExpanded by remember { mutableStateOf(false) } - var rewindOnPauseTimeExpanded by remember { mutableStateOf(false) } + var rewindTimeExpanded by remember { mutableStateOf(false) } + var forwardTimeExpanded by remember { mutableStateOf(false) } + var rewindOnPauseTimeExpanded by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = stringResource(R.string.settings_screen_seek_time_title), - style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - color = colorScheme.onSurface, - ) - }, - navigationIcon = { - IconButton(onClick = { onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = "Back", - tint = colorScheme.onSurface, - ) - } - }, + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.settings_screen_seek_time_title), + style = typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + color = colorScheme.onSurface, + ) + }, + navigationIcon = { + IconButton(onClick = { onBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = colorScheme.onSurface, ) + } }, - modifier = Modifier - .systemBarsPadding() - .fillMaxHeight(), + ) + }, + modifier = + Modifier + .systemBarsPadding() + .fillMaxHeight(), + content = { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SeekTimeOptionComposable( + title = stringResource(R.string.rewind_interval), + currentOption = preferredSeekTime?.rewind ?: SeekTime.Default.rewind, + enabled = true, + ) { rewindTimeExpanded = true } - content = { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SeekTimeOptionComposable( - title = stringResource(R.string.rewind_interval), - currentOption = preferredSeekTime?.rewind ?: SeekTime.Default.rewind, - enabled = true, - ) { rewindTimeExpanded = true } + SeekTimeOptionComposable( + title = stringResource(R.string.forward_interval), + currentOption = preferredSeekTime?.forward ?: SeekTime.Default.forward, + enabled = true, + ) { forwardTimeExpanded = true } + } + } + }, + ) - SeekTimeOptionComposable( - title = stringResource(R.string.forward_interval), - currentOption = preferredSeekTime?.forward ?: SeekTime.Default.forward, - enabled = true, - ) { forwardTimeExpanded = true } - } - } - }, + if (rewindTimeExpanded) { + CommonSettingsItemComposable( + items = SeekTimeOption.entries.map { it.toSettingsItem(context) }, + selectedItem = preferredSeekTime?.rewind?.toSettingsItem(context), + onDismissRequest = { rewindTimeExpanded = false }, + onItemSelected = { item -> + SeekTimeOption + .entries + .find { it.name == item.id } + ?.let { viewModel.preferRewindRewind(it) } + }, ) + } - if (rewindTimeExpanded) { - CommonSettingsItemComposable( - items = SeekTimeOption.entries.map { it.toSettingsItem(context) }, - selectedItem = preferredSeekTime?.rewind?.toSettingsItem(context), - onDismissRequest = { rewindTimeExpanded = false }, - onItemSelected = { item -> - SeekTimeOption - .entries - .find { it.name == item.id } - ?.let { viewModel.preferRewindRewind(it) } - }, - ) - } + if (forwardTimeExpanded) { + CommonSettingsItemComposable( + items = SeekTimeOption.entries.map { it.toSettingsItem(context) }, + selectedItem = preferredSeekTime?.forward?.toSettingsItem(context), + onDismissRequest = { forwardTimeExpanded = false }, + onItemSelected = { item -> + SeekTimeOption + .entries + .find { it.name == item.id } + ?.let { viewModel.preferForwardRewind(it) } + }, + ) + } - if (forwardTimeExpanded) { - CommonSettingsItemComposable( - items = SeekTimeOption.entries.map { it.toSettingsItem(context) }, - selectedItem = preferredSeekTime?.forward?.toSettingsItem(context), - onDismissRequest = { forwardTimeExpanded = false }, - onItemSelected = { item -> - SeekTimeOption - .entries - .find { it.name == item.id } - ?.let { viewModel.preferForwardRewind(it) } - }, - ) - } - - if (rewindOnPauseTimeExpanded) { - CommonSettingsItemComposable( - items = SeekTimeOption.entries.map { it.toSettingsItem(context) }, - selectedItem = rewindOnPause?.time?.toSettingsItem(context), - onDismissRequest = { rewindOnPauseTimeExpanded = false }, - onItemSelected = { item -> - SeekTimeOption - .entries - .find { it.name == item.id } - ?.let { viewModel.preferRewindTimeOnPause(it) } - }, - ) - } + if (rewindOnPauseTimeExpanded) { + CommonSettingsItemComposable( + items = SeekTimeOption.entries.map { it.toSettingsItem(context) }, + selectedItem = rewindOnPause?.time?.toSettingsItem(context), + onDismissRequest = { rewindOnPauseTimeExpanded = false }, + onItemSelected = { item -> + SeekTimeOption + .entries + .find { it.name == item.id } + ?.let { viewModel.preferRewindTimeOnPause(it) } + }, + ) + } } @Composable fun SeekTimeOptionComposable( - enabled: Boolean, - title: String, - currentOption: SeekTimeOption, - onClicked: () -> Unit, + enabled: Boolean, + title: String, + currentOption: SeekTimeOption, + onClicked: () -> Unit, ) { - val textColor = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.6f) - val context = LocalContext.current + val textColor = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.6f) + val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = enabled) { onClicked() } - .padding(horizontal = 24.dp, vertical = 12.dp), + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onClicked() } + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Column( + modifier = Modifier.weight(1f), ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = title, - style = typography.bodyLarge.copy( - fontWeight = FontWeight.SemiBold, - color = textColor, - ), - modifier = Modifier.padding(bottom = 4.dp), - ) - Text( - text = currentOption.toItem(context), - style = typography.bodyMedium.copy( - color = textColor, - ), - ) - } + Text( + text = title, + style = + typography.bodyLarge.copy( + fontWeight = FontWeight.SemiBold, + color = textColor, + ), + modifier = Modifier.padding(bottom = 4.dp), + ) + Text( + text = currentOption.toItem(context), + style = + typography.bodyMedium.copy( + color = textColor, + ), + ) } + } } -private fun SeekTimeOption.toSettingsItem(context: Context): CommonSettingsItem = - CommonSettingsItem(this.name, this.toItem(context), null) +private fun SeekTimeOption.toSettingsItem(context: Context): CommonSettingsItem = CommonSettingsItem(this.name, this.toItem(context), null) -private fun SeekTimeOption.toItem(context: Context): String { - return when (this) { - SeekTimeOption.SEEK_5 -> context.getString(R.string.seek_interval_5_seconds) - SeekTimeOption.SEEK_10 -> context.getString(R.string.seek_interval_10_seconds) - SeekTimeOption.SEEK_15 -> context.getString(R.string.seek_interval_15_seconds) - SeekTimeOption.SEEK_30 -> context.getString(R.string.seek_interval_30_seconds) - SeekTimeOption.SEEK_60 -> context.getString(R.string.seek_interval_60_seconds) - } -} +private fun SeekTimeOption.toItem(context: Context): String = + when (this) { + SeekTimeOption.SEEK_5 -> context.getString(R.string.seek_interval_5_seconds) + SeekTimeOption.SEEK_10 -> context.getString(R.string.seek_interval_10_seconds) + SeekTimeOption.SEEK_15 -> context.getString(R.string.seek_interval_15_seconds) + SeekTimeOption.SEEK_30 -> context.getString(R.string.seek_interval_30_seconds) + SeekTimeOption.SEEK_60 -> context.getString(R.string.seek_interval_60_seconds) + } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdditionalComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdditionalComposable.kt index 24bf691b..1448c6e1 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdditionalComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdditionalComposable.kt @@ -17,37 +17,42 @@ import org.grakovne.lissen.BuildConfig @Composable fun AdditionalComposable() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 12.dp), - color = colorScheme.onSurface.copy(alpha = 0.2f), - ) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = colorScheme.onSurface.copy(alpha = 0.2f), + ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - .align(Alignment.CenterHorizontally), - text = "Lissen ${BuildConfig.VERSION_NAME}", - style = TextStyle( - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - ), - ) - Text( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .align(Alignment.CenterHorizontally), - text = "© 2024-2025 Max Grakov. MIT License", - style = TextStyle( - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.Center, - ), - ) - } + Text( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .align(Alignment.CenterHorizontally), + text = "Lissen ${BuildConfig.VERSION_NAME}", + style = + TextStyle( + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + ), + ) + Text( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .align(Alignment.CenterHorizontally), + text = "© 2024-2025 Max Grakov. MIT License", + style = + TextStyle( + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + ), + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt index 496f8c5b..b09f775c 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt @@ -22,40 +22,41 @@ import androidx.compose.ui.unit.dp @Composable fun AdvancedSettingsItemComposable( - title: String, - description: String, - onclick: () -> Unit, + title: String, + description: String, + onclick: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onclick() } - .padding(start = 24.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 4.dp), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = description, - style = typography.bodyMedium, - color = colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton( - onClick = { onclick() }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, - contentDescription = "Logout", - ) - } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onclick() } + .padding(start = 24.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = description, + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } + IconButton( + onClick = { onclick() }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = "Logout", + ) + } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ColorSchemeSettingsComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ColorSchemeSettingsComposable.kt index 02910c62..d0702d5f 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ColorSchemeSettingsComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ColorSchemeSettingsComposable.kt @@ -25,63 +25,64 @@ import org.grakovne.lissen.common.ColorScheme import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable -fun ColorSchemeSettingsComposable( - viewModel: SettingsViewModel, -) { - val context = LocalContext.current - var colorSchemeExpanded by remember { mutableStateOf(false) } - val preferredColorScheme by viewModel.preferredColorScheme.observeAsState() +fun ColorSchemeSettingsComposable(viewModel: SettingsViewModel) { + val context = LocalContext.current + var colorSchemeExpanded by remember { mutableStateOf(false) } + val preferredColorScheme by viewModel.preferredColorScheme.observeAsState() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { colorSchemeExpanded = true } - .padding(horizontal = 24.dp, vertical = 12.dp), + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { colorSchemeExpanded = true } + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Column( + modifier = Modifier.weight(1f), ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = stringResource(R.string.settings_screen_color_scheme_title), - style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 4.dp), - ) - Text( - text = preferredColorScheme?.toItem(context)?.name ?: "", - style = typography.bodyMedium, - color = colorScheme.onSurfaceVariant, - ) - } + Text( + text = stringResource(R.string.settings_screen_color_scheme_title), + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 4.dp), + ) + Text( + text = preferredColorScheme?.toItem(context)?.name ?: "", + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) } + } - if (colorSchemeExpanded) { - CommonSettingsItemComposable( - items = listOf( - ColorScheme.FOLLOW_SYSTEM.toItem(context), - ColorScheme.LIGHT.toItem(context), - ColorScheme.DARK.toItem(context), - ColorScheme.BLACK.toItem(context), - ), - selectedItem = preferredColorScheme?.toItem(context), - onDismissRequest = { colorSchemeExpanded = false }, - onItemSelected = { item -> - ColorScheme - .entries - .find { it.name == item.id } - ?.let { viewModel.preferColorScheme(it) } - }, - ) - } + if (colorSchemeExpanded) { + CommonSettingsItemComposable( + items = + listOf( + ColorScheme.FOLLOW_SYSTEM.toItem(context), + ColorScheme.LIGHT.toItem(context), + ColorScheme.DARK.toItem(context), + ColorScheme.BLACK.toItem(context), + ), + selectedItem = preferredColorScheme?.toItem(context), + onDismissRequest = { colorSchemeExpanded = false }, + onItemSelected = { item -> + ColorScheme + .entries + .find { it.name == item.id } + ?.let { viewModel.preferColorScheme(it) } + }, + ) + } } private fun ColorScheme.toItem(context: Context): CommonSettingsItem { - val id = this.name - val name = when (this) { - ColorScheme.FOLLOW_SYSTEM -> context.getString(R.string.color_scheme_follow_system) - ColorScheme.LIGHT -> context.getString(R.string.color_scheme_light) - ColorScheme.DARK -> context.getString(R.string.color_scheme_dark) - ColorScheme.BLACK -> context.getString(R.string.color_scheme_black) + val id = this.name + val name = + when (this) { + ColorScheme.FOLLOW_SYSTEM -> context.getString(R.string.color_scheme_follow_system) + ColorScheme.LIGHT -> context.getString(R.string.color_scheme_light) + ColorScheme.DARK -> context.getString(R.string.color_scheme_dark) + ColorScheme.BLACK -> context.getString(R.string.color_scheme_black) } - return CommonSettingsItem(id, name, null) + return CommonSettingsItem(id, name, null) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItem.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItem.kt index 089c46c6..44a5004c 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItem.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItem.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.graphics.vector.ImageVector @Keep data class CommonSettingsItem( - val id: String, - val name: String, - val icon: ImageVector?, + val id: String, + val name: String, + val icon: ImageVector?, ) diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt index 325aed14..77d9d98f 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItemComposable.kt @@ -32,66 +32,68 @@ import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun CommonSettingsItemComposable( - items: List, - selectedItem: CommonSettingsItem?, - onDismissRequest: () -> Unit, - onItemSelected: (CommonSettingsItem) -> Unit, - selectedImage: ImageVector = Icons.Outlined.Check, + items: List, + selectedItem: CommonSettingsItem?, + onDismissRequest: () -> Unit, + onItemSelected: (CommonSettingsItem) -> Unit, + selectedImage: ImageVector = Icons.Outlined.Check, ) { - var activeItem by remember { mutableStateOf(selectedItem) } + var activeItem by remember { mutableStateOf(selectedItem) } - ModalBottomSheet( - containerColor = MaterialTheme.colorScheme.background, - onDismissRequest = onDismissRequest, - content = { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .padding(horizontal = 16.dp), - ) { - Spacer(modifier = Modifier.height(8.dp)) + ModalBottomSheet( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) - LazyColumn(modifier = Modifier.fillMaxWidth()) { - itemsIndexed(items) { index, item -> - ListItem( - leadingContent = { - item.icon?.let { - Icon( - imageVector = it, - contentDescription = "Settings Item Icon", - modifier = Modifier.size(24.dp), - ) - } - }, - headlineContent = { - Row { Text(item.name) } - }, - trailingContent = { - if (item.id == activeItem?.id) { - Icon( - imageVector = selectedImage, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - activeItem = item - onItemSelected(item) - }, - ) - if (index < items.lastIndex) { - HorizontalDivider() - } - } + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(items) { index, item -> + ListItem( + leadingContent = { + item.icon?.let { + Icon( + imageVector = it, + contentDescription = "Settings Item Icon", + modifier = Modifier.size(24.dp), + ) } + }, + headlineContent = { + Row { Text(item.name) } + }, + trailingContent = { + if (item.id == activeItem?.id) { + Icon( + imageVector = selectedImage, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + activeItem = item + onItemSelected(item) + }, + ) + if (index < items.lastIndex) { + HorizontalDivider() } - }, - ) + } + } + } + }, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GitHubLinkComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GitHubLinkComposable.kt index 2ece8a5d..6c01c708 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GitHubLinkComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GitHubLinkComposable.kt @@ -19,30 +19,31 @@ import org.grakovne.lissen.R @Composable fun GitHubLinkComposable() { - val uriHandler = LocalUriHandler.current + val uriHandler = LocalUriHandler.current - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { uriHandler.openUri("https://github.com/GrakovNe/lissen-android") } - .padding(horizontal = 24.dp, vertical = 12.dp), + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { uriHandler.openUri("https://github.com/GrakovNe/lissen-android") } + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Column( + modifier = Modifier.weight(1f), ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = stringResource(R.string.source_code_on_github_title), - style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 4.dp), - ) - Text( - text = stringResource(R.string.source_code_on_github_subtitle), - style = typography.bodyMedium, - color = colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.padding(bottom = 4.dp), - overflow = TextOverflow.Ellipsis, - ) - } + Text( + text = stringResource(R.string.source_code_on_github_title), + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 4.dp), + ) + Text( + text = stringResource(R.string.source_code_on_github_subtitle), + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.padding(bottom = 4.dp), + overflow = TextOverflow.Ellipsis, + ) } + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/LibraryOrderingSettingsComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/LibraryOrderingSettingsComposable.kt index 380fd330..989d4b41 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/LibraryOrderingSettingsComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/LibraryOrderingSettingsComposable.kt @@ -32,99 +32,107 @@ import org.grakovne.lissen.common.LibraryOrderingOption import org.grakovne.lissen.viewmodel.SettingsViewModel @Composable -fun LibraryOrderingSettingsComposable( - viewModel: SettingsViewModel, -) { - val context = LocalContext.current - var libraryOrderingExpanded by remember { mutableStateOf(false) } +fun LibraryOrderingSettingsComposable(viewModel: SettingsViewModel) { + val context = LocalContext.current + var libraryOrderingExpanded by remember { mutableStateOf(false) } - val configuration by viewModel - .preferredLibraryOrdering - .observeAsState(LibraryOrderingConfiguration.default) + val configuration by viewModel + .preferredLibraryOrdering + .observeAsState(LibraryOrderingConfiguration.default) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { libraryOrderingExpanded = true } - .padding(horizontal = 24.dp, vertical = 12.dp), + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { libraryOrderingExpanded = true } + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Column( + modifier = Modifier.weight(1f), ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = stringResource(R.string.settings_screen_library_ordering_title), - style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 4.dp), - ) - Text( - text = configuration.option.toItem(context).name ?: "", - style = typography.bodyMedium, - color = colorScheme.onSurfaceVariant, - ) - } + Text( + text = stringResource(R.string.settings_screen_library_ordering_title), + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 4.dp), + ) + Text( + text = configuration.option.toItem(context).name ?: "", + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) } + } - if (libraryOrderingExpanded) { - CommonSettingsItemComposable( - items = listOf( - LibraryOrderingOption.TITLE.toItem(context), - LibraryOrderingOption.AUTHOR.toItem(context), - LibraryOrderingOption.CREATED_AT.toItem(context), - LibraryOrderingOption.UPDATED_AT.toItem(context), - ), - selectedItem = configuration.option.toItem(context), - onDismissRequest = { libraryOrderingExpanded = false }, - onItemSelected = { item -> - LibraryOrderingOption - .entries - .find { it.name == item.id } - ?.let { - viewModel - .preferLibraryOrdering( - LibraryOrderingConfiguration( - option = it, - direction = provideOrderingDirection( - currentConfiguration = configuration, - selectedOption = it, - ), - ), - ) - } - }, - selectedImage = provideSelectedImage(configuration), - ) - } + if (libraryOrderingExpanded) { + CommonSettingsItemComposable( + items = + listOf( + LibraryOrderingOption.TITLE.toItem(context), + LibraryOrderingOption.AUTHOR.toItem(context), + LibraryOrderingOption.CREATED_AT.toItem(context), + LibraryOrderingOption.UPDATED_AT.toItem(context), + ), + selectedItem = configuration.option.toItem(context), + onDismissRequest = { libraryOrderingExpanded = false }, + onItemSelected = { item -> + LibraryOrderingOption + .entries + .find { it.name == item.id } + ?.let { + viewModel + .preferLibraryOrdering( + LibraryOrderingConfiguration( + option = it, + direction = + provideOrderingDirection( + currentConfiguration = configuration, + selectedOption = it, + ), + ), + ) + } + }, + selectedImage = provideSelectedImage(configuration), + ) + } } private fun provideOrderingDirection( - currentConfiguration: LibraryOrderingConfiguration, - selectedOption: LibraryOrderingOption, + currentConfiguration: LibraryOrderingConfiguration, + selectedOption: LibraryOrderingOption, ): LibraryOrderingDirection { - if (currentConfiguration.option != selectedOption) { - return ASCENDING - } + if (currentConfiguration.option != selectedOption) { + return ASCENDING + } - return when (currentConfiguration.direction) { - ASCENDING -> DESCENDING - DESCENDING -> ASCENDING - } + return when (currentConfiguration.direction) { + ASCENDING -> DESCENDING + DESCENDING -> ASCENDING + } } private fun provideSelectedImage(configuration: LibraryOrderingConfiguration) = - when (configuration.direction) { - ASCENDING -> Icons.Outlined.ArrowUpward - DESCENDING -> Icons.Outlined.ArrowDownward - } + when (configuration.direction) { + ASCENDING -> Icons.Outlined.ArrowUpward + DESCENDING -> Icons.Outlined.ArrowDownward + } private fun LibraryOrderingOption.toItem(context: Context): CommonSettingsItem { - val id = this.name + val id = this.name - val name = when (this) { - LibraryOrderingOption.TITLE -> context.getString(R.string.settings_screen_library_ordering_title_option) - LibraryOrderingOption.AUTHOR -> context.getString(R.string.settings_screen_library_ordering_author_option) - LibraryOrderingOption.CREATED_AT -> context.getString(R.string.settings_screen_library_ordering_creation_date_option) - LibraryOrderingOption.UPDATED_AT -> context.getString(R.string.settings_screen_library_ordering_modification_date_option) + val name = + when (this) { + LibraryOrderingOption.TITLE -> context.getString(R.string.settings_screen_library_ordering_title_option) + LibraryOrderingOption.AUTHOR -> context.getString(R.string.settings_screen_library_ordering_author_option) + LibraryOrderingOption.CREATED_AT -> + context.getString( + R.string.settings_screen_library_ordering_creation_date_option, + ) + LibraryOrderingOption.UPDATED_AT -> + context.getString( + R.string.settings_screen_library_ordering_modification_date_option, + ) } - return CommonSettingsItem(id, name, null) + return CommonSettingsItem(id, name, null) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt index 7db21ef0..55a70b8a 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ServerSettingsComposable.kt @@ -36,91 +36,96 @@ import org.grakovne.lissen.viewmodel.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerSettingsComposable( - navController: AppNavigationService, - viewModel: SettingsViewModel, + navController: AppNavigationService, + viewModel: SettingsViewModel, ) { - var connectionInfoExpanded by remember { mutableStateOf(false) } + var connectionInfoExpanded by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - viewModel.refreshConnectionInfo() + LaunchedEffect(Unit) { + viewModel.refreshConnectionInfo() + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { connectionInfoExpanded = true } + .padding(start = 24.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_screen_server_connection), + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 4.dp), + ) + + Text( + text = "${viewModel.host.value}", + style = typography.bodyMedium, + maxLines = 1, + modifier = Modifier.padding(bottom = 4.dp), + overflow = TextOverflow.Ellipsis, + ) } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { connectionInfoExpanded = true } - .padding(start = 24.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, + IconButton( + onClick = { + navController.showLogin() + viewModel.logout() + }, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.settings_screen_server_connection), - style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 4.dp), - ) + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Logout", + ) + } + } - Text( - text = "${viewModel.host.value}", - style = typography.bodyMedium, - maxLines = 1, - modifier = Modifier.padding(bottom = 4.dp), - overflow = TextOverflow.Ellipsis, - ) - } - IconButton( - onClick = { - navController.showLogin() - viewModel.logout() - }, + if (connectionInfoExpanded) { + ModalBottomSheet( + containerColor = MaterialTheme.colorScheme.background, + onDismissRequest = { connectionInfoExpanded = false }, + content = { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp), ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = "Logout", + viewModel.username.value?.let { + InfoRow( + label = stringResource(R.string.settings_screen_connected_as_title), + value = it, ) + HorizontalDivider() + } + viewModel.serverVersion.value?.let { + InfoRow( + label = stringResource(R.string.settings_screen_server_version), + value = it, + ) + } } - } - - if (connectionInfoExpanded) { - ModalBottomSheet( - containerColor = MaterialTheme.colorScheme.background, - onDismissRequest = { connectionInfoExpanded = false }, - content = { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .padding(horizontal = 16.dp), - ) { - viewModel.username.value?.let { - InfoRow( - label = stringResource(R.string.settings_screen_connected_as_title), - value = it, - ) - HorizontalDivider() - } - viewModel.serverVersion.value?.let { - InfoRow( - label = stringResource(R.string.settings_screen_server_version), - value = it, - ) - } - } - }, - ) - } + }, + ) + } } @Composable -fun InfoRow(label: String, value: String) { - ListItem( - headlineContent = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(text = label) - Text(text = value) - } - }, - ) +fun InfoRow( + label: String, + value: String, +) { + ListItem( + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label) + Text(text = value) + } + }, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/SettingsToggleItem.kt b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/SettingsToggleItem.kt index 2d0d5c15..e0c6dff0 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/SettingsToggleItem.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/SettingsToggleItem.kt @@ -18,42 +18,44 @@ import androidx.compose.ui.unit.dp @Composable fun SettingsToggleItem( - title: String, - description: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), ) { - Column( - modifier = Modifier.weight(1f), - ) { - Text( - text = title, - style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(bottom = 2.dp), - ) - Text( - text = description, - style = typography.bodyMedium, - color = colorScheme.onSurfaceVariant, - ) - } - - Switch( - checked = checked, - onCheckedChange = null, - colors = SwitchDefaults.colors( - uncheckedTrackColor = colorScheme.background, - checkedBorderColor = colorScheme.onSurface, - checkedThumbColor = colorScheme.onSurface, - checkedTrackColor = colorScheme.background, - ), - ) + Text( + text = title, + style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 2.dp), + ) + Text( + text = description, + style = typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) } + + Switch( + checked = checked, + onCheckedChange = null, + colors = + SwitchDefaults.colors( + uncheckedTrackColor = colorScheme.background, + checkedBorderColor = colorScheme.onSurface, + checkedThumbColor = colorScheme.onSurface, + checkedTrackColor = colorScheme.background, + ), + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/ui/theme/Theme.kt b/app/src/main/java/org/grakovne/lissen/ui/theme/Theme.kt index 5a4af710..217f05ab 100644 --- a/app/src/main/java/org/grakovne/lissen/ui/theme/Theme.kt +++ b/app/src/main/java/org/grakovne/lissen/ui/theme/Theme.kt @@ -12,7 +12,8 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat import org.grakovne.lissen.common.ColorScheme -private val LightColorScheme = lightColorScheme( +private val LightColorScheme = + lightColorScheme( primary = FoxOrange, secondary = Dark, tertiary = FoxOrange, @@ -20,54 +21,58 @@ private val LightColorScheme = lightColorScheme( background = LightBackground, surface = LightBackground, surfaceContainer = Color(0xFFEEEEEE), -) + ) -private val DarkColorScheme = darkColorScheme( +private val DarkColorScheme = + darkColorScheme( primary = FoxOrangeDimmed, tertiaryContainer = Color(0xFF1A1A1A), -) + ) -private val BlackColorScheme = darkColorScheme( +private val BlackColorScheme = + darkColorScheme( primary = FoxOrangeDimmed, background = Black, surface = Black, tertiaryContainer = Black, -) + ) @Composable fun LissenTheme( - colorSchemePreference: ColorScheme, - content: @Composable () -> Unit, + colorSchemePreference: ColorScheme, + content: @Composable () -> Unit, ) { - val view = LocalView.current - val window = (view.context as? Activity)?.window + val view = LocalView.current + val window = (view.context as? Activity)?.window - val isDarkTheme = when (colorSchemePreference) { - ColorScheme.FOLLOW_SYSTEM -> isSystemInDarkTheme() - ColorScheme.LIGHT -> false - ColorScheme.DARK -> true - ColorScheme.BLACK -> true + val isDarkTheme = + when (colorSchemePreference) { + ColorScheme.FOLLOW_SYSTEM -> isSystemInDarkTheme() + ColorScheme.LIGHT -> false + ColorScheme.DARK -> true + ColorScheme.BLACK -> true } - SideEffect { - window?.let { - WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = !isDarkTheme + SideEffect { + window?.let { + WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = !isDarkTheme + } + } + + val colors = + when (isDarkTheme) { + true -> { + if (colorSchemePreference == ColorScheme.BLACK) { + BlackColorScheme + } else { + DarkColorScheme } + } + false -> LightColorScheme } - val colors = when (isDarkTheme) { - true -> { - if (colorSchemePreference == ColorScheme.BLACK) { - BlackColorScheme - } else { - DarkColorScheme - } - } - false -> LightColorScheme - } - - MaterialTheme( - colorScheme = colors, - content = content, - ) + MaterialTheme( + colorScheme = colors, + content = content, + ) } diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/CachingModelView.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/CachingModelView.kt index 3f16c1f1..a965cc79 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/CachingModelView.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/CachingModelView.kt @@ -21,64 +21,68 @@ import java.io.Serializable import javax.inject.Inject @HiltViewModel -class CachingModelView @Inject constructor( +class CachingModelView + @Inject + constructor( @ApplicationContext private val context: Context, private val contentCachingProgress: ContentCachingProgress, private val contentCachingManager: ContentCachingManager, private val preferences: LissenSharedPreferences, -) : ViewModel() { - + ) : ViewModel() { private val _bookCachingProgress = mutableMapOf>() init { - viewModelScope.launch { - contentCachingProgress.statusFlow.collect { (item, progress) -> - val flow = _bookCachingProgress.getOrPut(item.id) { - MutableStateFlow(progress) - } - flow.value = progress + viewModelScope.launch { + contentCachingProgress.statusFlow.collect { (item, progress) -> + val flow = + _bookCachingProgress.getOrPut(item.id) { + MutableStateFlow(progress) } + flow.value = progress } + } } fun cache( - mediaItemId: String, - currentPosition: Double, - option: DownloadOption, + mediaItemId: String, + currentPosition: Double, + option: DownloadOption, ) { - val task = ContentCachingTask( - itemId = mediaItemId, - options = option, - currentPosition = currentPosition, + val task = + ContentCachingTask( + itemId = mediaItemId, + options = option, + currentPosition = currentPosition, ) - val intent = Intent(context, ContentCachingService::class.java).apply { - putExtra(ContentCachingService.CACHING_TASK_EXTRA, task as Serializable) + val intent = + Intent(context, ContentCachingService::class.java).apply { + putExtra(ContentCachingService.CACHING_TASK_EXTRA, task as Serializable) } - context.startForegroundService(intent) + context.startForegroundService(intent) } - fun getProgress(bookId: String) = _bookCachingProgress + fun getProgress(bookId: String) = + _bookCachingProgress .getOrPut(bookId) { MutableStateFlow(CacheState(CacheStatus.Idle)) } fun dropCache(bookId: String) { - viewModelScope - .launch { - contentCachingManager.dropCache(bookId) - _bookCachingProgress.remove(bookId) - } + viewModelScope + .launch { + contentCachingManager.dropCache(bookId) + _bookCachingProgress.remove(bookId) + } } fun toggleCacheForce() { - when (localCacheUsing()) { - true -> preferences.disableForceCache() - false -> preferences.enableForceCache() - } + when (localCacheUsing()) { + true -> preferences.disableForceCache() + false -> preferences.enableForceCache() + } } fun localCacheUsing() = preferences.isForceCache() - fun provideCacheState(bookId: String): LiveData = - contentCachingManager.hasMetadataCached(bookId) -} + fun provideCacheState(bookId: String): LiveData = contentCachingManager.hasMetadataCached(bookId) + } diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/LibraryViewModel.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/LibraryViewModel.kt index 9782a2cf..d6fbc950 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/LibraryViewModel.kt @@ -30,11 +30,12 @@ import javax.inject.Inject @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) -class LibraryViewModel @Inject constructor( +class LibraryViewModel + @Inject + constructor( private val mediaChannel: LissenMediaProvider, private val preferences: LissenSharedPreferences, -) : ViewModel() { - + ) : ViewModel() { private val _recentBooks = MutableLiveData>(emptyList()) val recentBooks: LiveData> = _recentBooks @@ -49,113 +50,118 @@ class LibraryViewModel @Inject constructor( private var defaultPagingSource: PagingSource? = null private var searchPagingSource: PagingSource? = null - private val pageConfig = PagingConfig( + private val pageConfig = + PagingConfig( pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE, prefetchDistance = PAGE_SIZE, - ) + ) - val searchPager: Flow> = combine( + val searchPager: Flow> = + combine( _searchToken, searchRequested.asFlow(), - ) { token, requested -> + ) { token, requested -> Pair(token, requested) - }.flatMapLatest { (token, _) -> + }.flatMapLatest { (token, _) -> Pager( - config = pageConfig, - pagingSourceFactory = { - val source = LibrarySearchPagingSource( - preferences = preferences, - mediaChannel = mediaChannel, - searchToken = token, - limit = PAGE_SIZE, - ) + config = pageConfig, + pagingSourceFactory = { + val source = + LibrarySearchPagingSource( + preferences = preferences, + mediaChannel = mediaChannel, + searchToken = token, + limit = PAGE_SIZE, + ) - searchPagingSource = source - source - }, + searchPagingSource = source + source + }, ).flow - }.cachedIn(viewModelScope) + }.cachedIn(viewModelScope) val libraryPager: Flow> by lazy { - Pager( - config = pageConfig, - pagingSourceFactory = { - val source = LibraryDefaultPagingSource(preferences, mediaChannel) - defaultPagingSource = source + Pager( + config = pageConfig, + pagingSourceFactory = { + val source = LibraryDefaultPagingSource(preferences, mediaChannel) + defaultPagingSource = source - source - }, - ).flow.cachedIn(viewModelScope) + source + }, + ).flow.cachedIn(viewModelScope) } fun requestSearch() { - _searchRequested.postValue(true) + _searchRequested.postValue(true) } fun dismissSearch() { - _searchRequested.postValue(false) - _searchToken.value = EMPTY_SEARCH + _searchRequested.postValue(false) + _searchToken.value = EMPTY_SEARCH } fun updateSearch(token: String) { - viewModelScope.launch { _searchToken.emit(token) } + viewModelScope.launch { _searchToken.emit(token) } } - fun fetchPreferredLibraryTitle(): String? = preferences + fun fetchPreferredLibraryTitle(): String? = + preferences .getPreferredLibrary() ?.title - fun fetchPreferredLibraryType() = preferences + fun fetchPreferredLibraryType() = + preferences .getPreferredLibrary() ?.type ?: LibraryType.UNKNOWN fun refreshRecentListening() { - viewModelScope.launch { - withContext(Dispatchers.IO) { - fetchRecentListening() - } + viewModelScope.launch { + withContext(Dispatchers.IO) { + fetchRecentListening() } + } } fun refreshLibrary() { - viewModelScope.launch { - withContext(Dispatchers.IO) { - when (searchRequested.value) { - true -> searchPagingSource?.invalidate() - else -> defaultPagingSource?.invalidate() - } - } + viewModelScope.launch { + withContext(Dispatchers.IO) { + when (searchRequested.value) { + true -> searchPagingSource?.invalidate() + else -> defaultPagingSource?.invalidate() + } } + } } fun fetchRecentListening() { - _recentBookUpdating.postValue(true) + _recentBookUpdating.postValue(true) - val preferredLibrary = preferences.getPreferredLibrary()?.id ?: run { - _recentBookUpdating.postValue(false) - return + val preferredLibrary = + preferences.getPreferredLibrary()?.id ?: run { + _recentBookUpdating.postValue(false) + return } - viewModelScope.launch { - mediaChannel - .fetchRecentListenedBooks(preferredLibrary) - .fold( - onSuccess = { - _recentBooks.postValue(it) - _recentBookUpdating.postValue(false) - }, - onFailure = { - _recentBookUpdating.postValue(false) - }, - ) - } + viewModelScope.launch { + mediaChannel + .fetchRecentListenedBooks(preferredLibrary) + .fold( + onSuccess = { + _recentBooks.postValue(it) + _recentBookUpdating.postValue(false) + }, + onFailure = { + _recentBookUpdating.postValue(false) + }, + ) + } } companion object { - - private const val EMPTY_SEARCH = "" - private const val PAGE_SIZE = 20 + private const val EMPTY_SEARCH = "" + private const val PAGE_SIZE = 20 } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/LoginViewModel.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/LoginViewModel.kt index c6319fca..f3bb41e1 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/LoginViewModel.kt @@ -18,10 +18,12 @@ import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import javax.inject.Inject @HiltViewModel -class LoginViewModel @Inject constructor( +class LoginViewModel + @Inject + constructor( preferences: LissenSharedPreferences, private val mediaChannel: LissenMediaProvider, -) : ViewModel() { + ) : ViewModel() { private val _host = MutableLiveData(preferences.getHost() ?: "") val host = _host @@ -38,93 +40,103 @@ class LoginViewModel @Inject constructor( val authMethods = _authMethods fun updateAuthMethods() { - viewModelScope - .launch { - val value = host.value ?: return@launch + viewModelScope + .launch { + val value = host.value ?: return@launch - mediaChannel - .provideAuthService() - .fetchAuthMethods(host = value) - .fold( - onSuccess = { _authMethods.value = it }, - onFailure = { _authMethods.value = emptyList() }, - ) - } - } - - fun setHost(host: String) { - _host.value = host - } - - fun setUsername(username: String) { - _username.value = username - } - - fun setPassword(password: String) { - _password.value = password - } - - fun readyToLogin() { - _loginState.value = LoginState.Idle - } - - fun startOAuth() { - viewModelScope.launch { - _loginState.value = LoginState.Loading - - val host = host.value ?: run { - _loginState.value = LoginState.Error(MissingCredentialsHost) - return@launch - } - - mediaChannel.startOAuth( - host = host, - onSuccess = { _loginState.value = LoginState.Idle }, - onFailure = { onLoginFailure(it) }, + mediaChannel + .provideAuthService() + .fetchAuthMethods(host = value) + .fold( + onSuccess = { _authMethods.value = it }, + onFailure = { _authMethods.value = emptyList() }, ) } } + fun setHost(host: String) { + _host.value = host + } + + fun setUsername(username: String) { + _username.value = username + } + + fun setPassword(password: String) { + _password.value = password + } + + fun readyToLogin() { + _loginState.value = LoginState.Idle + } + + fun startOAuth() { + viewModelScope.launch { + _loginState.value = LoginState.Loading + + val host = + host.value ?: run { + _loginState.value = LoginState.Error(MissingCredentialsHost) + return@launch + } + + mediaChannel.startOAuth( + host = host, + onSuccess = { _loginState.value = LoginState.Idle }, + onFailure = { onLoginFailure(it) }, + ) + } + } + fun login() { - viewModelScope.launch { - _loginState.value = LoginState.Loading + viewModelScope.launch { + _loginState.value = LoginState.Loading - val host = host.value ?: run { - _loginState.value = LoginState.Error(MissingCredentialsHost) - return@launch - } + val host = + host.value ?: run { + _loginState.value = LoginState.Error(MissingCredentialsHost) + return@launch + } - val username = username.value ?: run { - _loginState.value = LoginState.Error(MissingCredentialsUsername) - return@launch - } + val username = + username.value ?: run { + _loginState.value = LoginState.Error(MissingCredentialsUsername) + return@launch + } - val password = password.value ?: run { - _loginState.value = LoginState.Error(MissingCredentialsPassword) - return@launch - } + val password = + password.value ?: run { + _loginState.value = LoginState.Error(MissingCredentialsPassword) + return@launch + } - val result = mediaChannel - .authorize(host, username, password) - .foldAsync( - onSuccess = { _ -> LoginState.Success }, - onFailure = { error -> onLoginFailure(error.code) }, - ) - _loginState.value = result - } + val result = + mediaChannel + .authorize(host, username, password) + .foldAsync( + onSuccess = { _ -> LoginState.Success }, + onFailure = { error -> onLoginFailure(error.code) }, + ) + _loginState.value = result + } } private fun onLoginFailure(error: ApiError): LoginState.Error { - viewModelScope.launch { - _loginState.value = LoginState.Error(error) - } - return LoginState.Error(error) + viewModelScope.launch { + _loginState.value = LoginState.Error(error) + } + return LoginState.Error(error) } sealed class LoginState { - data object Idle : LoginState() - data object Loading : LoginState() - data object Success : LoginState() - data class Error(val message: ApiError) : LoginState() + data object Idle : LoginState() + + data object Loading : LoginState() + + data object Success : LoginState() + + data class Error( + val message: ApiError, + ) : LoginState() } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt index ab92f767..ce4d6de4 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/PlayerViewModel.kt @@ -14,11 +14,12 @@ import org.grakovne.lissen.playback.MediaRepository import javax.inject.Inject @HiltViewModel -class PlayerViewModel @Inject constructor( +class PlayerViewModel + @Inject + constructor( private val mediaRepository: MediaRepository, private val preferences: LissenSharedPreferences, -) : ViewModel() { - + ) : ViewModel() { val book: LiveData = mediaRepository.playingBook val currentChapterIndex: LiveData = mediaRepository.currentChapterIndex @@ -45,68 +46,68 @@ class PlayerViewModel @Inject constructor( val isPlaying: LiveData = mediaRepository.isPlaying fun recoverMiniPlayer() { - val playingBook = preferences.getPlayingBook() + val playingBook = preferences.getPlayingBook() - if (playingBook?.id != null && book.value == null) { - viewModelScope.launch { - mediaRepository.preparePlayback(playingBook.id) - } + if (playingBook?.id != null && book.value == null) { + viewModelScope.launch { + mediaRepository.preparePlayback(playingBook.id) } + } } fun expandPlayingQueue() { - _playingQueueExpanded.postValue(true) + _playingQueueExpanded.postValue(true) } fun setTimer(option: TimerOption?) { - mediaRepository.updateTimer(option) + mediaRepository.updateTimer(option) } fun collapsePlayingQueue() { - _playingQueueExpanded.postValue(false) + _playingQueueExpanded.postValue(false) } fun togglePlayingQueue() { - _playingQueueExpanded.postValue(!(_playingQueueExpanded.value ?: false)) + _playingQueueExpanded.postValue(!(_playingQueueExpanded.value ?: false)) } fun requestSearch() { - _searchRequested.postValue(true) + _searchRequested.postValue(true) } fun dismissSearch() { - _searchRequested.postValue(false) - _searchToken.postValue(EMPTY_SEARCH) + _searchRequested.postValue(false) + _searchToken.postValue(EMPTY_SEARCH) } fun updateSearch(token: String) { - _searchToken.postValue(token) + _searchToken.postValue(token) } fun preparePlayback(bookId: String) { - viewModelScope.launch { - mediaRepository.clearPreparedItem() - mediaRepository.preparePlayback(bookId) - } + viewModelScope.launch { + mediaRepository.clearPreparedItem() + mediaRepository.preparePlayback(bookId) + } } fun rewind() { - mediaRepository.rewind() + mediaRepository.rewind() } fun forward() { - mediaRepository.forward() + mediaRepository.forward() } fun seekTo(chapterPosition: Double) { - mediaRepository.setChapterPosition(chapterPosition) + mediaRepository.setChapterPosition(chapterPosition) } fun setChapter(chapter: PlayingChapter) { - if (chapter.available) { - val index = book.value?.chapters?.indexOf(chapter) ?: -1 - mediaRepository.setChapter(index) - } + if (chapter.available) { + val index = book.value?.chapters?.indexOf(chapter) ?: -1 + mediaRepository.setChapter(index) + } } fun clearPlayingBook() = mediaRepository.clearPlayingBook() @@ -120,12 +121,11 @@ class PlayerViewModel @Inject constructor( fun togglePlayPause() = mediaRepository.togglePlayPause() fun prepareAndPlay() { - val playingBook = preferences.getPlayingBook() ?: return - mediaRepository.prepareAndPlay(playingBook, false) + val playingBook = preferences.getPlayingBook() ?: return + mediaRepository.prepareAndPlay(playingBook, false) } companion object { - - private const val EMPTY_SEARCH = "" + private const val EMPTY_SEARCH = "" } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt index f704923b..f43917ef 100644 --- a/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/org/grakovne/lissen/viewmodel/SettingsViewModel.kt @@ -18,11 +18,12 @@ import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences import javax.inject.Inject @HiltViewModel -class SettingsViewModel @Inject constructor( +class SettingsViewModel + @Inject + constructor( private val mediaChannel: LissenMediaProvider, private val preferences: LissenSharedPreferences, -) : ViewModel() { - + ) : ViewModel() { private val _host = MutableLiveData(preferences.getHost()) val host = _host @@ -54,116 +55,113 @@ class SettingsViewModel @Inject constructor( val rewindOnPause = _rewindOnPause fun logout() { - preferences.clearPreferences() + preferences.clearPreferences() } fun refreshConnectionInfo() { - viewModelScope.launch { - when (val response = mediaChannel.fetchConnectionInfo()) { - is ApiResult.Error -> Unit - is ApiResult.Success -> { - _username.postValue(response.data.username) - _serverVersion.postValue(response.data.serverVersion) + viewModelScope.launch { + when (val response = mediaChannel.fetchConnectionInfo()) { + is ApiResult.Error -> Unit + is ApiResult.Success -> { + _username.postValue(response.data.username) + _serverVersion.postValue(response.data.serverVersion) - updateServerInfo() - } - } + updateServerInfo() + } } + } } fun fetchLibraries() { - viewModelScope.launch { - when (val response = mediaChannel.fetchLibraries()) { - is ApiResult.Success -> { - val libraries = response.data - _libraries.postValue(libraries) + viewModelScope.launch { + when (val response = mediaChannel.fetchLibraries()) { + is ApiResult.Success -> { + val libraries = response.data + _libraries.postValue(libraries) - val preferredLibrary = preferences.getPreferredLibrary() + val preferredLibrary = preferences.getPreferredLibrary() - _preferredLibrary.postValue( - when (preferredLibrary) { - null -> libraries.firstOrNull() - else -> libraries.find { it.id == preferredLibrary.id } - }, - ) - } + _preferredLibrary.postValue( + when (preferredLibrary) { + null -> libraries.firstOrNull() + else -> libraries.find { it.id == preferredLibrary.id } + }, + ) + } - is ApiResult.Error -> { - _libraries.postValue(preferences.getPreferredLibrary()?.let { listOf(it) }) - } - } + is ApiResult.Error -> { + _libraries.postValue(preferences.getPreferredLibrary()?.let { listOf(it) }) + } } + } } - fun fetchPreferredLibraryId(): String { - return preferences.getPreferredLibrary()?.id ?: "" - } + fun fetchPreferredLibraryId(): String = preferences.getPreferredLibrary()?.id ?: "" - fun fetchLibraryOrdering(): LibraryOrderingConfiguration { - return preferences.getLibraryOrdering() - } + fun fetchLibraryOrdering(): LibraryOrderingConfiguration = preferences.getLibraryOrdering() fun preferLibrary(library: Library) { - _preferredLibrary.postValue(library) - preferences.savePreferredLibrary(library) + _preferredLibrary.postValue(library) + preferences.savePreferredLibrary(library) } fun preferLibraryOrdering(configuration: LibraryOrderingConfiguration) { - _preferredLibraryOrdering.postValue(configuration) - preferences.saveLibraryOrdering(configuration) + _preferredLibraryOrdering.postValue(configuration) + preferences.saveLibraryOrdering(configuration) } fun preferColorScheme(colorScheme: ColorScheme) { - _preferredColorScheme.postValue(colorScheme) - preferences.saveColorScheme(colorScheme) + _preferredColorScheme.postValue(colorScheme) + preferences.saveColorScheme(colorScheme) } fun preferForwardRewind(option: SeekTimeOption) { - val current = _seekTime.value ?: return - val updated = current.copy(forward = option) + val current = _seekTime.value ?: return + val updated = current.copy(forward = option) - preferences.saveSeekTime(updated) - _seekTime.postValue(updated) + preferences.saveSeekTime(updated) + _seekTime.postValue(updated) } fun preferRewindRewind(option: SeekTimeOption) { - val current = _seekTime.value ?: return - val updated = current.copy(rewind = option) + val current = _seekTime.value ?: return + val updated = current.copy(rewind = option) - preferences.saveSeekTime(updated) - _seekTime.postValue(updated) + preferences.saveSeekTime(updated) + _seekTime.postValue(updated) } fun preferRewindOnPause(value: Boolean) { - val current = _rewindOnPause.value ?: return - val updated = current.copy(enabled = value) + val current = _rewindOnPause.value ?: return + val updated = current.copy(enabled = value) - preferences.saveRewindOnPause(updated) - _rewindOnPause.value = updated + preferences.saveRewindOnPause(updated) + _rewindOnPause.value = updated } fun preferRewindTimeOnPause(option: SeekTimeOption) { - val current = _rewindOnPause.value ?: return - val updated = current.copy(time = option) + val current = _rewindOnPause.value ?: return + val updated = current.copy(time = option) - preferences.saveRewindOnPause(updated) - _rewindOnPause.value = updated + preferences.saveRewindOnPause(updated) + _rewindOnPause.value = updated } fun updateCustomHeaders(headers: List) { - _customHeaders.postValue(headers) + _customHeaders.postValue(headers) - val meaningfulHeaders = headers - .map { it.clean() } - .distinctBy { it.name } - .filterNot { it.name.isEmpty() } - .filterNot { it.value.isEmpty() } + val meaningfulHeaders = + headers + .map { it.clean() } + .distinctBy { it.name } + .filterNot { it.name.isEmpty() } + .filterNot { it.value.isEmpty() } - preferences.saveCustomHeaders(meaningfulHeaders) + preferences.saveCustomHeaders(meaningfulHeaders) } private fun updateServerInfo() { - serverVersion.value?.let { preferences.saveServerVersion(it) } - username.value?.let { preferences.saveUsername(it) } + serverVersion.value?.let { preferences.saveServerVersion(it) } + username.value?.let { preferences.saveUsername(it) } } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidget.kt b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidget.kt index 514d90a4..80c6a67f 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidget.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidget.kt @@ -52,282 +52,297 @@ import org.grakovne.lissen.ui.theme.LightBackground import org.grakovne.lissen.widget.PlayerWidget.Companion.bookIdKey class PlayerWidget : GlanceAppWidget() { + override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition - override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + override suspend fun provideGlance( + context: Context, + id: GlanceId, + ) { + provideContent { + GlanceTheme( + colors = + ColorProviders( + light = + lightColorScheme( + background = LightBackground, + ), + dark = darkColorScheme(), + ), + ) { + val preferences = + EntryPointAccessors + .fromApplication(context, WidgetPreferencesEntryPoint::class.java) + .lissenSharedPreferences() - override suspend fun provideGlance(context: Context, id: GlanceId) { - provideContent { - GlanceTheme( - colors = ColorProviders( - light = lightColorScheme( - background = LightBackground, - ), - dark = darkColorScheme(), - ), - ) { - val preferences = EntryPointAccessors - .fromApplication(context, WidgetPreferencesEntryPoint::class.java) - .lissenSharedPreferences() - - val state = currentState() - val maybeCover = state[encodedCover]?.takeIf { it.isNotBlank() }?.fromBase64() - val bookId = state[bookId] ?: "" - val bookTitle = state[title] ?: "" - val chapterTitle = state[chapterTitle] - ?.takeIf { it.isNotBlank() } - ?: when (bookId) { - "" -> context.getString(org.grakovne.lissen.R.string.widget_placeholder_text) - else -> "" - } - - val isPlaying = state[isPlaying] ?: false - - Column( - modifier = GlanceModifier - .fillMaxWidth() - .background(GlanceTheme.colors.background) - .padding(16.dp) - .safelyRunOnClick(context), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = GlanceModifier - .fillMaxWidth() - .padding(bottom = 16.dp), - ) { - val cover = maybeCover - ?: decodeResource(context.resources, drawable.cover_fallback_png) - - val coverImageProvider = ImageProvider(cover) - - Image( - contentScale = ContentScale.FillBounds, - provider = coverImageProvider, - contentDescription = null, - modifier = GlanceModifier - .size(80.dp) - .cornerRadius(8.dp), - ) - - Column( - modifier = GlanceModifier - .fillMaxWidth() - .padding(start = 20.dp), - ) { - Text( - text = chapterTitle, - style = TextStyle( - fontFamily = SansSerif, - fontSize = 20.sp, - color = GlanceTheme.colors.onBackground, - ), - maxLines = 2, - modifier = GlanceModifier.padding(bottom = 8.dp), - ) - - Text( - text = bookTitle, - style = TextStyle( - fontFamily = SansSerif, - fontSize = 14.sp, - color = GlanceTheme.colors.onBackground, - ), - maxLines = 1, - ) - } - } - - Spacer( - modifier = GlanceModifier - .fillMaxWidth() - .height(1.dp) - .background(Color(0xFFDADADA)), - ) - - Row( - modifier = GlanceModifier - .padding(top = 16.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - WidgetControlButton( - size = 36.dp, - icon = ImageProvider(R.drawable.media3_icon_previous), - contentColor = GlanceTheme.colors.onBackground, - onClick = actionRunCallback( - actionParametersOf(bookIdKey to bookId), - ), - modifier = GlanceModifier.defaultWeight(), - ) - - WidgetControlButton( - size = 36.dp, - icon = ImageProvider(provideRewindIcon(preferences.getSeekTime().rewind)), - contentColor = GlanceTheme.colors.onBackground, - onClick = actionRunCallback( - actionParametersOf(bookIdKey to bookId), - ), - modifier = GlanceModifier.defaultWeight(), - ) - - WidgetControlButton( - icon = if (isPlaying) { - ImageProvider(R.drawable.media3_icon_pause) - } else { - ImageProvider(R.drawable.media3_icon_play) - }, - size = 48.dp, - contentColor = GlanceTheme.colors.onBackground, - onClick = actionRunCallback( - actionParametersOf(bookIdKey to bookId), - ), - modifier = GlanceModifier.defaultWeight(), - ) - - WidgetControlButton( - icon = ImageProvider(provideForwardIcon(preferences.getSeekTime().forward)), - size = 36.dp, - contentColor = GlanceTheme.colors.onBackground, - onClick = actionRunCallback( - actionParametersOf(bookIdKey to bookId), - ), - modifier = GlanceModifier.defaultWeight(), - ) - - WidgetControlButton( - icon = ImageProvider(R.drawable.media3_icon_next), - size = 36.dp, - contentColor = GlanceTheme.colors.onBackground, - onClick = actionRunCallback( - actionParametersOf(bookIdKey to bookId), - ), - modifier = GlanceModifier.defaultWeight(), - ) - } - } + val state = currentState() + val maybeCover = state[encodedCover]?.takeIf { it.isNotBlank() }?.fromBase64() + val bookId = state[bookId] ?: "" + val bookTitle = state[title] ?: "" + val chapterTitle = + state[chapterTitle] + ?.takeIf { it.isNotBlank() } + ?: when (bookId) { + "" -> context.getString(org.grakovne.lissen.R.string.widget_placeholder_text) + else -> "" } + + val isPlaying = state[isPlaying] ?: false + + Column( + modifier = + GlanceModifier + .fillMaxWidth() + .background(GlanceTheme.colors.background) + .padding(16.dp) + .safelyRunOnClick(context), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + GlanceModifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + val cover = + maybeCover + ?: decodeResource(context.resources, drawable.cover_fallback_png) + + val coverImageProvider = ImageProvider(cover) + + Image( + contentScale = ContentScale.FillBounds, + provider = coverImageProvider, + contentDescription = null, + modifier = + GlanceModifier + .size(80.dp) + .cornerRadius(8.dp), + ) + + Column( + modifier = + GlanceModifier + .fillMaxWidth() + .padding(start = 20.dp), + ) { + Text( + text = chapterTitle, + style = + TextStyle( + fontFamily = SansSerif, + fontSize = 20.sp, + color = GlanceTheme.colors.onBackground, + ), + maxLines = 2, + modifier = GlanceModifier.padding(bottom = 8.dp), + ) + + Text( + text = bookTitle, + style = + TextStyle( + fontFamily = SansSerif, + fontSize = 14.sp, + color = GlanceTheme.colors.onBackground, + ), + maxLines = 1, + ) + } + } + + Spacer( + modifier = + GlanceModifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0xFFDADADA)), + ) + + Row( + modifier = + GlanceModifier + .padding(top = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + WidgetControlButton( + size = 36.dp, + icon = ImageProvider(R.drawable.media3_icon_previous), + contentColor = GlanceTheme.colors.onBackground, + onClick = + actionRunCallback( + actionParametersOf(bookIdKey to bookId), + ), + modifier = GlanceModifier.defaultWeight(), + ) + + WidgetControlButton( + size = 36.dp, + icon = ImageProvider(provideRewindIcon(preferences.getSeekTime().rewind)), + contentColor = GlanceTheme.colors.onBackground, + onClick = + actionRunCallback( + actionParametersOf(bookIdKey to bookId), + ), + modifier = GlanceModifier.defaultWeight(), + ) + + WidgetControlButton( + icon = + if (isPlaying) { + ImageProvider(R.drawable.media3_icon_pause) + } else { + ImageProvider(R.drawable.media3_icon_play) + }, + size = 48.dp, + contentColor = GlanceTheme.colors.onBackground, + onClick = + actionRunCallback( + actionParametersOf(bookIdKey to bookId), + ), + modifier = GlanceModifier.defaultWeight(), + ) + + WidgetControlButton( + icon = ImageProvider(provideForwardIcon(preferences.getSeekTime().forward)), + size = 36.dp, + contentColor = GlanceTheme.colors.onBackground, + onClick = + actionRunCallback( + actionParametersOf(bookIdKey to bookId), + ), + modifier = GlanceModifier.defaultWeight(), + ) + + WidgetControlButton( + icon = ImageProvider(R.drawable.media3_icon_next), + size = 36.dp, + contentColor = GlanceTheme.colors.onBackground, + onClick = + actionRunCallback( + actionParametersOf(bookIdKey to bookId), + ), + modifier = GlanceModifier.defaultWeight(), + ) + } } + } } + } - private fun GlanceModifier.safelyRunOnClick( - context: Context, - ) = provideAppLaunchIntent(context) - ?.let { intent -> this.clickable(onClick = actionStartActivity(intent)) } - ?: this + private fun GlanceModifier.safelyRunOnClick(context: Context) = + provideAppLaunchIntent(context) + ?.let { intent -> this.clickable(onClick = actionStartActivity(intent)) } + ?: this - companion object { + companion object { + fun provideRewindIcon(option: SeekTimeOption): Int = R.drawable.media3_icon_rewind - fun provideRewindIcon(option: SeekTimeOption): Int = R.drawable.media3_icon_rewind + fun provideForwardIcon(option: SeekTimeOption): Int = R.drawable.media3_icon_fast_forward - fun provideForwardIcon(option: SeekTimeOption): Int = R.drawable.media3_icon_fast_forward + val bookIdKey = ActionParameters.Key("book_id") - val bookIdKey = ActionParameters.Key("book_id") + val encodedCover = stringPreferencesKey("player_widget_key_cover") + val bookId = stringPreferencesKey("player_widget_key_id") + val title = stringPreferencesKey("player_widget_key_title") + val chapterTitle = stringPreferencesKey("player_widget_key_chapter_title") - val encodedCover = stringPreferencesKey("player_widget_key_cover") - val bookId = stringPreferencesKey("player_widget_key_id") - val title = stringPreferencesKey("player_widget_key_title") - val chapterTitle = stringPreferencesKey("player_widget_key_chapter_title") - - val isPlaying = booleanPreferencesKey("player_widget_key_is_playing") - } + val isPlaying = booleanPreferencesKey("player_widget_key_is_playing") + } } class PlayToggleActionCallback : ActionCallback { - - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters, - ) { - safelyRun( - playingItemId = parameters[bookIdKey] ?: return, - context = context, - ) { it.togglePlayPause() } - } + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters, + ) { + safelyRun( + playingItemId = parameters[bookIdKey] ?: return, + context = context, + ) { it.togglePlayPause() } + } } class ForwardActionCallback : ActionCallback { - - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters, - ) { - safelyRun( - playingItemId = parameters[bookIdKey] ?: return, - context = context, - ) { it.forward() } - } + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters, + ) { + safelyRun( + playingItemId = parameters[bookIdKey] ?: return, + context = context, + ) { it.forward() } + } } class RewindActionCallback : ActionCallback { - - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters, - ) { - safelyRun( - playingItemId = parameters[bookIdKey] ?: return, - context = context, - ) { it.rewind() } - } + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters, + ) { + safelyRun( + playingItemId = parameters[bookIdKey] ?: return, + context = context, + ) { it.rewind() } + } } class NextChapterActionCallback : ActionCallback { - - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters, - ) { - safelyRun( - playingItemId = parameters[bookIdKey] ?: return, - context = context, - ) { it.nextTrack() } - } + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters, + ) { + safelyRun( + playingItemId = parameters[bookIdKey] ?: return, + context = context, + ) { it.nextTrack() } + } } class PreviousChapterActionCallback : ActionCallback { - - override suspend fun onAction( - context: Context, - glanceId: GlanceId, - parameters: ActionParameters, - ) { - safelyRun( - playingItemId = parameters[bookIdKey] ?: return, - context = context, - ) { it.previousTrack() } - } + override suspend fun onAction( + context: Context, + glanceId: GlanceId, + parameters: ActionParameters, + ) { + safelyRun( + playingItemId = parameters[bookIdKey] ?: return, + context = context, + ) { it.previousTrack() } + } } -private fun provideAppLaunchIntent(context: Context): Intent? = context +private fun provideAppLaunchIntent(context: Context): Intent? = + context .packageManager .getLaunchIntentForPackage(context.packageName) ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP } private suspend fun safelyRun( - playingItemId: String, - context: Context, - action: (WidgetPlaybackController) -> Unit, + playingItemId: String, + context: Context, + action: (WidgetPlaybackController) -> Unit, ) { - try { - val playbackController = EntryPointAccessors - .fromApplication( - context = context.applicationContext, - entryPoint = WidgetPlaybackControllerEntryPoint::class.java, - ) - .widgetPlaybackController() + try { + val playbackController = + EntryPointAccessors + .fromApplication( + context = context.applicationContext, + entryPoint = WidgetPlaybackControllerEntryPoint::class.java, + ).widgetPlaybackController() - when (playbackController.providePlayingItem()) { - null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) } - else -> action(playbackController) - } - } catch (ex: Exception) { - Log.w(TAG, "Unable to run $action on $playingItemId due to $ex") + when (playbackController.providePlayingItem()) { + null -> playbackController.prepareAndRun(playingItemId) { action(playbackController) } + else -> action(playbackController) } + } catch (ex: Exception) { + Log.w(TAG, "Unable to run $action on $playingItemId due to $ex") + } } private const val TAG = "PlayerWidget" diff --git a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetModule.kt b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetModule.kt index 3cc7a24e..59859253 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetModule.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetModule.kt @@ -10,8 +10,7 @@ import org.grakovne.lissen.common.RunningComponent @Module @InstallIn(SingletonComponent::class) interface PlayerWidgetModule { - - @Binds - @IntoSet - fun bindPlayerWidgetStateService(service: PlayerWidgetStateService): RunningComponent + @Binds + @IntoSet + fun bindPlayerWidgetStateService(service: PlayerWidgetStateService): RunningComponent } diff --git a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetReceiver.kt b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetReceiver.kt index 729e6f85..b0294d99 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetReceiver.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetReceiver.kt @@ -4,6 +4,5 @@ import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver class PlayerWidgetReceiver : GlanceAppWidgetReceiver() { - - override val glanceAppWidget: GlanceAppWidget = PlayerWidget() + override val glanceAppWidget: GlanceAppWidget = PlayerWidget() } diff --git a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetStateService.kt b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetStateService.kt index 04065d1f..1c00bf68 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetStateService.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetStateService.kt @@ -21,102 +21,111 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class PlayerWidgetStateService @Inject constructor( +class PlayerWidgetStateService + @Inject + constructor( @ApplicationContext private val context: Context, private val mediaRepository: MediaRepository, private val mediaProvider: LissenMediaProvider, -) : RunningComponent { - + ) : RunningComponent { private var playingBookId: String? = null private var cachedCover: ByteArray? = null private val scope = CoroutineScope(Dispatchers.IO) override fun onCreate() { - scope.launch { - combine( - mediaRepository.playingBook.asFlow().distinctUntilChanged(), - mediaRepository.isPlaying.asFlow().filterNotNull().distinctUntilChanged(), - mediaRepository.currentChapterIndex.asFlow().distinctUntilChanged(), - ) { book: DetailedItem?, isPlaying, chapterIndex: Int? -> - val chapterTitle = provideChapterTitle(book, chapterIndex) + scope.launch { + combine( + mediaRepository.playingBook.asFlow().distinctUntilChanged(), + mediaRepository.isPlaying + .asFlow() + .filterNotNull() + .distinctUntilChanged(), + mediaRepository.currentChapterIndex.asFlow().distinctUntilChanged(), + ) { book: DetailedItem?, isPlaying, chapterIndex: Int? -> + val chapterTitle = provideChapterTitle(book, chapterIndex) - val maybeCover = when (book) { - null -> null - else -> when { - playingBookId != book.id || cachedCover == null -> { - mediaProvider.fetchBookCover(book.id) - .fold( - onSuccess = { inputStream -> - inputStream.use { stream -> - val buffer = ByteArray(8192) - val output = ByteArrayOutputStream() - var bytesRead: Int - while (stream.read(buffer).also { bytesRead = it } != -1) { - output.write(buffer, 0, bytesRead) - } - output.toByteArray() - }.also { cover -> - cachedCover = cover - playingBookId = book.id - } - }, - onFailure = { null }, - ) - } + val maybeCover = + when (book) { + null -> null + else -> + when { + playingBookId != book.id || cachedCover == null -> { + mediaProvider + .fetchBookCover(book.id) + .fold( + onSuccess = { inputStream -> + inputStream + .use { stream -> + val buffer = ByteArray(8192) + val output = ByteArrayOutputStream() + var bytesRead: Int + while (stream.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + } + output.toByteArray() + }.also { cover -> + cachedCover = cover + playingBookId = book.id + } + }, + onFailure = { null }, + ) + } - else -> cachedCover - } + else -> cachedCover } - - PlayingItemState( - id = book?.id ?: "", - title = book?.title ?: "", - chapterTitle = chapterTitle, - isPlaying = isPlaying, - imageCover = maybeCover, - ) - }.collect { playingItemState -> - updatePlayingItem(playingItemState) } + + PlayingItemState( + id = book?.id ?: "", + title = book?.title ?: "", + chapterTitle = chapterTitle, + isPlaying = isPlaying, + imageCover = maybeCover, + ) + }.collect { playingItemState -> + updatePlayingItem(playingItemState) } + } } - private fun provideChapterTitle(book: DetailedItem?, chapterIndex: Int?): String? { - if (null == book || null == chapterIndex) { - return null - } + private fun provideChapterTitle( + book: DetailedItem?, + chapterIndex: Int?, + ): String? { + if (null == book || null == chapterIndex) { + return null + } - return when (chapterIndex in book.chapters.indices) { - true -> book.chapters[chapterIndex].title - false -> book.title - } + return when (chapterIndex in book.chapters.indices) { + true -> book.chapters[chapterIndex].title + false -> book.title + } } - private suspend fun updatePlayingItem( - state: PlayingItemState, - ) { - val manager = GlanceAppWidgetManager(context) - val glanceIds = manager.getGlanceIds(PlayerWidget::class.java) + private suspend fun updatePlayingItem(state: PlayingItemState) { + val manager = GlanceAppWidgetManager(context) + val glanceIds = manager.getGlanceIds(PlayerWidget::class.java) - glanceIds - .forEach { glanceId -> - updateAppWidgetState(context, glanceId) { prefs -> - prefs[PlayerWidget.bookId] = state.id - prefs[PlayerWidget.encodedCover] = state.imageCover?.toBase64() ?: "" - prefs[PlayerWidget.title] = state.title - prefs[PlayerWidget.chapterTitle] = state.chapterTitle ?: "" - prefs[PlayerWidget.isPlaying] = state.isPlaying - } - PlayerWidget().update(context, glanceId) - } + glanceIds + .forEach { glanceId -> + updateAppWidgetState(context, glanceId) { prefs -> + prefs[PlayerWidget.bookId] = state.id + prefs[PlayerWidget.encodedCover] = state.imageCover?.toBase64() ?: "" + prefs[PlayerWidget.title] = state.title + prefs[PlayerWidget.chapterTitle] = state.chapterTitle ?: "" + prefs[PlayerWidget.isPlaying] = state.isPlaying + } + PlayerWidget().update(context, glanceId) + } } -} + } data class PlayingItemState( - val id: String, - val title: String, - val chapterTitle: String?, - val isPlaying: Boolean = false, - val imageCover: ByteArray?, + val id: String, + val title: String, + val chapterTitle: String?, + val isPlaying: Boolean = false, + val imageCover: ByteArray?, ) diff --git a/app/src/main/java/org/grakovne/lissen/widget/WidgetControlButton.kt b/app/src/main/java/org/grakovne/lissen/widget/WidgetControlButton.kt index 3a0d51f5..0e633d97 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/WidgetControlButton.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/WidgetControlButton.kt @@ -17,25 +17,26 @@ import androidx.glance.unit.ColorProvider @Composable fun WidgetControlButton( - icon: ImageProvider, - contentColor: ColorProvider, - onClick: Action, - modifier: GlanceModifier, - size: Dp, + icon: ImageProvider, + contentColor: ColorProvider, + onClick: Action, + modifier: GlanceModifier, + size: Dp, ) { - Row( - modifier = modifier, - verticalAlignment = Alignment.Vertical.CenterVertically, - horizontalAlignment = Alignment.Horizontal.CenterHorizontally, - ) { - Image( - provider = icon, - contentDescription = null, - colorFilter = ColorFilter.tint(contentColor), - modifier = GlanceModifier - .size(size) - .cornerRadius(16.dp) - .clickable(onClick = onClick), - ) - } + Row( + modifier = modifier, + verticalAlignment = Alignment.Vertical.CenterVertically, + horizontalAlignment = Alignment.Horizontal.CenterHorizontally, + ) { + Image( + provider = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = + GlanceModifier + .size(size) + .cornerRadius(16.dp) + .clickable(onClick = onClick), + ) + } } diff --git a/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackController.kt b/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackController.kt index 8a7980a1..492e7b3e 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackController.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackController.kt @@ -17,34 +17,39 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class WidgetPlaybackController @Inject constructor( +class WidgetPlaybackController + @Inject + constructor( @ApplicationContext private val context: Context, private val mediaRepository: MediaRepository, -) { - + ) { private var playbackReadyAction: () -> Unit = {} - private val bookDetailsReadyReceiver = object : BroadcastReceiver() { + private val bookDetailsReadyReceiver = + object : BroadcastReceiver() { @Suppress("DEPRECATION") - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == PLAYBACK_READY) { - val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + if (intent?.action == PLAYBACK_READY) { + val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem - book?.let { - CoroutineScope(Dispatchers.Main).launch { - playbackReadyAction - .invoke() - .also { playbackReadyAction = { } } - } - } + book?.let { + CoroutineScope(Dispatchers.Main).launch { + playbackReadyAction + .invoke() + .also { playbackReadyAction = { } } + } } + } } - } + } init { - LocalBroadcastManager - .getInstance(context) - .registerReceiver(bookDetailsReadyReceiver, IntentFilter(PLAYBACK_READY)) + LocalBroadcastManager + .getInstance(context) + .registerReceiver(bookDetailsReadyReceiver, IntentFilter(PLAYBACK_READY)) } fun providePlayingItem() = mediaRepository.playingBook.value @@ -60,10 +65,10 @@ class WidgetPlaybackController @Inject constructor( fun forward() = mediaRepository.forward() suspend fun prepareAndRun( - itemId: String, - onPlaybackReady: () -> Unit, + itemId: String, + onPlaybackReady: () -> Unit, ) { - playbackReadyAction = onPlaybackReady - mediaRepository.preparePlayback(bookId = itemId, fromBackground = true) + playbackReadyAction = onPlaybackReady + mediaRepository.preparePlayback(bookId = itemId, fromBackground = true) } -} + } diff --git a/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackControllerEntryPoint.kt b/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackControllerEntryPoint.kt index 06aa9aa1..33401e75 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackControllerEntryPoint.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackControllerEntryPoint.kt @@ -7,5 +7,5 @@ import dagger.hilt.components.SingletonComponent @EntryPoint @InstallIn(SingletonComponent::class) interface WidgetPlaybackControllerEntryPoint { - fun widgetPlaybackController(): WidgetPlaybackController + fun widgetPlaybackController(): WidgetPlaybackController } diff --git a/app/src/main/java/org/grakovne/lissen/widget/WidgetPreferencesEntryPoint.kt b/app/src/main/java/org/grakovne/lissen/widget/WidgetPreferencesEntryPoint.kt index a1ce6994..4cd5678f 100644 --- a/app/src/main/java/org/grakovne/lissen/widget/WidgetPreferencesEntryPoint.kt +++ b/app/src/main/java/org/grakovne/lissen/widget/WidgetPreferencesEntryPoint.kt @@ -8,5 +8,5 @@ import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences @EntryPoint @InstallIn(SingletonComponent::class) interface WidgetPreferencesEntryPoint { - fun lissenSharedPreferences(): LissenSharedPreferences + fun lissenSharedPreferences(): LissenSharedPreferences } diff --git a/build.gradle.kts b/build.gradle.kts index 289dbedc..2f45d973 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false id("com.google.dagger.hilt.android") version "2.56.2" apply false - id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false + id("com.google.devtools.ksp") version "2.1.20-2.0.1" apply false alias(libs.plugins.compose.compiler) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fdcbe47b..2fd73d80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,11 +8,11 @@ converterGson = "2.11.0" glance = "1.1.1" hiltAndroid = "2.56.2" hiltNavigationCompose = "1.2.0" -kotlin = "2.0.21" +kotlin = "2.1.20" activityCompose = "1.10.1" composeBom = "2025.04.01" compose = "1.7.8" -loggingInterceptor = "4.11.0" +loggingInterceptor = "4.12.0" material = "1.8.0" material3 = "1.3.2" materialVersion = "1.12.0" @@ -73,4 +73,3 @@ androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-s compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -