├── .github └── workflows │ └── build_app.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .editorconfig ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── debug │ └── res │ │ └── values │ │ └── strings.xml │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ └── org │ │ └── grakovne │ │ └── lissen │ │ ├── LissenApplication.kt │ │ ├── channel │ │ ├── audiobookshelf │ │ │ ├── AudiobookshelfChannelProvider.kt │ │ │ ├── common │ │ │ │ ├── AudiobookshelfChannel.kt │ │ │ │ ├── UnknownAudiobookshelfChannel.kt │ │ │ │ ├── api │ │ │ │ │ ├── ApiClientConfig.kt │ │ │ │ │ ├── AudioBookshelfDataRepository.kt │ │ │ │ │ ├── AudioBookshelfMediaRepository.kt │ │ │ │ │ ├── AudioBookshelfSyncService.kt │ │ │ │ │ ├── AudiobookshelfAuthService.kt │ │ │ │ │ ├── RequestHeadersProvider.kt │ │ │ │ │ ├── SafeApiCall.kt │ │ │ │ │ ├── library │ │ │ │ │ │ └── AudioBookshelfLibrarySyncService.kt │ │ │ │ │ └── podcast │ │ │ │ │ │ └── AudioBookshelfPodcastSyncService.kt │ │ │ │ ├── client │ │ │ │ │ ├── AudiobookshelfApiClient.kt │ │ │ │ │ └── AudiobookshelfMediaClient.kt │ │ │ │ ├── converter │ │ │ │ │ ├── AuthMethodResponseConverter.kt │ │ │ │ │ ├── ConnectionInfoResponseConverter.kt │ │ │ │ │ ├── LibraryPageResponseConverter.kt │ │ │ │ │ ├── LibraryResponseConverter.kt │ │ │ │ │ ├── LoginResponseConverter.kt │ │ │ │ │ ├── PlaybackSessionResponseConverter.kt │ │ │ │ │ └── RecentListeningResponseConverter.kt │ │ │ │ ├── model │ │ │ │ │ ├── MediaProgressResponse.kt │ │ │ │ │ ├── auth │ │ │ │ │ │ └── AuthMethodResponse.kt │ │ │ │ │ ├── connection │ │ │ │ │ │ └── ConnectionInfoResponse.kt │ │ │ │ │ ├── metadata │ │ │ │ │ │ ├── AuthorItemsResponse.kt │ │ │ │ │ │ └── LibraryResponse.kt │ │ │ │ │ ├── playback │ │ │ │ │ │ ├── PlaybackSessionResponse.kt │ │ │ │ │ │ ├── PlaybackStartRequest.kt │ │ │ │ │ │ └── ProgressSyncRequest.kt │ │ │ │ │ └── user │ │ │ │ │ │ ├── CredentialsLoginRequest.kt │ │ │ │ │ │ ├── LoggedUserResponse.kt │ │ │ │ │ │ ├── PersonalizedFeedResponse.kt │ │ │ │ │ │ └── UserInfoResponse.kt │ │ │ │ └── oauth │ │ │ │ │ ├── AudiobookshelfOAuthCallbackActivity.kt │ │ │ │ │ └── OAuthScheme.kt │ │ │ ├── library │ │ │ │ ├── LibraryAudiobookshelfChannel.kt │ │ │ │ ├── converter │ │ │ │ │ ├── BookResponseConverter.kt │ │ │ │ │ ├── LibraryOrderingRequestConverter.kt │ │ │ │ │ └── LibrarySearchItemsConverter.kt │ │ │ │ └── model │ │ │ │ │ ├── BookResponse.kt │ │ │ │ │ ├── LibraryItemsResponse.kt │ │ │ │ │ └── LibrarySearchResponse.kt │ │ │ └── podcast │ │ │ │ ├── PodcastAudiobookshelfChannel.kt │ │ │ │ ├── converter │ │ │ │ ├── PodcastOrderingRequestConverter.kt │ │ │ │ ├── PodcastPageResponseConverter.kt │ │ │ │ ├── PodcastResponseConverter.kt │ │ │ │ └── PodcastSearchItemsConverter.kt │ │ │ │ └── model │ │ │ │ ├── PodcastItemsResponse.kt │ │ │ │ ├── PodcastResponse.kt │ │ │ │ └── PodcastSearchResponse.kt │ │ └── common │ │ │ ├── ApiClient.kt │ │ │ ├── ApiError.kt │ │ │ ├── ApiResult.kt │ │ │ ├── AuthMethod.kt │ │ │ ├── BinaryApiClient.kt │ │ │ ├── ChannelAuthService.kt │ │ │ ├── ChannelCode.kt │ │ │ ├── ChannelFilteringConfiguration.kt │ │ │ ├── ChannelModule.kt │ │ │ ├── ChannelProvider.kt │ │ │ ├── ConnectionInfo.kt │ │ │ ├── LibraryType.kt │ │ │ ├── MediaChannel.kt │ │ │ ├── OAuthContextCache.kt │ │ │ ├── Pkce.kt │ │ │ └── UserAgent.kt │ │ ├── common │ │ ├── CertificateExtension.kt │ │ ├── ColorScheme.kt │ │ ├── HapticAction.kt │ │ ├── HttpClient.kt │ │ ├── ImageExtension.kt │ │ ├── LibraryOrderingConfiguration.kt │ │ ├── LibraryOrderingDirection.kt │ │ ├── LibraryOrderingOption.kt │ │ ├── NetworkQualityService.kt │ │ └── RunningComponent.kt │ │ ├── content │ │ ├── LissenMediaProvider.kt │ │ └── cache │ │ │ ├── CacheBookStorageProperties.kt │ │ │ ├── CacheState.kt │ │ │ ├── CalculateRequestedChapters.kt │ │ │ ├── ContentCachingExecutor.kt │ │ │ ├── ContentCachingManager.kt │ │ │ ├── ContentCachingNotificationService.kt │ │ │ ├── ContentCachingProgress.kt │ │ │ ├── ContentCachingService.kt │ │ │ ├── FindRelatedFiles.kt │ │ │ ├── GetImageDimensions.kt │ │ │ ├── LocalCacheModule.kt │ │ │ ├── LocalCacheRepository.kt │ │ │ ├── LocalCacheStorage.kt │ │ │ ├── Migrations.kt │ │ │ ├── SourceWithBackdropBlur.kt │ │ │ ├── api │ │ │ ├── CachedBookRepository.kt │ │ │ ├── CachedLibraryRepository.kt │ │ │ ├── FetchRequestBuilder.kt │ │ │ └── SearchRequestBuilder.kt │ │ │ ├── converter │ │ │ ├── CachedBookEntityConverter.kt │ │ │ ├── CachedBookEntityDetailedConverter.kt │ │ │ ├── CachedBookEntityRecentConverter.kt │ │ │ └── CachedLibraryEntityConverter.kt │ │ │ ├── dao │ │ │ ├── CachedBookDao.kt │ │ │ └── CachedLibraryDao.kt │ │ │ └── entity │ │ │ ├── CachedBookEntity.kt │ │ │ ├── CachedLibraryEntity.kt │ │ │ └── PlaybackProgressEntity.kt │ │ ├── domain │ │ ├── Book.kt │ │ ├── CacheStatus.kt │ │ ├── ContentCachingTask.kt │ │ ├── DetailedItem.kt │ │ ├── DownloadOption.kt │ │ ├── Library.kt │ │ ├── PagedItems.kt │ │ ├── PlaybackProgress.kt │ │ ├── PlaybackSession.kt │ │ ├── RecentBook.kt │ │ ├── RewindOnPauseTime.kt │ │ ├── SeekTime.kt │ │ ├── SeekTimeOption.kt │ │ ├── TimerOption.kt │ │ ├── UserAccount.kt │ │ └── connection │ │ │ └── ServerRequestHeader.kt │ │ ├── persistence │ │ └── preferences │ │ │ └── LissenSharedPreferences.kt │ │ ├── playback │ │ ├── MediaModule.kt │ │ ├── MediaRepository.kt │ │ └── service │ │ │ ├── CalculateChapterIndex.kt │ │ │ ├── CalculateChapterPosition.kt │ │ │ ├── MimeTypeProvider.kt │ │ │ ├── PlaybackNotificationModule.kt │ │ │ ├── PlaybackNotificationService.kt │ │ │ ├── PlaybackService.kt │ │ │ └── PlaybackSynchronizationService.kt │ │ ├── shortcuts │ │ ├── ContinuePlaybackShortcut.kt │ │ └── ShortcutsModule.kt │ │ ├── ui │ │ ├── PlaybackSpeedSlider.kt │ │ ├── activity │ │ │ └── AppActivity.kt │ │ ├── components │ │ │ ├── AsyncShimmeringImage.kt │ │ │ ├── BookCoverFetcher.kt │ │ │ └── ImageLoaderEntryPoint.kt │ │ ├── extensions │ │ │ ├── AsyncExtensions.kt │ │ │ └── TimeExtensions.kt │ │ ├── icons │ │ │ ├── BookHeadphones.kt │ │ │ ├── Search.kt │ │ │ └── TimerPlay.kt │ │ ├── navigation │ │ │ ├── AppLaunchAction.kt │ │ │ ├── AppNavHost.kt │ │ │ └── AppNavigationService.kt │ │ ├── screens │ │ │ ├── common │ │ │ │ └── RequestNotificationPermissions.kt │ │ │ ├── library │ │ │ │ ├── LibraryScreen.kt │ │ │ │ ├── PreferredLibrarySettingComposable.kt │ │ │ │ ├── composables │ │ │ │ │ ├── BookComposable.kt │ │ │ │ │ ├── DefaultActionComposable.kt │ │ │ │ │ ├── LibrarySearchActionComposable.kt │ │ │ │ │ ├── LibrarySwitchComposable.kt │ │ │ │ │ ├── MiniPlayerComposable.kt │ │ │ │ │ ├── RecentBooksComposable.kt │ │ │ │ │ ├── fallback │ │ │ │ │ │ └── LibraryFallbackComposable.kt │ │ │ │ │ └── placeholder │ │ │ │ │ │ ├── LibraryPlaceholderComposable.kt │ │ │ │ │ │ └── RecentBooksPlaceholderComposable.kt │ │ │ │ └── paging │ │ │ │ │ ├── LibraryDefaultPagingSource.kt │ │ │ │ │ └── LibrarySearchPagingSource.kt │ │ │ ├── login │ │ │ │ └── LoginScreen.kt │ │ │ ├── player │ │ │ │ ├── ChapterSearchActionComposable.kt │ │ │ │ ├── PlayerScreen.kt │ │ │ │ └── composable │ │ │ │ │ ├── DownloadsComposable.kt │ │ │ │ │ ├── MediaDetailComposable.kt │ │ │ │ │ ├── NavigationBarComposable.kt │ │ │ │ │ ├── PlaybackSpeedComposable.kt │ │ │ │ │ ├── PlayingQueueComposable.kt │ │ │ │ │ ├── PlaylistItemComposable.kt │ │ │ │ │ ├── TimerComposable.kt │ │ │ │ │ ├── TrackControlComposable.kt │ │ │ │ │ ├── TrackDetailsComposable.kt │ │ │ │ │ ├── common │ │ │ │ │ ├── ProvideForwardIcon.kt │ │ │ │ │ ├── ProvideNowPlayingTitle.kt │ │ │ │ │ └── ProvideReplayIcon.kt │ │ │ │ │ ├── fallback │ │ │ │ │ └── PlayingQueueFallbackComposable.kt │ │ │ │ │ └── placeholder │ │ │ │ │ ├── PlayingQueuePlaceholderComposable.kt │ │ │ │ │ ├── TrackControlPlaceholderComposable.kt │ │ │ │ │ └── TrackDetailsPlaceholderComposable.kt │ │ │ └── settings │ │ │ │ ├── SettingsScreen.kt │ │ │ │ ├── advanced │ │ │ │ ├── CustomHeaderComposable.kt │ │ │ │ ├── CustomHeadersSettingsScreen.kt │ │ │ │ ├── SeekSettingsScreen.kt │ │ │ │ └── cache │ │ │ │ │ ├── CachedItemsFallbackComposable.kt │ │ │ │ │ ├── CachedItemsPageSource.kt │ │ │ │ │ └── CachedItemsSettingsScreen.kt │ │ │ │ └── composable │ │ │ │ ├── AdditionalComposable.kt │ │ │ │ ├── AdvancedSettingsItemComposable.kt │ │ │ │ ├── ColorSchemeSettingsComposable.kt │ │ │ │ ├── CommonSettingsItem.kt │ │ │ │ ├── CommonSettingsItemComposable.kt │ │ │ │ ├── GitHubLinkComposable.kt │ │ │ │ ├── LibraryOrderingSettingsComposable.kt │ │ │ │ ├── ServerSettingsComposable.kt │ │ │ │ └── SettingsToggleItem.kt │ │ └── theme │ │ │ ├── Color.kt │ │ │ └── Theme.kt │ │ ├── viewmodel │ │ ├── CachingModelView.kt │ │ ├── LibraryViewModel.kt │ │ ├── LoginViewModel.kt │ │ ├── PlayerViewModel.kt │ │ └── SettingsViewModel.kt │ │ └── widget │ │ ├── PlayerWidget.kt │ │ ├── PlayerWidgetModule.kt │ │ ├── PlayerWidgetReceiver.kt │ │ ├── PlayerWidgetStateService.kt │ │ ├── WidgetControlButton.kt │ │ ├── WidgetPlaybackController.kt │ │ ├── WidgetPlaybackControllerEntryPoint.kt │ │ └── WidgetPreferencesEntryPoint.kt │ └── res │ ├── drawable-anydpi │ └── ic_downloading.xml │ ├── drawable-hdpi │ ├── ic_downloading.png │ └── media3_notification_small_icon.png │ ├── drawable-mdpi │ ├── ic_downloading.png │ └── media3_notification_small_icon.png │ ├── drawable-xhdpi │ ├── ic_downloading.png │ └── media3_notification_small_icon.png │ ├── drawable-xxhdpi │ ├── ic_downloading.png │ └── media3_notification_small_icon.png │ ├── drawable-xxxhdpi │ └── media3_notification_small_icon.png │ ├── drawable │ ├── available_offline_filled.xml │ ├── available_offline_outline.xml │ ├── cover_fallback.xml │ ├── cover_fallback_png.png │ ├── ic_launcher_background.xml │ ├── ic_launcher_foreground.xml │ └── ic_play.xml │ ├── layout │ └── widget_placeholder.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ ├── ic_launcher_monochrome.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ ├── ic_launcher_monochrome.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ ├── ic_launcher_monochrome.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ ├── ic_launcher_monochrome.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ ├── ic_launcher_monochrome.webp │ └── ic_launcher_round.webp │ ├── values-ar │ └── strings.xml │ ├── values-de │ └── strings.xml │ ├── values-es │ └── strings.xml │ ├── values-fi │ └── strings.xml │ ├── values-fr │ └── strings.xml │ ├── values-hr │ └── strings.xml │ ├── values-hu │ └── strings.xml │ ├── values-it │ └── strings.xml │ ├── values-night │ └── styles.xml │ ├── values-nl │ └── strings.xml │ ├── values-ru │ └── strings.xml │ ├── values-sl │ └── strings.xml │ ├── values-sv │ └── strings.xml │ ├── values-ta │ └── strings.xml │ ├── values-tr │ └── strings.xml │ ├── values-v31 │ └── styles.xml │ ├── values-zh-rCN │ └── strings.xml │ ├── values │ ├── colors.xml │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── styles.xml │ └── xml │ ├── backup_rules.xml │ ├── data_extraction_rules.xml │ ├── mini_player_widget_info.xml │ └── network_security_config.xml ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── metadata ├── en-US │ ├── full_description.txt │ ├── images │ │ ├── featureGraphic.png │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ └── 4.png │ ├── short_description.txt │ └── title.txt └── ru-RU │ ├── full_description.txt │ ├── images │ ├── featureGraphic.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ └── 4.png │ ├── short_description.txt │ └── title.txt └── settings.gradle.kts /.github/workflows/build_app.yml: -------------------------------------------------------------------------------- 1 | name: Build Lissen App 2 | 3 | env: 4 | # The name of the main module repository 5 | main_project_module: app 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | 23 | # Set Current Date As Env Variable 24 | - name: Set current date as env variable 25 | run: echo "date_today=$(date +'%Y-%m-%d')" >> $GITHUB_ENV 26 | 27 | # Set Repository Name As Env Variable 28 | - name: Set repository name as env variable 29 | run: echo "repository_name=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV 30 | 31 | - name: Set Up JDK 32 | uses: actions/setup-java@v3 33 | with: 34 | distribution: 'zulu' 35 | java-version: '21' 36 | 37 | - name: Change wrapper permissions 38 | run: chmod +x ./gradlew 39 | 40 | # Run Build Project 41 | - name: Build gradle project 42 | run: ./gradlew build -Proom.schemaLocation=$GITHUB_WORKSPACE/app/schemas 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle files 2 | .gradle/ 3 | build/ 4 | 5 | # Local configuration file (sdk path, etc) 6 | local.properties 7 | 8 | # Log/OS Files 9 | *.log 10 | 11 | # Android Studio generated files and folders 12 | captures/ 13 | .externalNativeBuild/ 14 | .cxx/ 15 | *.apk 16 | output.json 17 | 18 | # IntelliJ 19 | *.iml 20 | .idea/ 21 | misc.xml 22 | deploymentTargetDropDown.xml 23 | render.experimental.xml 24 | .kotlin 25 | 26 | # Keystore files 27 | *.jks 28 | *.keystore 29 | 30 | # Google Services (e.g. APIs or Firebase) 31 | google-services.json 32 | 33 | # Android Profiling 34 | *.hprof 35 | 36 | # Schemas 37 | app/schemas 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Max Grakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --- 24 | 25 | This software includes contributions from the following people: 26 | 27 | - [Max Grakov](https://github.com/GrakovNe) 28 | - [zeroquinc](https://github.com/zeroquinc) 29 | - [firain](https://github.com/firain) 30 | - [bittin](https://github.com/bittin) 31 | - [TamilNeram](https://github.com/TamilNeram) 32 | - [Freizan](https://github.com/Freizan) 33 | - [thehijacker](https://github.com/thehijacker) 34 | - [eskiiom](https://github.com/eskiiom) 35 | - [kdankert](https://github.com/kdankert) 36 | - [paperbenni](https://github.com/paperbenni) 37 | - [schoenfeldj](https://github.com/schoenfeldj) 38 | - [jadehawk](https://github.com/jadehawk) 39 | - [Kabika82](https://github.com/Kabika82) 40 | - [dessalines](https://github.com/dessalines) 41 | - [hkoivuneva](https://github.com/hkoivuneva) 42 | - [cronyakatsuki](https://github.com/cronyakatsuki) 43 | -------------------------------------------------------------------------------- /app/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{kt,kts}] 4 | indent_style = space 5 | indent_size = 2 6 | max_line_length = 140 7 | insert_final_newline = true 8 | 9 | ktlint_standard_function-naming = disabled 10 | ktlint_standard_property-naming = disabled 11 | ktlint_standard_backing-property-naming = disabled 12 | 13 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Gson uses generic type information stored in a class file when working with 2 | # fields. Proguard removes such information by default, keep it. 3 | -keepattributes Signature 4 | 5 | # This is also needed for R8 in compat mode since multiple 6 | # optimizations will remove the generic signature such as class 7 | # merging and argument removal. See: 8 | # https://r8.googlesource.com/r8/+/refs/heads/main/compatibility-faq.md#troubleshooting-gson-gson 9 | -keep class com.google.gson.reflect.TypeToken { *; } 10 | -keep class * extends com.google.gson.reflect.TypeToken 11 | 12 | # Optional. For using GSON @Expose annotation 13 | -keepattributes AnnotationDefault,RuntimeVisibleAnnotations 14 | -keep class com.google.gson.reflect.TypeToken { ; } 15 | -keepclassmembers class **$TypeAdapterFactory { ; } -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lissen (DEBUG) 3 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/LissenApplication.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import dagger.hilt.android.HiltAndroidApp 6 | import org.acra.ReportField 7 | import org.acra.config.httpSender 8 | import org.acra.config.toast 9 | import org.acra.data.StringFormat 10 | import org.acra.ktx.initAcra 11 | import org.acra.sender.HttpSender 12 | import org.grakovne.lissen.common.RunningComponent 13 | import javax.inject.Inject 14 | 15 | @HiltAndroidApp 16 | class LissenApplication : Application() { 17 | @Inject 18 | lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> 19 | 20 | override fun attachBaseContext(base: Context) { 21 | super.attachBaseContext(base) 22 | initCrashReporting() 23 | } 24 | 25 | override fun onCreate() { 26 | super.onCreate() 27 | appContext = applicationContext 28 | runningComponents.forEach { it.onCreate() } 29 | } 30 | 31 | private fun initCrashReporting() { 32 | initAcra { 33 | buildConfigClass = BuildConfig::class.java 34 | reportFormat = StringFormat.JSON 35 | 36 | httpSender { 37 | uri = "https://acrarium.grakovne.org/report" 38 | basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN 39 | basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD 40 | httpMethod = HttpSender.Method.POST 41 | dropReportsOnTimeout = false 42 | } 43 | 44 | toast { 45 | text = getString(R.string.app_crach_toast) 46 | } 47 | 48 | reportContent = 49 | listOf( 50 | ReportField.APP_VERSION_NAME, 51 | ReportField.APP_VERSION_CODE, 52 | ReportField.ANDROID_VERSION, 53 | ReportField.PHONE_MODEL, 54 | ReportField.STACK_TRACE, 55 | ) 56 | } 57 | } 58 | 59 | companion object { 60 | lateinit var appContext: Context 61 | private set 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannelProvider.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.UnknownAudiobookshelfChannel 4 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudiobookshelfAuthService 5 | import org.grakovne.lissen.channel.audiobookshelf.library.LibraryAudiobookshelfChannel 6 | import org.grakovne.lissen.channel.audiobookshelf.podcast.PodcastAudiobookshelfChannel 7 | import org.grakovne.lissen.channel.common.ChannelAuthService 8 | import org.grakovne.lissen.channel.common.ChannelCode 9 | import org.grakovne.lissen.channel.common.ChannelProvider 10 | import org.grakovne.lissen.channel.common.LibraryType 11 | import org.grakovne.lissen.channel.common.MediaChannel 12 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 13 | import javax.inject.Inject 14 | import javax.inject.Singleton 15 | 16 | @Singleton 17 | class AudiobookshelfChannelProvider 18 | @Inject 19 | constructor( 20 | private val podcastAudiobookshelfChannel: PodcastAudiobookshelfChannel, 21 | private val libraryAudiobookshelfChannel: LibraryAudiobookshelfChannel, 22 | private val unknownAudiobookshelfChannel: UnknownAudiobookshelfChannel, 23 | private val audiobookshelfAuthService: AudiobookshelfAuthService, 24 | private val sharedPreferences: LissenSharedPreferences, 25 | ) : ChannelProvider { 26 | override fun provideMediaChannel(): MediaChannel { 27 | val libraryType = 28 | sharedPreferences 29 | .getPreferredLibrary() 30 | ?.type 31 | ?: LibraryType.UNKNOWN 32 | 33 | return when (libraryType) { 34 | LibraryType.LIBRARY -> libraryAudiobookshelfChannel 35 | LibraryType.PODCAST -> podcastAudiobookshelfChannel 36 | LibraryType.UNKNOWN -> unknownAudiobookshelfChannel 37 | } 38 | } 39 | 40 | override fun provideChannelAuth(): ChannelAuthService = audiobookshelfAuthService 41 | 42 | override fun getChannelCode(): ChannelCode = ChannelCode.AUDIOBOOKSHELF 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/UnknownAudiobookshelfChannel.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudioBookshelfDataRepository 4 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudioBookshelfMediaRepository 5 | import org.grakovne.lissen.channel.audiobookshelf.common.api.library.AudioBookshelfLibrarySyncService 6 | import org.grakovne.lissen.channel.audiobookshelf.common.converter.ConnectionInfoResponseConverter 7 | import org.grakovne.lissen.channel.audiobookshelf.common.converter.LibraryResponseConverter 8 | import org.grakovne.lissen.channel.audiobookshelf.common.converter.PlaybackSessionResponseConverter 9 | import org.grakovne.lissen.channel.audiobookshelf.common.converter.RecentListeningResponseConverter 10 | import org.grakovne.lissen.channel.common.ApiError 11 | import org.grakovne.lissen.channel.common.ApiResult 12 | import org.grakovne.lissen.channel.common.LibraryType 13 | import org.grakovne.lissen.domain.Book 14 | import org.grakovne.lissen.domain.DetailedItem 15 | import org.grakovne.lissen.domain.PagedItems 16 | import org.grakovne.lissen.domain.PlaybackSession 17 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 18 | import javax.inject.Inject 19 | import javax.inject.Singleton 20 | 21 | @Singleton 22 | class UnknownAudiobookshelfChannel 23 | @Inject 24 | constructor( 25 | dataRepository: AudioBookshelfDataRepository, 26 | mediaRepository: AudioBookshelfMediaRepository, 27 | recentListeningResponseConverter: RecentListeningResponseConverter, 28 | preferences: LissenSharedPreferences, 29 | syncService: AudioBookshelfLibrarySyncService, 30 | sessionResponseConverter: PlaybackSessionResponseConverter, 31 | libraryResponseConverter: LibraryResponseConverter, 32 | connectionInfoResponseConverter: ConnectionInfoResponseConverter, 33 | ) : AudiobookshelfChannel( 34 | dataRepository = dataRepository, 35 | mediaRepository = mediaRepository, 36 | recentBookResponseConverter = recentListeningResponseConverter, 37 | sessionResponseConverter = sessionResponseConverter, 38 | preferences = preferences, 39 | syncService = syncService, 40 | libraryResponseConverter = libraryResponseConverter, 41 | connectionInfoResponseConverter = connectionInfoResponseConverter, 42 | ) { 43 | override fun getLibraryType(): LibraryType = LibraryType.UNKNOWN 44 | 45 | override suspend fun fetchBooks( 46 | libraryId: String, 47 | pageSize: Int, 48 | pageNumber: Int, 49 | ): ApiResult> = ApiResult.Error(ApiError.UnsupportedError) 50 | 51 | override suspend fun searchBooks( 52 | libraryId: String, 53 | query: String, 54 | limit: Int, 55 | ): ApiResult> = ApiResult.Error(ApiError.UnsupportedError) 56 | 57 | override suspend fun startPlayback( 58 | bookId: String, 59 | episodeId: String, 60 | supportedMimeTypes: List, 61 | deviceId: String, 62 | ): ApiResult = ApiResult.Error(ApiError.UnsupportedError) 63 | 64 | override suspend fun fetchBook(bookId: String): ApiResult = ApiResult.Error(ApiError.UnsupportedError) 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/ApiClientConfig.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.api 2 | 3 | import androidx.annotation.Keep 4 | import org.grakovne.lissen.domain.connection.ServerRequestHeader 5 | 6 | @Keep 7 | data class ApiClientConfig( 8 | val host: String?, 9 | val token: String?, 10 | val customHeaders: List?, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/AudioBookshelfSyncService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.api 2 | 3 | import org.grakovne.lissen.channel.common.ApiResult 4 | import org.grakovne.lissen.domain.PlaybackProgress 5 | 6 | interface AudioBookshelfSyncService { 7 | suspend fun syncProgress( 8 | itemId: String, 9 | progress: PlaybackProgress, 10 | ): ApiResult 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/RequestHeadersProvider.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.api 2 | 3 | import org.grakovne.lissen.channel.common.USER_AGENT 4 | import org.grakovne.lissen.domain.connection.ServerRequestHeader 5 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class RequestHeadersProvider 11 | @Inject 12 | constructor( 13 | private val preferences: LissenSharedPreferences, 14 | ) { 15 | fun fetchRequestHeaders(): List { 16 | val usersHeaders = preferences.getCustomHeaders() 17 | 18 | val userAgent = ServerRequestHeader("User-Agent", USER_AGENT) 19 | return usersHeaders + userAgent 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/SafeApiCall.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.api 2 | 3 | import android.util.Log 4 | import org.grakovne.lissen.channel.common.ApiError 5 | import org.grakovne.lissen.channel.common.ApiResult 6 | import retrofit2.Response 7 | import java.io.IOException 8 | 9 | private const val TAG: String = "safeApiCall" 10 | 11 | suspend fun safeApiCall(apiCall: suspend () -> Response): ApiResult { 12 | return try { 13 | val response = apiCall.invoke() 14 | 15 | return when (response.code()) { 16 | 200 -> 17 | when (val body = response.body()) { 18 | null -> ApiResult.Error(ApiError.InternalError) 19 | else -> ApiResult.Success(body) 20 | } 21 | 22 | 400 -> ApiResult.Error(ApiError.InternalError) 23 | 401 -> ApiResult.Error(ApiError.Unauthorized) 24 | 403 -> ApiResult.Error(ApiError.Unauthorized) 25 | 404 -> ApiResult.Error(ApiError.InternalError) 26 | 500 -> ApiResult.Error(ApiError.InternalError) 27 | else -> ApiResult.Error(ApiError.InternalError) 28 | } 29 | } catch (e: IOException) { 30 | Log.e(TAG, "Unable to make network api call $apiCall due to: $e") 31 | ApiResult.Error(ApiError.NetworkError) 32 | } catch (e: Exception) { 33 | Log.e(TAG, "Unable to make network api call $apiCall due to: $e") 34 | ApiResult.Error(ApiError.InternalError) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/library/AudioBookshelfLibrarySyncService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.api.library 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudioBookshelfDataRepository 4 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudioBookshelfSyncService 5 | import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.ProgressSyncRequest 6 | import org.grakovne.lissen.channel.common.ApiResult 7 | import org.grakovne.lissen.domain.PlaybackProgress 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class AudioBookshelfLibrarySyncService 13 | @Inject 14 | constructor( 15 | private val dataRepository: AudioBookshelfDataRepository, 16 | ) : AudioBookshelfSyncService { 17 | private var previousItemId: String? = null 18 | private var previousTrackedTime: Double = 0.0 19 | 20 | override suspend fun syncProgress( 21 | itemId: String, 22 | progress: PlaybackProgress, 23 | ): ApiResult { 24 | val trackedTime = 25 | previousTrackedTime 26 | .takeIf { itemId == previousItemId } 27 | ?.let { progress.currentTotalTime - previousTrackedTime } 28 | ?.toInt() 29 | ?: 0 30 | 31 | val request = 32 | ProgressSyncRequest( 33 | currentTime = progress.currentTotalTime, 34 | timeListened = trackedTime, 35 | ) 36 | 37 | return dataRepository 38 | .publishLibraryItemProgress(itemId, request) 39 | .also { 40 | previousTrackedTime = progress.currentTotalTime 41 | previousItemId = itemId 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/api/podcast/AudioBookshelfPodcastSyncService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.api.podcast 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudioBookshelfDataRepository 4 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudioBookshelfSyncService 5 | import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.ProgressSyncRequest 6 | import org.grakovne.lissen.channel.common.ApiResult 7 | import org.grakovne.lissen.domain.PlaybackProgress 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class AudioBookshelfPodcastSyncService 13 | @Inject 14 | constructor( 15 | private val dataRepository: AudioBookshelfDataRepository, 16 | ) : AudioBookshelfSyncService { 17 | private var previousItemId: String? = null 18 | private var previousTrackedTime: Double = 0.0 19 | 20 | override suspend fun syncProgress( 21 | itemId: String, 22 | progress: PlaybackProgress, 23 | ): ApiResult { 24 | val trackedTime = 25 | previousTrackedTime 26 | .takeIf { itemId == previousItemId } 27 | ?.let { progress.currentChapterTime - previousTrackedTime } 28 | ?.toInt() 29 | ?: 0 30 | 31 | val request = 32 | ProgressSyncRequest( 33 | currentTime = progress.currentChapterTime, 34 | timeListened = trackedTime, 35 | ) 36 | 37 | return dataRepository 38 | .publishLibraryItemProgress(itemId, request) 39 | .also { 40 | previousTrackedTime = progress.currentChapterTime 41 | previousItemId = itemId 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/client/AudiobookshelfMediaClient.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.client 2 | 3 | import okhttp3.ResponseBody 4 | import retrofit2.Response 5 | import retrofit2.http.GET 6 | import retrofit2.http.Path 7 | import retrofit2.http.Query 8 | import retrofit2.http.Streaming 9 | 10 | interface AudiobookshelfMediaClient { 11 | @GET("/api/items/{itemId}/cover") 12 | @Streaming 13 | suspend fun getItemCover( 14 | @Path("itemId") itemId: String, 15 | @Query("raw") raw: Int = 1, 16 | ): Response 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/AuthMethodResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.model.auth.AuthMethodResponse 4 | import org.grakovne.lissen.channel.common.AuthMethod 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class AuthMethodResponseConverter 10 | @Inject 11 | constructor() { 12 | fun apply(response: AuthMethodResponse): List = 13 | response 14 | .authMethods 15 | .mapNotNull { 16 | when (it) { 17 | "local" -> AuthMethod.CREDENTIALS 18 | "openid" -> AuthMethod.O_AUTH 19 | else -> null 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/ConnectionInfoResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.model.connection.ConnectionInfoResponse 4 | import org.grakovne.lissen.channel.common.ConnectionInfo 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class ConnectionInfoResponseConverter 10 | @Inject 11 | constructor() { 12 | fun apply(response: ConnectionInfoResponse): ConnectionInfo = 13 | ConnectionInfo( 14 | username = response.user.username, 15 | serverVersion = response.serverSettings?.version, 16 | buildNumber = response.serverSettings?.buildNumber, 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryPageResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.library.model.LibraryItemsResponse 4 | import org.grakovne.lissen.domain.Book 5 | import org.grakovne.lissen.domain.PagedItems 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class LibraryPageResponseConverter 11 | @Inject 12 | constructor() { 13 | fun apply(response: LibraryItemsResponse): PagedItems = 14 | response 15 | .results 16 | .mapNotNull { 17 | val title = it.media.metadata.title ?: return@mapNotNull null 18 | 19 | Book( 20 | id = it.id, 21 | title = title, 22 | series = it.media.metadata.seriesName, 23 | subtitle = it.media.metadata.subtitle, 24 | author = it.media.metadata.authorName, 25 | duration = it.media.duration.toInt(), 26 | hasContent = it.media.numChapters?.let { count -> count > 0 } ?: true, 27 | ) 28 | }.let { 29 | PagedItems( 30 | items = it, 31 | currentPage = response.page, 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LibraryResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.model.metadata.LibraryResponse 4 | import org.grakovne.lissen.channel.common.LibraryType 5 | import org.grakovne.lissen.domain.Library 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class LibraryResponseConverter 11 | @Inject 12 | constructor() { 13 | fun apply(response: LibraryResponse): List = 14 | response 15 | .libraries 16 | .map { 17 | it 18 | .mediaType 19 | .toLibraryType() 20 | .let { type -> Library(it.id, it.name, type) } 21 | } 22 | 23 | private fun String.toLibraryType() = 24 | when (this) { 25 | "podcast" -> LibraryType.PODCAST 26 | "book" -> LibraryType.LIBRARY 27 | else -> LibraryType.UNKNOWN 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/LoginResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.model.user.LoggedUserResponse 4 | import org.grakovne.lissen.domain.UserAccount 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class LoginResponseConverter 10 | @Inject 11 | constructor() { 12 | fun apply(response: LoggedUserResponse): UserAccount = 13 | UserAccount( 14 | token = response.user.token, 15 | username = response.user.username, 16 | preferredLibraryId = response.userDefaultLibraryId, 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/PlaybackSessionResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.model.playback.PlaybackSessionResponse 4 | import org.grakovne.lissen.domain.PlaybackSession 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class PlaybackSessionResponseConverter 10 | @Inject 11 | constructor() { 12 | fun apply(response: PlaybackSessionResponse): PlaybackSession = 13 | PlaybackSession( 14 | sessionId = response.id, 15 | bookId = response.libraryItemId, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/converter/RecentListeningResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.model.user.PersonalizedFeedResponse 4 | import org.grakovne.lissen.domain.RecentBook 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class RecentListeningResponseConverter 10 | @Inject 11 | constructor() { 12 | fun apply( 13 | response: List, 14 | progress: Map>, 15 | ): List = 16 | response 17 | .find { it.labelStringKey == LABEL_CONTINUE_LISTENING } 18 | ?.entities 19 | ?.distinctBy { it.id } 20 | ?.map { 21 | RecentBook( 22 | id = it.id, 23 | title = it.media.metadata.title, 24 | subtitle = it.media.metadata.subtitle, 25 | author = it.media.metadata.authorName, 26 | listenedPercentage = progress[it.id]?.second?.let { it * 100 }?.toInt(), 27 | listenedLastUpdate = progress[it.id]?.first, 28 | ) 29 | } ?: emptyList() 30 | 31 | companion object { 32 | private const val LABEL_CONTINUE_LISTENING = "LabelContinueListening" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/MediaProgressResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class MediaProgressResponse( 7 | val libraryItemId: String, 8 | val episodeId: String?, 9 | val currentTime: Double, 10 | val isFinished: Boolean, 11 | val lastUpdate: Long, 12 | val progress: Double, 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/auth/AuthMethodResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.auth 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class AuthMethodResponse( 7 | val authMethods: List = emptyList(), 8 | ) 9 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/connection/ConnectionInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.connection 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class ConnectionInfoResponse( 7 | val user: ConnectionInfoUserResponse, 8 | val serverSettings: ConnectionInfoServerResponse?, 9 | ) 10 | 11 | @Keep 12 | data class ConnectionInfoUserResponse( 13 | val username: String, 14 | ) 15 | 16 | @Keep 17 | data class ConnectionInfoServerResponse( 18 | val version: String?, 19 | val buildNumber: String?, 20 | ) 21 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/AuthorItemsResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.metadata 2 | 3 | import androidx.annotation.Keep 4 | import org.grakovne.lissen.channel.audiobookshelf.library.model.LibraryItem 5 | 6 | @Keep 7 | data class AuthorItemsResponse( 8 | val libraryItems: List, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/metadata/LibraryResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.metadata 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class LibraryResponse( 7 | val libraries: List, 8 | ) 9 | 10 | @Keep 11 | data class LibraryItemResponse( 12 | val id: String, 13 | val name: String, 14 | val mediaType: String, 15 | ) 16 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackSessionResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.playback 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PlaybackSessionResponse( 7 | val id: String, 8 | val libraryItemId: String, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/PlaybackStartRequest.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.playback 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PlaybackStartRequest( 7 | val deviceInfo: DeviceInfo, 8 | val supportedMimeTypes: List, 9 | val mediaPlayer: String, 10 | val forceTranscode: Boolean, 11 | val forceDirectPlay: Boolean, 12 | ) 13 | 14 | @Keep 15 | data class DeviceInfo( 16 | val clientName: String, 17 | val deviceId: String, 18 | val deviceName: String, 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/playback/ProgressSyncRequest.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.playback 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class ProgressSyncRequest( 7 | val timeListened: Int, 8 | val currentTime: Double, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/CredentialsLoginRequest.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.user 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class CredentialsLoginRequest( 7 | val username: String, 8 | val password: String, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/LoggedUserResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.user 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class LoggedUserResponse( 7 | val user: User, 8 | val userDefaultLibraryId: String?, 9 | ) 10 | 11 | @Keep 12 | data class User( 13 | val id: String, 14 | val token: String, 15 | val username: String = "username", 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/PersonalizedFeedResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.user 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PersonalizedFeedResponse( 7 | val id: String, 8 | val labelStringKey: String, 9 | val entities: List, 10 | ) 11 | 12 | @Keep 13 | data class PersonalizedFeedItemResponse( 14 | val id: String, 15 | val libraryId: String, 16 | val media: PersonalizedFeedItemMediaResponse, 17 | val updateAt: Long, 18 | ) 19 | 20 | @Keep 21 | data class PersonalizedFeedItemMediaResponse( 22 | val id: String, 23 | val metadata: PersonalizedFeedItemMetadataResponse, 24 | ) 25 | 26 | @Keep 27 | data class PersonalizedFeedItemMetadataResponse( 28 | val title: String, 29 | val subtitle: String?, 30 | val authorName: String, 31 | ) 32 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/model/user/UserInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.user 2 | 3 | import androidx.annotation.Keep 4 | import org.grakovne.lissen.channel.audiobookshelf.common.model.MediaProgressResponse 5 | 6 | @Keep 7 | data class UserInfoResponse( 8 | val user: UserResponse, 9 | ) 10 | 11 | @Keep 12 | data class UserResponse( 13 | val mediaProgress: List?, 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/oauth/AudiobookshelfOAuthCallbackActivity.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.oauth 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.widget.Toast 7 | import android.widget.Toast.LENGTH_SHORT 8 | import androidx.activity.ComponentActivity 9 | import androidx.lifecycle.lifecycleScope 10 | import dagger.hilt.android.AndroidEntryPoint 11 | import kotlinx.coroutines.launch 12 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudiobookshelfAuthService 13 | import org.grakovne.lissen.channel.common.OAuthContextCache 14 | import org.grakovne.lissen.channel.common.makeText 15 | import org.grakovne.lissen.content.LissenMediaProvider 16 | import org.grakovne.lissen.domain.UserAccount 17 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 18 | import org.grakovne.lissen.ui.activity.AppActivity 19 | import javax.inject.Inject 20 | 21 | @AndroidEntryPoint 22 | class AudiobookshelfOAuthCallbackActivity : ComponentActivity() { 23 | @Inject 24 | lateinit var contextCache: OAuthContextCache 25 | 26 | @Inject 27 | lateinit var authService: AudiobookshelfAuthService 28 | 29 | @Inject 30 | lateinit var mediaProvider: LissenMediaProvider 31 | 32 | @Inject 33 | lateinit var preferences: LissenSharedPreferences 34 | 35 | override fun onCreate(savedInstanceState: Bundle?) { 36 | super.onCreate(savedInstanceState) 37 | val data = intent?.data 38 | 39 | if (null == data) { 40 | finish() 41 | return 42 | } 43 | 44 | if (intent?.action == Intent.ACTION_VIEW && data.scheme == AuthScheme) { 45 | val code = data.getQueryParameter("code") ?: "" 46 | Log.d(TAG, "Got Exchange code from ABS") 47 | 48 | lifecycleScope.launch { 49 | authService.exchangeToken( 50 | host = 51 | preferences.getHost() ?: kotlin.run { 52 | onLoginFailed("invalid_host") 53 | return@launch 54 | }, 55 | code = code, 56 | onSuccess = { onLogged(it) }, 57 | onFailure = { onLoginFailed(it) }, 58 | ) 59 | } 60 | } 61 | } 62 | 63 | private suspend fun onLogged(userAccount: UserAccount) { 64 | mediaProvider.onPostLogin( 65 | host = preferences.getHost() ?: return, 66 | account = userAccount, 67 | ) 68 | 69 | val intent = 70 | Intent(this, AppActivity::class.java).apply { 71 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or 72 | Intent.FLAG_ACTIVITY_CLEAR_TASK 73 | } 74 | 75 | startActivity(intent) 76 | finish() 77 | } 78 | 79 | private fun onLoginFailed(reason: String) { 80 | runOnUiThread { 81 | authService 82 | .examineError(reason) 83 | .makeText(this) 84 | .let { Toast.makeText(this, it, LENGTH_SHORT).show() } 85 | 86 | finish() 87 | } 88 | } 89 | 90 | companion object { 91 | private const val TAG = "AudiobookshelfOAuthCallbackActivity" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/common/oauth/OAuthScheme.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.oauth 2 | 3 | const val AuthScheme = "lissen" 4 | const val AuthHost = "oauth" 5 | const val AuthClient = "lissen_app" 6 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibraryOrderingRequestConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.library.converter 2 | 3 | import org.grakovne.lissen.common.LibraryOrderingConfiguration 4 | import org.grakovne.lissen.common.LibraryOrderingDirection 5 | import org.grakovne.lissen.common.LibraryOrderingOption 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class LibraryOrderingRequestConverter 11 | @Inject 12 | constructor() { 13 | fun apply(configuration: LibraryOrderingConfiguration): Pair { 14 | val option = 15 | when (configuration.option) { 16 | LibraryOrderingOption.TITLE -> "media.metadata.title" 17 | LibraryOrderingOption.AUTHOR -> "media.metadata.authorName" 18 | LibraryOrderingOption.CREATED_AT -> "addedAt" 19 | LibraryOrderingOption.UPDATED_AT -> "mtimeMs" 20 | } 21 | 22 | val direction = 23 | when (configuration.direction) { 24 | LibraryOrderingDirection.ASCENDING -> "0" 25 | LibraryOrderingDirection.DESCENDING -> "1" 26 | } 27 | 28 | return option to direction 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/converter/LibrarySearchItemsConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.library.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.library.model.LibraryItem 4 | import org.grakovne.lissen.domain.Book 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class LibrarySearchItemsConverter 10 | @Inject 11 | constructor() { 12 | fun apply(response: List) = 13 | response 14 | .mapNotNull { 15 | val title = it.media.metadata.title ?: return@mapNotNull null 16 | 17 | Book( 18 | id = it.id, 19 | title = title, 20 | series = it.media.metadata.seriesName, 21 | subtitle = it.media.metadata.subtitle, 22 | author = it.media.metadata.authorName, 23 | duration = it.media.duration.toInt(), 24 | hasContent = it.media.numChapters?.let { count -> count > 0 } ?: true, 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/BookResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.library.model 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class BookResponse( 7 | val id: String, 8 | val ino: String, 9 | val libraryId: String, 10 | val media: BookMedia, 11 | val addedAt: Long, 12 | val ctimeMs: Long, 13 | ) 14 | 15 | @Keep 16 | data class BookMedia( 17 | val metadata: LibraryMetadataResponse, 18 | val audioFiles: List?, 19 | val chapters: List?, 20 | ) 21 | 22 | @Keep 23 | data class LibraryMetadataResponse( 24 | val title: String, 25 | val subtitle: String?, 26 | val authors: List?, 27 | val narrators: List?, 28 | val series: List?, 29 | val description: String?, 30 | val publisher: String?, 31 | val publishedYear: String?, 32 | ) 33 | 34 | @Keep 35 | data class LibrarySeriesResponse( 36 | val id: String, 37 | val name: String, 38 | val sequence: String?, 39 | ) 40 | 41 | @Keep 42 | data class LibraryAuthorResponse( 43 | val id: String, 44 | val name: String, 45 | ) 46 | 47 | @Keep 48 | data class BookAudioFileResponse( 49 | val index: Int, 50 | val ino: String, 51 | val duration: Double, 52 | val metadata: AudioFileMetadata, 53 | val metaTags: AudioFileTag?, 54 | val mimeType: String, 55 | ) 56 | 57 | @Keep 58 | data class AudioFileMetadata( 59 | val filename: String, 60 | val ext: String, 61 | val size: Long, 62 | ) 63 | 64 | @Keep 65 | data class AudioFileTag( 66 | val tagAlbum: String, 67 | val tagTitle: String, 68 | ) 69 | 70 | @Keep 71 | data class LibraryChapterResponse( 72 | val start: Double, 73 | val end: Double, 74 | val title: String, 75 | val id: String, 76 | ) 77 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibraryItemsResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.library.model 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class LibraryItemsResponse( 7 | val results: List, 8 | val page: Int, 9 | ) 10 | 11 | @Keep 12 | data class LibraryItem( 13 | val id: String, 14 | val media: Media, 15 | ) 16 | 17 | @Keep 18 | data class Media( 19 | val numChapters: Int?, 20 | val duration: Double, 21 | val metadata: LibraryMetadata, 22 | ) 23 | 24 | @Keep 25 | data class LibraryMetadata( 26 | val title: String?, 27 | val subtitle: String?, 28 | val seriesName: String?, 29 | val authorName: String?, 30 | ) 31 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/library/model/LibrarySearchResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.library.model 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class LibrarySearchResponse( 7 | val book: List, 8 | val authors: List, 9 | val series: List, 10 | ) 11 | 12 | @Keep 13 | data class LibrarySearchItemResponse( 14 | val libraryItem: LibraryItem, 15 | ) 16 | 17 | @Keep 18 | data class LibrarySearchAuthorResponse( 19 | val id: String, 20 | val name: String, 21 | ) 22 | 23 | @Keep 24 | data class LibrarySearchSeriesResponse( 25 | val books: List, 26 | ) 27 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastOrderingRequestConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.podcast.converter 2 | 3 | import org.grakovne.lissen.common.LibraryOrderingConfiguration 4 | import org.grakovne.lissen.common.LibraryOrderingDirection 5 | import org.grakovne.lissen.common.LibraryOrderingOption 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class PodcastOrderingRequestConverter 11 | @Inject 12 | constructor() { 13 | fun apply(configuration: LibraryOrderingConfiguration): Pair { 14 | val option = 15 | when (configuration.option) { 16 | LibraryOrderingOption.TITLE -> "media.metadata.title" 17 | LibraryOrderingOption.AUTHOR -> "media.metadata.author" 18 | LibraryOrderingOption.CREATED_AT -> "addedAt" 19 | LibraryOrderingOption.UPDATED_AT -> "mtimeMs" 20 | } 21 | 22 | val direction = 23 | when (configuration.direction) { 24 | LibraryOrderingDirection.ASCENDING -> "0" 25 | LibraryOrderingDirection.DESCENDING -> "1" 26 | } 27 | 28 | return option to direction 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastPageResponseConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.podcast.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.podcast.model.PodcastItemsResponse 4 | import org.grakovne.lissen.domain.Book 5 | import org.grakovne.lissen.domain.PagedItems 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class PodcastPageResponseConverter 11 | @Inject 12 | constructor() { 13 | fun apply(response: PodcastItemsResponse): PagedItems = 14 | response 15 | .results 16 | .mapNotNull { 17 | val title = it.media.metadata.title ?: return@mapNotNull null 18 | 19 | Book( 20 | id = it.id, 21 | title = title, 22 | subtitle = null, 23 | series = null, 24 | author = it.media.metadata.author, 25 | duration = it.media.duration.toInt(), 26 | hasContent = it.media.numEpisodes?.let { count -> count > 0 } ?: true, 27 | ) 28 | }.let { 29 | PagedItems( 30 | items = it, 31 | currentPage = response.page, 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/converter/PodcastSearchItemsConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.podcast.converter 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.podcast.model.PodcastItem 4 | import org.grakovne.lissen.domain.Book 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class PodcastSearchItemsConverter 10 | @Inject 11 | constructor() { 12 | fun apply(response: List): List { 13 | return response 14 | .mapNotNull { 15 | val title = it.media.metadata.title ?: return@mapNotNull null 16 | 17 | Book( 18 | id = it.id, 19 | title = title, 20 | subtitle = null, 21 | series = null, 22 | author = it.media.metadata.author, 23 | duration = it.media.duration.toInt(), 24 | hasContent = it.media.numEpisodes?.let { count -> count > 0 } ?: true, 25 | ) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastItemsResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.podcast.model 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PodcastItemsResponse( 7 | val results: List, 8 | val page: Int, 9 | ) 10 | 11 | @Keep 12 | data class PodcastItem( 13 | val id: String, 14 | val media: PodcastItemMedia, 15 | ) 16 | 17 | @Keep 18 | data class PodcastItemMedia( 19 | val duration: Double, 20 | val numEpisodes: Int?, 21 | val metadata: PodcastMetadata, 22 | ) 23 | 24 | @Keep 25 | data class PodcastMetadata( 26 | val title: String?, 27 | val author: String?, 28 | ) 29 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.podcast.model 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PodcastResponse( 7 | val id: String, 8 | val ino: String, 9 | val libraryId: String, 10 | val media: PodcastMedia, 11 | val addedAt: Long, 12 | val ctimeMs: Long, 13 | ) 14 | 15 | @Keep 16 | data class PodcastMedia( 17 | val metadata: PodcastMediaMetadataResponse, 18 | val episodes: List?, 19 | ) 20 | 21 | @Keep 22 | data class PodcastMediaMetadataResponse( 23 | val title: String, 24 | val author: String?, 25 | val description: String?, 26 | val publisher: String?, 27 | ) 28 | 29 | @Keep 30 | data class PodcastEpisodeResponse( 31 | val id: String, 32 | val season: String?, 33 | val episode: String?, 34 | val pubDate: String?, 35 | val title: String, 36 | val audioFile: PodcastAudioFileResponse, 37 | ) 38 | 39 | @Keep 40 | data class PodcastAudioFileResponse( 41 | val index: Int, 42 | val ino: String, 43 | val duration: Double, 44 | val mimeType: String, 45 | ) 46 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/audiobookshelf/podcast/model/PodcastSearchResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.podcast.model 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PodcastSearchResponse( 7 | val podcast: List, 8 | ) 9 | 10 | @Keep 11 | data class PodcastSearchItemResponse( 12 | val libraryItem: PodcastItem, 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ApiClient.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.OkHttpClient 5 | import okhttp3.Request 6 | import okhttp3.logging.HttpLoggingInterceptor 7 | import org.grakovne.lissen.common.withTrustedCertificates 8 | import org.grakovne.lissen.domain.connection.ServerRequestHeader 9 | import retrofit2.Retrofit 10 | import retrofit2.converter.gson.GsonConverterFactory 11 | import java.util.concurrent.TimeUnit 12 | 13 | class ApiClient( 14 | host: String, 15 | requestHeaders: List?, 16 | token: String? = null, 17 | ) { 18 | private val httpClient = 19 | OkHttpClient 20 | .Builder() 21 | .withTrustedCertificates() 22 | .addInterceptor( 23 | HttpLoggingInterceptor().apply { 24 | level = HttpLoggingInterceptor.Level.NONE 25 | }, 26 | ).addInterceptor { chain: Interceptor.Chain -> 27 | val original: Request = chain.request() 28 | val requestBuilder: Request.Builder = original.newBuilder() 29 | 30 | if (token != null) { 31 | requestBuilder.header("Authorization", "Bearer $token") 32 | } 33 | 34 | requestHeaders 35 | ?.filter { it.name.isNotEmpty() } 36 | ?.filter { it.value.isNotEmpty() } 37 | ?.forEach { requestBuilder.header(it.name, it.value) } 38 | 39 | val request: Request = requestBuilder.build() 40 | chain.proceed(request) 41 | }.connectTimeout(30, TimeUnit.SECONDS) 42 | .readTimeout(90, TimeUnit.SECONDS) 43 | .build() 44 | 45 | val retrofit: Retrofit = 46 | Retrofit 47 | .Builder() 48 | .baseUrl(host) 49 | .client(httpClient) 50 | .addConverterFactory(GsonConverterFactory.create()) 51 | .build() 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ApiError.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import android.content.Context 4 | import org.grakovne.lissen.R 5 | import org.grakovne.lissen.channel.audiobookshelf.common.oauth.AuthHost 6 | import org.grakovne.lissen.channel.audiobookshelf.common.oauth.AuthScheme 7 | 8 | sealed class ApiError { 9 | data object Unauthorized : ApiError() 10 | 11 | data object NetworkError : ApiError() 12 | 13 | data object InvalidCredentialsHost : ApiError() 14 | 15 | data object MissingCredentialsHost : ApiError() 16 | 17 | data object MissingCredentialsUsername : ApiError() 18 | 19 | data object MissingCredentialsPassword : ApiError() 20 | 21 | data object InternalError : ApiError() 22 | 23 | data object InvalidRedirectUri : ApiError() 24 | 25 | data object OAuthFlowFailed : ApiError() 26 | 27 | data object UnsupportedError : ApiError() 28 | } 29 | 30 | fun ApiError.makeText(context: Context) = 31 | when (this) { 32 | ApiError.InternalError -> context.getString(R.string.login_error_host_is_down) 33 | ApiError.MissingCredentialsHost -> context.getString(R.string.login_error_host_url_is_missing) 34 | ApiError.MissingCredentialsPassword -> context.getString(R.string.login_error_username_is_missing) 35 | ApiError.MissingCredentialsUsername -> context.getString(R.string.login_error_password_is_missing) 36 | ApiError.Unauthorized -> context.getString(R.string.login_error_credentials_are_invalid) 37 | ApiError.InvalidCredentialsHost -> context.getString(R.string.login_error_host_url_shall_be_https_or_http) 38 | ApiError.NetworkError -> context.getString(R.string.login_error_connection_error) 39 | ApiError.InvalidRedirectUri -> 40 | context.getString( 41 | R.string.login_error_lissen_auth_scheme_must_be_whitelisted, 42 | AuthScheme, 43 | AuthHost, 44 | ) 45 | ApiError.UnsupportedError -> context.getString(R.string.login_error_connection_error) 46 | ApiError.OAuthFlowFailed -> context.getString(R.string.login_error_lissen_auth_failed) 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ApiResult.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | sealed class ApiResult { 7 | data class Success( 8 | val data: T, 9 | ) : ApiResult() 10 | 11 | data class Error( 12 | val code: ApiError, 13 | val message: String? = null, 14 | ) : ApiResult() 15 | 16 | fun fold( 17 | onSuccess: (T) -> R, 18 | onFailure: (Error) -> R, 19 | ): R = 20 | when (this) { 21 | is Success -> onSuccess(this.data) 22 | is Error -> onFailure(this) 23 | } 24 | 25 | suspend fun foldAsync( 26 | onSuccess: suspend (T) -> R, 27 | onFailure: suspend (Error) -> R, 28 | ): R = 29 | when (this) { 30 | is Success -> onSuccess(this.data) 31 | is Error -> onFailure(this) 32 | } 33 | 34 | suspend fun map(transform: suspend (T) -> R): ApiResult = 35 | when (this) { 36 | is Success -> Success(transform(this.data)) 37 | is Error -> Error(this.code, this.message) 38 | } 39 | 40 | suspend fun flatMap(transform: suspend (T) -> ApiResult): ApiResult = 41 | when (this) { 42 | is Success -> transform(this.data) 43 | is Error -> Error(this.code, this.message) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/AuthMethod.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | enum class AuthMethod { 4 | CREDENTIALS, 5 | O_AUTH, 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/BinaryApiClient.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import okhttp3.Interceptor 4 | import okhttp3.OkHttpClient 5 | import okhttp3.logging.HttpLoggingInterceptor 6 | import org.grakovne.lissen.common.withTrustedCertificates 7 | import org.grakovne.lissen.domain.connection.ServerRequestHeader 8 | import retrofit2.Retrofit 9 | import java.util.concurrent.TimeUnit 10 | 11 | class BinaryApiClient( 12 | host: String, 13 | requestHeaders: List?, 14 | token: String, 15 | ) { 16 | private val httpClient = 17 | OkHttpClient 18 | .Builder() 19 | .withTrustedCertificates() 20 | .addInterceptor( 21 | HttpLoggingInterceptor().apply { 22 | level = HttpLoggingInterceptor.Level.NONE 23 | }, 24 | ).addInterceptor { chain: Interceptor.Chain -> 25 | val request = 26 | chain 27 | .request() 28 | .newBuilder() 29 | .header("Authorization", "Bearer $token") 30 | 31 | requestHeaders 32 | ?.filter { it.name.isNotEmpty() } 33 | ?.filter { it.value.isNotEmpty() } 34 | ?.forEach { request.header(it.name, it.value) } 35 | 36 | chain.proceed(request.build()) 37 | }.connectTimeout(30, TimeUnit.SECONDS) 38 | .readTimeout(90, TimeUnit.SECONDS) 39 | .build() 40 | 41 | val retrofit: Retrofit = 42 | Retrofit 43 | .Builder() 44 | .baseUrl(host) 45 | .client(httpClient) 46 | .build() 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ChannelAuthService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import org.grakovne.lissen.domain.UserAccount 4 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 5 | 6 | abstract class ChannelAuthService( 7 | private val preferences: LissenSharedPreferences, 8 | ) { 9 | abstract suspend fun authorize( 10 | host: String, 11 | username: String, 12 | password: String, 13 | onSuccess: suspend (UserAccount) -> Unit, 14 | ): ApiResult 15 | 16 | abstract suspend fun startOAuth( 17 | host: String, 18 | onSuccess: () -> Unit, 19 | onFailure: (ApiError) -> Unit, 20 | ) 21 | 22 | abstract suspend fun exchangeToken( 23 | host: String, 24 | code: String, 25 | onSuccess: suspend (UserAccount) -> Unit, 26 | onFailure: (String) -> Unit, 27 | ) 28 | 29 | abstract suspend fun fetchAuthMethods(host: String): ApiResult> 30 | 31 | fun persistCredentials( 32 | host: String, 33 | username: String, 34 | token: String, 35 | ) { 36 | preferences.saveHost(host) 37 | preferences.saveUsername(username) 38 | preferences.saveToken(token) 39 | } 40 | 41 | fun examineError(raw: String): ApiError = 42 | when { 43 | raw.contains("Invalid redirect_uri") -> ApiError.InvalidRedirectUri 44 | raw.contains("invalid_host") -> ApiError.MissingCredentialsHost 45 | else -> ApiError.OAuthFlowFailed 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ChannelCode.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | enum class ChannelCode { 4 | AUDIOBOOKSHELF, 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ChannelFilteringConfiguration.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | import org.grakovne.lissen.common.LibraryOrderingConfiguration 5 | import org.grakovne.lissen.common.LibraryOrderingOption 6 | 7 | @Keep 8 | data class ChannelFilteringConfiguration( 9 | val orderingOptions: List, 10 | val defaultOrdering: LibraryOrderingConfiguration, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ChannelModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.OptIn 4 | import androidx.media3.common.util.UnstableApi 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfChannelProvider 10 | import javax.inject.Singleton 11 | 12 | @Module 13 | @InstallIn(SingletonComponent::class) 14 | object ChannelModule { 15 | @OptIn(UnstableApi::class) 16 | @Provides 17 | @Singleton 18 | fun getChannelProviders( 19 | audiobookshelfChannelProvider: AudiobookshelfChannelProvider, 20 | ): Map = 21 | mapOf( 22 | audiobookshelfChannelProvider.getChannelCode() to audiobookshelfChannelProvider, 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ChannelProvider.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | interface ChannelProvider { 4 | fun provideMediaChannel(): MediaChannel 5 | 6 | fun provideChannelAuth(): ChannelAuthService 7 | 8 | fun getChannelCode(): ChannelCode 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/ConnectionInfo.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class ConnectionInfo( 7 | val username: String, 8 | val serverVersion: String?, 9 | val buildNumber: String?, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/LibraryType.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | enum class LibraryType { 4 | LIBRARY, 5 | PODCAST, 6 | UNKNOWN, 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/MediaChannel.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import android.net.Uri 4 | import okio.Buffer 5 | import org.grakovne.lissen.domain.Book 6 | import org.grakovne.lissen.domain.DetailedItem 7 | import org.grakovne.lissen.domain.Library 8 | import org.grakovne.lissen.domain.PagedItems 9 | import org.grakovne.lissen.domain.PlaybackProgress 10 | import org.grakovne.lissen.domain.PlaybackSession 11 | import org.grakovne.lissen.domain.RecentBook 12 | 13 | interface MediaChannel { 14 | fun getLibraryType(): LibraryType 15 | 16 | fun provideFileUri( 17 | libraryItemId: String, 18 | fileId: String, 19 | ): Uri 20 | 21 | suspend fun syncProgress( 22 | sessionId: String, 23 | progress: PlaybackProgress, 24 | ): ApiResult 25 | 26 | suspend fun fetchBookCover(bookId: String): ApiResult 27 | 28 | suspend fun fetchBooks( 29 | libraryId: String, 30 | pageSize: Int, 31 | pageNumber: Int, 32 | ): ApiResult> 33 | 34 | suspend fun searchBooks( 35 | libraryId: String, 36 | query: String, 37 | limit: Int, 38 | ): ApiResult> 39 | 40 | suspend fun fetchLibraries(): ApiResult> 41 | 42 | suspend fun startPlayback( 43 | bookId: String, 44 | episodeId: String, 45 | supportedMimeTypes: List, 46 | deviceId: String, 47 | ): ApiResult 48 | 49 | suspend fun fetchConnectionInfo(): ApiResult 50 | 51 | suspend fun fetchRecentListenedBooks(libraryId: String): ApiResult> 52 | 53 | suspend fun fetchBook(bookId: String): ApiResult 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/OAuthContextCache.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import javax.inject.Inject 4 | import javax.inject.Singleton 5 | 6 | @Singleton 7 | class OAuthContextCache 8 | @Inject 9 | constructor() { 10 | private var pkce: Pkce = clearPkce() 11 | private var cookies: String = clearCookies() 12 | 13 | fun storePkce(pkce: Pkce) { 14 | this.pkce = pkce 15 | } 16 | 17 | fun readPkce() = pkce 18 | 19 | fun clearPkce(): Pkce { 20 | pkce = Pkce("", "", "") 21 | return pkce 22 | } 23 | 24 | fun storeCookies(cookies: List) { 25 | this.cookies = cookies.joinToString("; ") { it.substringBefore(";") } 26 | } 27 | 28 | fun readCookies() = cookies 29 | 30 | fun clearCookies(): String { 31 | cookies = "" 32 | return cookies 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/Pkce.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | import java.nio.charset.StandardCharsets 5 | import java.security.MessageDigest 6 | import java.util.Base64 7 | 8 | fun randomPkce(): Pkce { 9 | val verifier = generateRandomHexString(42) 10 | val challenge = base64UrlEncode(sha256(verifier)) 11 | val state = generateRandomHexString(42) 12 | 13 | return Pkce( 14 | verifier = verifier, 15 | challenge = challenge, 16 | state = state, 17 | ) 18 | } 19 | 20 | private fun generateRandomHexString(byteCount: Int = 32): String { 21 | val array = ByteArray(byteCount) 22 | java.security.SecureRandom().nextBytes(array) 23 | 24 | return array.joinToString("") { "%02x".format(it) } 25 | } 26 | 27 | private fun sha256(input: String): ByteArray { 28 | val digest = MessageDigest.getInstance("SHA-256") 29 | return digest.digest(input.toByteArray(StandardCharsets.US_ASCII)) 30 | } 31 | 32 | private fun base64UrlEncode(bytes: ByteArray) = 33 | Base64 34 | .getUrlEncoder() 35 | .withoutPadding() 36 | .encodeToString(bytes) 37 | 38 | @Keep 39 | data class Pkce( 40 | val verifier: String, 41 | val challenge: String, 42 | val state: String, 43 | ) 44 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/channel/common/UserAgent.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import android.os.Build 4 | 5 | val USER_AGENT = 6 | "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; K) " + 7 | "AppleWebKit/537.36 (KHTML, like Gecko) " + 8 | "Chrome/130.0.6723.106 Mobile Safari/537.36" 9 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/CertificateExtension.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import okhttp3.OkHttpClient 4 | import java.security.KeyStore 5 | import javax.net.ssl.SSLContext 6 | import javax.net.ssl.TrustManagerFactory 7 | import javax.net.ssl.TrustManagerFactory.getInstance 8 | import javax.net.ssl.X509TrustManager 9 | 10 | private val systemTrustManager: X509TrustManager by lazy { 11 | val keyStore = KeyStore.getInstance("AndroidCAStore") 12 | keyStore.load(null) 13 | 14 | val trustManagerFactory = getInstance(TrustManagerFactory.getDefaultAlgorithm()) 15 | trustManagerFactory.init(keyStore) 16 | 17 | trustManagerFactory 18 | .trustManagers 19 | .first { it is X509TrustManager } as X509TrustManager 20 | } 21 | 22 | private val systemSSLContext: SSLContext by lazy { 23 | SSLContext.getInstance("TLS").apply { 24 | init(null, arrayOf(systemTrustManager), null) 25 | } 26 | } 27 | 28 | fun OkHttpClient.Builder.withTrustedCertificates(): OkHttpClient.Builder = 29 | try { 30 | sslSocketFactory(systemSSLContext.socketFactory, systemTrustManager) 31 | } catch (ex: Exception) { 32 | this 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/ColorScheme.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | enum class ColorScheme { 4 | FOLLOW_SYSTEM, 5 | 6 | LIGHT, 7 | DARK, 8 | BLACK, 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/HapticAction.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import android.view.HapticFeedbackConstants 4 | import android.view.View 5 | 6 | fun hapticAction( 7 | view: View, 8 | action: () -> Unit, 9 | ) { 10 | action() 11 | view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/HttpClient.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import okhttp3.OkHttpClient 4 | import okhttp3.logging.HttpLoggingInterceptor 5 | import java.util.concurrent.TimeUnit 6 | 7 | fun createOkHttpClient(): OkHttpClient = 8 | OkHttpClient 9 | .Builder() 10 | .connectTimeout(30, TimeUnit.SECONDS) 11 | .readTimeout(90, TimeUnit.SECONDS) 12 | .writeTimeout(30, TimeUnit.SECONDS) 13 | .withTrustedCertificates() 14 | .addInterceptor( 15 | HttpLoggingInterceptor().apply { 16 | level = HttpLoggingInterceptor.Level.NONE 17 | }, 18 | ).build() 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/ImageExtension.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import android.graphics.Bitmap 4 | import android.graphics.BitmapFactory 5 | import android.util.Base64 6 | 7 | fun String.fromBase64(): Bitmap? = 8 | try { 9 | val bytes = Base64.decode(this, Base64.DEFAULT) 10 | BitmapFactory.decodeByteArray(bytes, 0, bytes.size) 11 | } catch (ex: Exception) { 12 | null 13 | } 14 | 15 | fun ByteArray.toBase64(): String = Base64.encodeToString(this, Base64.DEFAULT) 16 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/LibraryOrderingConfiguration.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import androidx.annotation.Keep 4 | import androidx.compose.runtime.saveable.Saver 5 | 6 | @Keep 7 | data class LibraryOrderingConfiguration( 8 | val option: LibraryOrderingOption, 9 | val direction: LibraryOrderingDirection, 10 | ) { 11 | companion object { 12 | val default = 13 | LibraryOrderingConfiguration( 14 | option = LibraryOrderingOption.TITLE, 15 | direction = LibraryOrderingDirection.ASCENDING, 16 | ) 17 | 18 | val saver: Saver = 19 | Saver( 20 | save = { 21 | listOf(it.option.name, it.direction.name) 22 | }, 23 | restore = { 24 | LibraryOrderingConfiguration( 25 | option = LibraryOrderingOption.valueOf(it[0]), 26 | direction = LibraryOrderingDirection.valueOf(it[1]), 27 | ) 28 | }, 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/LibraryOrderingDirection.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | enum class LibraryOrderingDirection { 4 | ASCENDING, 5 | DESCENDING, 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/LibraryOrderingOption.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | enum class LibraryOrderingOption { 4 | TITLE, 5 | AUTHOR, 6 | UPDATED_AT, 7 | CREATED_AT, 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/NetworkQualityService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import android.content.Context 4 | import android.content.Context.CONNECTIVITY_SERVICE 5 | import android.net.ConnectivityManager 6 | import android.net.NetworkCapabilities 7 | import dagger.hilt.android.qualifiers.ApplicationContext 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class NetworkQualityService 13 | @Inject 14 | constructor( 15 | @ApplicationContext private val context: Context, 16 | ) { 17 | private val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager 18 | 19 | fun isNetworkAvailable(): Boolean { 20 | val network = connectivityManager.activeNetwork ?: return false 21 | 22 | val networkCapabilities = 23 | connectivityManager 24 | .getNetworkCapabilities(network) 25 | ?: return false 26 | 27 | return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/common/RunningComponent.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | interface RunningComponent { 4 | fun onCreate() 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/CacheBookStorageProperties.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import android.content.Context 4 | import dagger.hilt.android.qualifiers.ApplicationContext 5 | import java.io.File 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class CacheBookStorageProperties 11 | @Inject 12 | constructor( 13 | @ApplicationContext private val context: Context, 14 | ) { 15 | fun provideBookCache(bookId: String) = 16 | context 17 | .getExternalFilesDir(MEDIA_CACHE_FOLDER) 18 | ?.resolve(bookId) 19 | 20 | fun provideMediaCachePatch( 21 | bookId: String, 22 | fileId: String, 23 | ) = context 24 | .getExternalFilesDir(MEDIA_CACHE_FOLDER) 25 | ?.resolve(bookId) 26 | ?.resolve(fileId) 27 | ?: throw IllegalStateException("") 28 | 29 | fun provideBookCoverPath(bookId: String): File = 30 | context 31 | .getExternalFilesDir(MEDIA_CACHE_FOLDER) 32 | ?.resolve(bookId) 33 | ?.resolve("cover.img") 34 | ?: throw IllegalStateException("") 35 | 36 | companion object { 37 | const val MEDIA_CACHE_FOLDER = "media_cache" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/CacheState.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import androidx.annotation.Keep 4 | import org.grakovne.lissen.domain.CacheStatus 5 | 6 | @Keep 7 | data class CacheState( 8 | val status: CacheStatus, 9 | val progress: Double = 0.0, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/CalculateRequestedChapters.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import org.grakovne.lissen.domain.AllItemsDownloadOption 4 | import org.grakovne.lissen.domain.CurrentItemDownloadOption 5 | import org.grakovne.lissen.domain.DetailedItem 6 | import org.grakovne.lissen.domain.DownloadOption 7 | import org.grakovne.lissen.domain.NumberItemDownloadOption 8 | import org.grakovne.lissen.domain.PlayingChapter 9 | import org.grakovne.lissen.domain.RemainingItemsDownloadOption 10 | import org.grakovne.lissen.playback.service.calculateChapterIndex 11 | 12 | fun calculateRequestedChapters( 13 | book: DetailedItem, 14 | option: DownloadOption, 15 | currentTotalPosition: Double, 16 | ): List { 17 | val chapterIndex = calculateChapterIndex(book, currentTotalPosition) 18 | 19 | return when (option) { 20 | AllItemsDownloadOption -> book.chapters 21 | CurrentItemDownloadOption -> listOfNotNull(book.chapters.getOrNull(chapterIndex)) 22 | is NumberItemDownloadOption -> 23 | book.chapters.subList( 24 | chapterIndex.coerceAtLeast(0), 25 | (chapterIndex + option.itemsNumber).coerceIn(chapterIndex..book.chapters.size), 26 | ) 27 | RemainingItemsDownloadOption -> 28 | book.chapters.subList( 29 | chapterIndex.coerceIn(0, book.chapters.size), 30 | book.chapters.size, 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingExecutor.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.grakovne.lissen.channel.common.MediaChannel 5 | import org.grakovne.lissen.domain.DetailedItem 6 | import org.grakovne.lissen.domain.DownloadOption 7 | 8 | class ContentCachingExecutor( 9 | private val item: DetailedItem, 10 | private val options: DownloadOption, 11 | private val position: Double, 12 | private val contentCachingManager: ContentCachingManager, 13 | ) { 14 | fun run(channel: MediaChannel): Flow = 15 | contentCachingManager 16 | .cacheMediaItem( 17 | mediaItem = item, 18 | option = options, 19 | channel = channel, 20 | currentTotalPosition = position, 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingNotificationService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.content.Context 7 | import dagger.hilt.android.qualifiers.ApplicationContext 8 | import org.grakovne.lissen.R 9 | import org.grakovne.lissen.domain.CacheStatus 10 | import org.grakovne.lissen.domain.DetailedItem 11 | import javax.inject.Inject 12 | import javax.inject.Singleton 13 | import kotlin.math.roundToInt 14 | 15 | @Singleton 16 | class ContentCachingNotificationService 17 | @Inject 18 | constructor( 19 | @ApplicationContext private val context: Context, 20 | ) { 21 | private val service = context.getSystemService(NotificationManager::class.java) 22 | 23 | fun cancel() = 24 | context 25 | .getSystemService(NotificationManager::class.java) 26 | .cancel(NOTIFICATION_ID) 27 | 28 | fun updateCachingNotification(items: List>): Notification { 29 | val cachingItems = 30 | items 31 | .filter { (_, state) -> listOf(CacheStatus.Caching, CacheStatus.Completed).contains(state.status) } 32 | 33 | val itemProgress = 34 | cachingItems.sumOf { (_, state) -> 35 | when (state.status) { 36 | CacheStatus.Caching -> state.progress 37 | CacheStatus.Completed -> 1.0 38 | else -> 0.0 39 | } 40 | } 41 | 42 | return Notification 43 | .Builder(context, createNotificationChannel()) 44 | .setContentText(items.provideCachingTitles()) 45 | .setContentTitle(context.getString(R.string.notification_content_caching_title)) 46 | .setSmallIcon(R.drawable.ic_downloading) 47 | .setOngoing(true) 48 | .setOnlyAlertOnce(true) 49 | .setProgress( 50 | cachingItems.size * 100, 51 | (itemProgress * 100).roundToInt(), 52 | cachingItems.isEmpty(), 53 | ).build() 54 | .also { service.notify(NOTIFICATION_ID, it) } 55 | } 56 | 57 | fun updateErrorNotification(): Notification = 58 | Notification 59 | .Builder(context, createNotificationChannel()) 60 | .setContentTitle(context.getString(R.string.notification_content_caching_error_title)) 61 | .setContentText(context.getString(R.string.notification_content_caching_error_description)) 62 | .setSmallIcon(R.drawable.ic_downloading) 63 | .build() 64 | .also { service.notify(NOTIFICATION_ID, it) } 65 | 66 | private fun createNotificationChannel(): String { 67 | val channelId = "caching_channel" 68 | 69 | val channel = 70 | NotificationChannel( 71 | channelId, 72 | context.getString(R.string.notification_content_caching_channel), 73 | NotificationManager.IMPORTANCE_LOW, 74 | ) 75 | 76 | service.createNotificationChannel(channel) 77 | return channelId 78 | } 79 | 80 | companion object { 81 | private fun List>.provideCachingTitles() = 82 | this 83 | .filter { (_, state) -> CacheStatus.Caching == state.status } 84 | .joinToString(", ") { (key, _) -> key.title } 85 | 86 | const val NOTIFICATION_ID = 2042025 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/ContentCachingProgress.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import kotlinx.coroutines.flow.MutableSharedFlow 4 | import kotlinx.coroutines.flow.asSharedFlow 5 | import org.grakovne.lissen.domain.DetailedItem 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class ContentCachingProgress 11 | @Inject 12 | constructor() { 13 | private val _statusFlow = MutableSharedFlow>(replay = 1) 14 | val statusFlow = _statusFlow.asSharedFlow() 15 | 16 | suspend fun emit( 17 | item: DetailedItem, 18 | progress: CacheState, 19 | ) { 20 | _statusFlow.emit(item to progress) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/FindRelatedFiles.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import org.grakovne.lissen.domain.BookFile 4 | import org.grakovne.lissen.domain.PlayingChapter 5 | 6 | fun findRelatedFiles( 7 | chapter: PlayingChapter, 8 | files: List, 9 | ): List { 10 | val chapterStartRounded = chapter.start.round() 11 | val chapterEndRounded = chapter.end.round() 12 | 13 | val startTimes = 14 | files 15 | .runningFold(0.0) { acc, file -> acc + file.duration } 16 | .dropLast(1) 17 | 18 | val fileStartTimes = files.zip(startTimes) 19 | 20 | return fileStartTimes 21 | .filter { (file, fileStartTime) -> 22 | val fileStartTimeRounded = fileStartTime.round() 23 | val fileEndTimeRounded = (fileStartTime + file.duration).round() 24 | 25 | fileStartTimeRounded < chapterEndRounded && chapterStartRounded < fileEndTimeRounded 26 | }.map { it.first } 27 | } 28 | 29 | private const val PRECISION = 0.01 30 | 31 | private fun Double.round(): Double = kotlin.math.round(this / PRECISION) * PRECISION 32 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/GetImageDimensions.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import android.graphics.BitmapFactory 4 | import okio.Buffer 5 | 6 | fun getImageDimensions(buffer: Buffer): Pair? = 7 | try { 8 | val boundsOptions = 9 | BitmapFactory.Options().apply { 10 | inJustDecodeBounds = true 11 | } 12 | 13 | val peekedSource = buffer.peek() 14 | BitmapFactory.decodeStream(peekedSource.inputStream(), null, boundsOptions) 15 | boundsOptions.outWidth to boundsOptions.outHeight 16 | } catch (ex: Exception) { 17 | null 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import dagger.hilt.components.SingletonComponent 10 | import org.grakovne.lissen.content.cache.dao.CachedBookDao 11 | import org.grakovne.lissen.content.cache.dao.CachedLibraryDao 12 | import javax.inject.Singleton 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | object LocalCacheModule { 17 | private const val DATABASE_NAME = "lissen_local_cache_storage" 18 | 19 | @Provides 20 | @Singleton 21 | fun provideAppDatabase( 22 | @ApplicationContext context: Context, 23 | ): LocalCacheStorage { 24 | val database = 25 | Room.databaseBuilder( 26 | context = context, 27 | klass = LocalCacheStorage::class.java, 28 | name = DATABASE_NAME, 29 | ) 30 | 31 | return database 32 | .addMigrations(MIGRATION_1_2) 33 | .addMigrations(MIGRATION_2_3) 34 | .addMigrations(MIGRATION_3_4) 35 | .addMigrations(MIGRATION_4_5) 36 | .addMigrations(MIGRATION_5_6) 37 | .addMigrations(MIGRATION_6_7) 38 | .addMigrations(MIGRATION_7_8) 39 | .addMigrations(MIGRATION_8_9) 40 | .addMigrations(MIGRATION_9_10) 41 | .addMigrations(MIGRATION_10_11) 42 | .addMigrations(MIGRATION_11_12) 43 | .addMigrations(MIGRATION_12_13) 44 | .addMigrations(MIGRATION_13_14) 45 | .build() 46 | } 47 | 48 | @Provides 49 | @Singleton 50 | fun provideCachedBookDao(appDatabase: LocalCacheStorage): CachedBookDao = appDatabase.cachedBookDao() 51 | 52 | @Provides 53 | @Singleton 54 | fun provideCachedLibraryDao(appDatabase: LocalCacheStorage): CachedLibraryDao = appDatabase.cachedLibraryDao() 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/LocalCacheStorage.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import org.grakovne.lissen.content.cache.dao.CachedBookDao 6 | import org.grakovne.lissen.content.cache.dao.CachedLibraryDao 7 | import org.grakovne.lissen.content.cache.entity.BookChapterEntity 8 | import org.grakovne.lissen.content.cache.entity.BookEntity 9 | import org.grakovne.lissen.content.cache.entity.BookFileEntity 10 | import org.grakovne.lissen.content.cache.entity.CachedLibraryEntity 11 | import org.grakovne.lissen.content.cache.entity.MediaProgressEntity 12 | 13 | @Database( 14 | entities = [ 15 | BookEntity::class, 16 | BookFileEntity::class, 17 | BookChapterEntity::class, 18 | MediaProgressEntity::class, 19 | CachedLibraryEntity::class, 20 | ], 21 | version = 14, 22 | exportSchema = true, 23 | ) 24 | abstract class LocalCacheStorage : RoomDatabase() { 25 | abstract fun cachedBookDao(): CachedBookDao 26 | 27 | abstract fun cachedLibraryDao(): CachedLibraryDao 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/SourceWithBackdropBlur.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import android.graphics.BitmapFactory 6 | import android.graphics.Canvas 7 | import androidx.core.graphics.createBitmap 8 | import androidx.core.graphics.scale 9 | import com.hoko.blur.HokoBlur 10 | import com.hoko.blur.HokoBlur.MODE_STACK 11 | import com.hoko.blur.HokoBlur.SCHEME_NATIVE 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.withContext 14 | import okio.Buffer 15 | import okio.BufferedSource 16 | 17 | suspend fun sourceWithBackdropBlur( 18 | source: BufferedSource, 19 | context: Context, 20 | ): Buffer = 21 | withContext(Dispatchers.IO) { 22 | val peeked = source.peek() 23 | 24 | val original = BitmapFactory.decodeStream(peeked.inputStream()) 25 | val width = original.width 26 | val height = original.height 27 | 28 | val size = maxOf(width, height) 29 | 30 | val blurred = 31 | HokoBlur 32 | .with(context) 33 | .scheme(SCHEME_NATIVE) 34 | .mode(MODE_STACK) 35 | .radius(24) 36 | .forceCopy(true) 37 | .blur(original.scale(size, size)) 38 | 39 | val result = createBitmap(size, size) 40 | val canvas = Canvas(result) 41 | canvas.drawBitmap(blurred, 0f, 0f, null) 42 | 43 | val left = ((size - width) / 2f) 44 | val top = ((size - height) / 2f) 45 | canvas.drawBitmap(original, left, top, null) 46 | 47 | val buffer = Buffer() 48 | result.compress(Bitmap.CompressFormat.JPEG, 90, buffer.outputStream()) 49 | 50 | buffer 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/api/CachedLibraryRepository.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.api 2 | 3 | import org.grakovne.lissen.content.cache.converter.CachedLibraryEntityConverter 4 | import org.grakovne.lissen.content.cache.dao.CachedLibraryDao 5 | import org.grakovne.lissen.domain.Library 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class CachedLibraryRepository 11 | @Inject 12 | constructor( 13 | private val dao: CachedLibraryDao, 14 | private val converter: CachedLibraryEntityConverter, 15 | ) { 16 | suspend fun cacheLibraries(libraries: List) = dao.updateLibraries(libraries) 17 | 18 | suspend fun fetchLibraries() = 19 | dao 20 | .fetchLibraries() 21 | .map { converter.apply(it) } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/api/FetchRequestBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.api 2 | 3 | import androidx.sqlite.db.SimpleSQLiteQuery 4 | import androidx.sqlite.db.SupportSQLiteQuery 5 | 6 | class FetchRequestBuilder { 7 | private var libraryId: String? = null 8 | private var pageNumber: Int = 0 9 | private var pageSize: Int = 20 10 | private var orderField: String = "title" 11 | private var orderDirection: String = "ASC" 12 | 13 | fun libraryId(id: String?) = apply { this.libraryId = id } 14 | 15 | fun pageNumber(number: Int) = apply { this.pageNumber = number } 16 | 17 | fun pageSize(size: Int) = apply { this.pageSize = size } 18 | 19 | fun orderField(field: String) = apply { this.orderField = field } 20 | 21 | fun orderDirection(direction: String) = apply { this.orderDirection = direction } 22 | 23 | fun build(): SupportSQLiteQuery { 24 | val args = mutableListOf() 25 | 26 | val whereClause = 27 | when (val libraryId = libraryId) { 28 | null -> "libraryId IS NULL" 29 | else -> { 30 | args.add(libraryId) 31 | "(libraryId = ? OR libraryId IS NULL)" 32 | } 33 | } 34 | 35 | val field = 36 | when (orderField) { 37 | "title", "author", "duration" -> orderField 38 | else -> "title" 39 | } 40 | 41 | val direction = 42 | when (orderDirection.uppercase()) { 43 | "ASC", "DESC" -> orderDirection.uppercase() 44 | else -> "ASC" 45 | } 46 | 47 | args.add(pageSize) 48 | args.add(pageNumber * pageSize) 49 | 50 | val sql = 51 | """ 52 | SELECT * FROM detailed_books 53 | WHERE $whereClause 54 | ORDER BY $field $direction 55 | LIMIT ? OFFSET ? 56 | """.trimIndent() 57 | 58 | return SimpleSQLiteQuery(sql, args.toTypedArray()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/api/SearchRequestBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.api 2 | 3 | import androidx.sqlite.db.SimpleSQLiteQuery 4 | import androidx.sqlite.db.SupportSQLiteQuery 5 | 6 | class SearchRequestBuilder { 7 | private var libraryId: String? = null 8 | private var searchQuery: String = "" 9 | private var orderField: String = "title" 10 | private var orderDirection: String = "ASC" 11 | 12 | fun libraryId(id: String?) = apply { this.libraryId = id } 13 | 14 | fun searchQuery(query: String) = apply { this.searchQuery = query } 15 | 16 | fun orderField(field: String) = apply { this.orderField = field } 17 | 18 | fun orderDirection(direction: String) = apply { this.orderDirection = direction } 19 | 20 | fun build(): SupportSQLiteQuery { 21 | val args = mutableListOf() 22 | 23 | val whereClause = 24 | buildString { 25 | when (val libraryId = libraryId) { 26 | null -> append("(libraryId IS NULL)") 27 | else -> { 28 | append("(libraryId = ? OR libraryId IS NULL)") 29 | args.add(libraryId) 30 | } 31 | } 32 | append(" AND (title LIKE ? OR author LIKE ? OR seriesNames LIKE ?)") 33 | val pattern = "%$searchQuery%" 34 | args.add(pattern) 35 | args.add(pattern) 36 | args.add(pattern) 37 | } 38 | 39 | val field = 40 | when (orderField) { 41 | "title", "author", "duration" -> orderField 42 | else -> "title" 43 | } 44 | 45 | val direction = 46 | when (orderDirection.uppercase()) { 47 | "ASC", "DESC" -> orderDirection.uppercase() 48 | else -> "ASC" 49 | } 50 | 51 | val sql = 52 | """ 53 | SELECT * FROM detailed_books 54 | WHERE $whereClause 55 | ORDER BY $field $direction 56 | """.trimIndent() 57 | 58 | return SimpleSQLiteQuery(sql, args.toTypedArray()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.converter 2 | 3 | import com.google.gson.reflect.TypeToken 4 | import org.grakovne.lissen.content.cache.converter.CachedBookEntityDetailedConverter.Companion.gson 5 | import org.grakovne.lissen.content.cache.entity.BookEntity 6 | import org.grakovne.lissen.content.cache.entity.BookSeriesDto 7 | import org.grakovne.lissen.domain.Book 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class CachedBookEntityConverter 13 | @Inject 14 | constructor() { 15 | fun apply(entity: BookEntity): Book = 16 | Book( 17 | id = entity.id, 18 | title = entity.title, 19 | subtitle = entity.subtitle, 20 | author = entity.author, 21 | series = 22 | entity 23 | .seriesJson 24 | ?.let { 25 | val type = object : TypeToken>() {}.type 26 | gson.fromJson>(it, type) 27 | }?.joinToString(", ") { series -> 28 | buildString { 29 | append(series.title) 30 | series.sequence 31 | ?.takeIf(String::isNotBlank) 32 | ?.let { append(" #$it") } 33 | } 34 | }, 35 | duration = entity.duration, 36 | hasContent = true, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityDetailedConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.converter 2 | 3 | import com.google.gson.Gson 4 | import com.google.gson.reflect.TypeToken 5 | import org.grakovne.lissen.content.cache.entity.BookSeriesDto 6 | import org.grakovne.lissen.content.cache.entity.CachedBookEntity 7 | import org.grakovne.lissen.domain.BookFile 8 | import org.grakovne.lissen.domain.BookSeries 9 | import org.grakovne.lissen.domain.DetailedItem 10 | import org.grakovne.lissen.domain.MediaProgress 11 | import org.grakovne.lissen.domain.PlayingChapter 12 | import javax.inject.Inject 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class CachedBookEntityDetailedConverter 17 | @Inject 18 | constructor() { 19 | fun apply(entity: CachedBookEntity): DetailedItem = 20 | DetailedItem( 21 | id = entity.detailedBook.id, 22 | title = entity.detailedBook.title, 23 | subtitle = entity.detailedBook.subtitle, 24 | author = entity.detailedBook.author, 25 | narrator = entity.detailedBook.narrator, 26 | libraryId = entity.detailedBook.libraryId, 27 | localProvided = true, 28 | files = 29 | entity.files.map { fileEntity -> 30 | BookFile( 31 | id = fileEntity.bookFileId, 32 | name = fileEntity.name, 33 | duration = fileEntity.duration, 34 | mimeType = fileEntity.mimeType, 35 | ) 36 | }, 37 | chapters = 38 | entity.chapters.map { chapterEntity -> 39 | PlayingChapter( 40 | duration = chapterEntity.duration, 41 | start = chapterEntity.start, 42 | end = chapterEntity.end, 43 | title = chapterEntity.title, 44 | available = chapterEntity.isCached, 45 | id = chapterEntity.bookChapterId, 46 | podcastEpisodeState = null, // currently state is not available for local mode 47 | ) 48 | }, 49 | abstract = entity.detailedBook.abstract, 50 | publisher = entity.detailedBook.publisher, 51 | year = entity.detailedBook.year, 52 | createdAt = entity.detailedBook.createdAt, 53 | updatedAt = entity.detailedBook.updatedAt, 54 | series = 55 | entity 56 | .detailedBook 57 | .seriesJson 58 | ?.let { 59 | val type = object : TypeToken>() {}.type 60 | gson.fromJson>(it, type) 61 | }?.map { 62 | BookSeries( 63 | name = it.title, 64 | serialNumber = it.sequence, 65 | ) 66 | } ?: emptyList(), 67 | progress = 68 | entity.progress?.let { progressEntity -> 69 | MediaProgress( 70 | currentTime = progressEntity.currentTime, 71 | isFinished = progressEntity.isFinished, 72 | lastUpdate = progressEntity.lastUpdate, 73 | ) 74 | }, 75 | ) 76 | 77 | companion object { 78 | val gson = Gson() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedBookEntityRecentConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.converter 2 | 3 | import org.grakovne.lissen.content.cache.entity.BookEntity 4 | import org.grakovne.lissen.domain.RecentBook 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class CachedBookEntityRecentConverter 10 | @Inject 11 | constructor() { 12 | fun apply( 13 | entity: BookEntity, 14 | currentTime: Pair?, 15 | ): RecentBook = 16 | RecentBook( 17 | id = entity.id, 18 | title = entity.title, 19 | subtitle = entity.subtitle, 20 | author = entity.author, 21 | listenedLastUpdate = currentTime?.first ?: 0, 22 | listenedPercentage = 23 | currentTime 24 | ?.second 25 | ?.let { it / entity.duration } 26 | ?.let { it * 100 } 27 | ?.toInt(), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/converter/CachedLibraryEntityConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.converter 2 | 3 | import org.grakovne.lissen.content.cache.entity.CachedLibraryEntity 4 | import org.grakovne.lissen.domain.Library 5 | import javax.inject.Inject 6 | import javax.inject.Singleton 7 | 8 | @Singleton 9 | class CachedLibraryEntityConverter 10 | @Inject 11 | constructor() { 12 | fun apply(entity: CachedLibraryEntity): Library = 13 | Library( 14 | id = entity.id, 15 | title = entity.title, 16 | type = entity.type, 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/dao/CachedLibraryDao.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import androidx.room.Transaction 8 | import org.grakovne.lissen.content.cache.entity.CachedLibraryEntity 9 | import org.grakovne.lissen.domain.Library 10 | 11 | @Dao 12 | interface CachedLibraryDao { 13 | @Transaction 14 | suspend fun updateLibraries(libraries: List) { 15 | val entities = 16 | libraries.map { 17 | CachedLibraryEntity( 18 | id = it.id, 19 | title = it.title, 20 | type = it.type, 21 | ) 22 | } 23 | 24 | upsertLibraries(entities) 25 | deleteLibrariesExcept(entities.map { it.id }) 26 | } 27 | 28 | @Transaction 29 | @Query("SELECT * FROM libraries WHERE id = :libraryId") 30 | suspend fun fetchLibrary(libraryId: String): CachedLibraryEntity? 31 | 32 | @Transaction 33 | @Query("SELECT * FROM libraries") 34 | suspend fun fetchLibraries(): List 35 | 36 | @Insert(onConflict = OnConflictStrategy.REPLACE) 37 | suspend fun upsertLibraries(libraries: List) 38 | 39 | @Query("DELETE FROM libraries WHERE id NOT IN (:ids)") 40 | suspend fun deleteLibrariesExcept(ids: List) 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedBookEntity.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.entity 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.Embedded 5 | import androidx.room.Entity 6 | import androidx.room.ForeignKey 7 | import androidx.room.Index 8 | import androidx.room.PrimaryKey 9 | import androidx.room.Relation 10 | import java.io.Serializable 11 | 12 | @Keep 13 | data class CachedBookEntity( 14 | @Embedded val detailedBook: BookEntity, 15 | @Relation( 16 | parentColumn = "id", 17 | entityColumn = "bookId", 18 | ) 19 | val files: List, 20 | @Relation( 21 | parentColumn = "id", 22 | entityColumn = "bookId", 23 | ) 24 | val chapters: List, 25 | @Relation( 26 | parentColumn = "id", 27 | entityColumn = "bookId", 28 | ) 29 | val progress: MediaProgressEntity?, 30 | ) 31 | 32 | @Keep 33 | @Entity(tableName = "detailed_books") 34 | data class BookEntity( 35 | @PrimaryKey val id: String, 36 | val title: String, 37 | val subtitle: String?, 38 | val author: String?, 39 | val narrator: String?, 40 | val year: String?, 41 | val abstract: String?, 42 | val publisher: String?, 43 | val duration: Int, 44 | val libraryId: String?, 45 | val seriesJson: String?, // List Json 46 | val seriesNames: String?, 47 | val createdAt: Long, 48 | val updatedAt: Long, 49 | ) : Serializable 50 | 51 | @Keep 52 | @Entity( 53 | tableName = "book_files", 54 | foreignKeys = [ 55 | ForeignKey( 56 | entity = BookEntity::class, 57 | parentColumns = ["id"], 58 | childColumns = ["bookId"], 59 | onDelete = ForeignKey.CASCADE, 60 | ), 61 | ], 62 | indices = [Index(value = ["bookId"])], 63 | ) 64 | data class BookFileEntity( 65 | @PrimaryKey(autoGenerate = true) val id: Long = 0L, 66 | val bookFileId: String, 67 | val name: String, 68 | val duration: Double, 69 | val mimeType: String, 70 | val bookId: String, 71 | ) : Serializable 72 | 73 | @Keep 74 | @Entity( 75 | tableName = "book_chapters", 76 | foreignKeys = [ 77 | ForeignKey( 78 | entity = BookEntity::class, 79 | parentColumns = ["id"], 80 | childColumns = ["bookId"], 81 | onDelete = ForeignKey.CASCADE, 82 | ), 83 | ], 84 | indices = [Index(value = ["bookId"])], 85 | ) 86 | data class BookChapterEntity( 87 | @PrimaryKey(autoGenerate = true) val id: Long = 0L, 88 | val bookChapterId: String, 89 | val duration: Double, 90 | val start: Double, 91 | val end: Double, 92 | val title: String, 93 | val bookId: String, 94 | val isCached: Boolean, 95 | ) : Serializable 96 | 97 | @Keep 98 | @Entity( 99 | tableName = "media_progress", 100 | foreignKeys = [ 101 | ForeignKey( 102 | entity = BookEntity::class, 103 | parentColumns = ["id"], 104 | childColumns = ["bookId"], 105 | onDelete = ForeignKey.CASCADE, 106 | ), 107 | ], 108 | indices = [Index(value = ["bookId"])], 109 | ) 110 | data class MediaProgressEntity( 111 | @PrimaryKey val bookId: String, 112 | val currentTime: Double, 113 | val isFinished: Boolean, 114 | val lastUpdate: Long, 115 | ) : Serializable 116 | 117 | @Keep 118 | data class BookSeriesDto( 119 | val title: String, 120 | val sequence: String?, 121 | ) 122 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/entity/CachedLibraryEntity.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.entity 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import org.grakovne.lissen.channel.common.LibraryType 7 | import java.io.Serializable 8 | 9 | @Keep 10 | @Entity( 11 | tableName = "libraries", 12 | ) 13 | data class CachedLibraryEntity( 14 | @PrimaryKey 15 | val id: String, 16 | val title: String, 17 | val type: LibraryType, 18 | ) : Serializable 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/content/cache/entity/PlaybackProgressEntity.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.entity 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Keep 8 | @Entity(tableName = "playback_progress") 9 | data class PlaybackProgressEntity( 10 | @PrimaryKey(autoGenerate = true) val id: Long = 0, 11 | val currentTime: Double, 12 | val totalTime: Double, 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/Book.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class Book( 7 | val id: String, 8 | val subtitle: String?, 9 | val series: String?, 10 | val title: String, 11 | val author: String?, 12 | val duration: Int, 13 | val hasContent: Boolean, 14 | ) 15 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/CacheStatus.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | sealed class CacheStatus { 4 | data object Idle : CacheStatus() 5 | 6 | data object Caching : CacheStatus() 7 | 8 | data object Completed : CacheStatus() 9 | 10 | data object Error : CacheStatus() 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/ContentCachingTask.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | import java.io.Serializable 5 | 6 | @Keep 7 | data class ContentCachingTask( 8 | val itemId: String, 9 | val options: DownloadOption, 10 | val currentPosition: Double, 11 | ) : Serializable 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/DetailedItem.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | import java.io.Serializable 5 | 6 | @Keep 7 | data class DetailedItem( 8 | val id: String, 9 | val title: String, 10 | val subtitle: String?, 11 | val author: String?, 12 | val narrator: String?, 13 | val publisher: String?, 14 | val series: List, 15 | val year: String?, 16 | val abstract: String?, 17 | val files: List, 18 | val chapters: List, 19 | val progress: MediaProgress?, 20 | val libraryId: String?, 21 | val localProvided: Boolean, 22 | val createdAt: Long, 23 | val updatedAt: Long, 24 | ) : Serializable 25 | 26 | @Keep 27 | data class BookFile( 28 | val id: String, 29 | val name: String, 30 | val duration: Double, 31 | val mimeType: String, 32 | ) : Serializable 33 | 34 | @Keep 35 | data class MediaProgress( 36 | val currentTime: Double, 37 | val isFinished: Boolean, 38 | val lastUpdate: Long, 39 | ) : Serializable 40 | 41 | @Keep 42 | data class PlayingChapter( 43 | val available: Boolean, 44 | val podcastEpisodeState: BookChapterState?, 45 | val duration: Double, 46 | val start: Double, 47 | val end: Double, 48 | val title: String, 49 | val id: String, 50 | ) : Serializable 51 | 52 | @Keep 53 | data class BookSeries( 54 | val serialNumber: String?, 55 | val name: String, 56 | ) : Serializable 57 | 58 | @Keep 59 | enum class BookChapterState { 60 | FINISHED, 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/DownloadOption.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | import java.io.Serializable 5 | 6 | @Keep 7 | sealed interface DownloadOption : Serializable 8 | 9 | class NumberItemDownloadOption( 10 | val itemsNumber: Int, 11 | ) : DownloadOption 12 | 13 | data object CurrentItemDownloadOption : DownloadOption 14 | 15 | data object RemainingItemsDownloadOption : DownloadOption 16 | 17 | data object AllItemsDownloadOption : DownloadOption 18 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/Library.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | import org.grakovne.lissen.channel.common.LibraryType 5 | 6 | @Keep 7 | data class Library( 8 | val id: String, 9 | val title: String, 10 | val type: LibraryType, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/PagedItems.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PagedItems( 7 | val items: List, 8 | val currentPage: Int, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/PlaybackProgress.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PlaybackProgress( 7 | val currentChapterTime: Double, 8 | val currentTotalTime: Double, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/PlaybackSession.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PlaybackSession( 7 | val sessionId: String, 8 | val bookId: String, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/RecentBook.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class RecentBook( 7 | val id: String, 8 | val title: String, 9 | val subtitle: String?, 10 | val author: String?, 11 | val listenedPercentage: Int?, 12 | val listenedLastUpdate: Long?, 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/RewindOnPauseTime.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class RewindOnPauseTime( 7 | val enabled: Boolean, 8 | val time: SeekTimeOption, 9 | ) { 10 | companion object { 11 | val Default = 12 | RewindOnPauseTime( 13 | enabled = false, 14 | time = SeekTimeOption.SEEK_5, 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/SeekTime.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class SeekTime( 7 | val rewind: SeekTimeOption, 8 | val forward: SeekTimeOption, 9 | ) { 10 | companion object { 11 | val Default = 12 | SeekTime( 13 | rewind = SeekTimeOption.SEEK_10, 14 | forward = SeekTimeOption.SEEK_30, 15 | ) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/SeekTimeOption.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | enum class SeekTimeOption { 4 | SEEK_5, 5 | SEEK_10, 6 | SEEK_15, 7 | SEEK_30, 8 | SEEK_60, 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/TimerOption.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | sealed interface TimerOption 4 | 5 | class DurationTimerOption( 6 | val duration: Int, 7 | ) : TimerOption 8 | 9 | data object CurrentEpisodeTimerOption : TimerOption 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/UserAccount.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class UserAccount( 7 | val token: String, 8 | val username: String, 9 | val preferredLibraryId: String?, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/domain/connection/ServerRequestHeader.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.domain.connection 2 | 3 | import androidx.annotation.Keep 4 | import java.util.UUID 5 | 6 | @Keep 7 | data class ServerRequestHeader( 8 | val name: String, 9 | val value: String, 10 | val id: UUID = UUID.randomUUID(), 11 | ) { 12 | companion object { 13 | fun empty() = ServerRequestHeader("", "") 14 | 15 | fun ServerRequestHeader.clean(): ServerRequestHeader { 16 | val name = this.name.clean() 17 | val value = this.value.clean() 18 | 19 | return this.copy(name = name, value = value) 20 | } 21 | 22 | /** 23 | * Cleans this string to contain only valid tchar characters for HTTP header names as per RFC 7230. 24 | * 25 | * @return A string containing only allowed tchar characters. 26 | */ 27 | private fun String.clean(): String { 28 | val invalidCharacters = Regex("[^!#\$%&'*+\\-.^_`|~0-9A-Za-z]") 29 | return this.replace(invalidCharacters, "").trim() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterIndex.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import org.grakovne.lissen.domain.DetailedItem 4 | 5 | fun calculateChapterIndex( 6 | item: DetailedItem, 7 | totalPosition: Double, 8 | ): Int { 9 | var accumulatedDuration = 0.0 10 | 11 | for ((index, chapter) in item.chapters.withIndex()) { 12 | accumulatedDuration += chapter.duration 13 | if (totalPosition < accumulatedDuration - 0.1) { 14 | return index 15 | } 16 | } 17 | 18 | return item.chapters.size - 1 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/playback/service/CalculateChapterPosition.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import org.grakovne.lissen.domain.DetailedItem 4 | 5 | fun calculateChapterPosition( 6 | book: DetailedItem, 7 | overallPosition: Double, 8 | ): Double { 9 | var accumulatedDuration = 0.0 10 | 11 | for (chapter in book.chapters) { 12 | val chapterEnd = accumulatedDuration + chapter.duration 13 | if (overallPosition < chapterEnd - 0.1) { 14 | return (overallPosition - accumulatedDuration) 15 | } 16 | accumulatedDuration = chapterEnd 17 | } 18 | 19 | return 0.0 20 | } 21 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/playback/service/MimeTypeProvider.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | class MimeTypeProvider { 4 | companion object { 5 | fun getSupportedMimeTypes() = 6 | listOf( 7 | "audio/flac", 8 | "audio/mp4", 9 | "audio/aac", 10 | "audio/mpeg", 11 | "audio/mp3", 12 | "audio/webm", 13 | "audio/ac3", 14 | "audio/opus", 15 | "audio/vorbis", 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dagger.multibindings.IntoSet 8 | import org.grakovne.lissen.common.RunningComponent 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | interface PlaybackNotificationModule { 13 | @Binds 14 | @IntoSet 15 | fun bindPlaybackNotificationService(service: PlaybackNotificationService): RunningComponent 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/playback/service/PlaybackNotificationService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import androidx.annotation.OptIn 4 | import androidx.media3.common.Player 5 | import androidx.media3.common.util.UnstableApi 6 | import androidx.media3.exoplayer.ExoPlayer 7 | import androidx.media3.exoplayer.source.SilenceMediaSource 8 | import org.grakovne.lissen.common.RunningComponent 9 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 10 | import javax.inject.Inject 11 | import javax.inject.Singleton 12 | 13 | @Singleton 14 | @OptIn(UnstableApi::class) 15 | class PlaybackNotificationService 16 | @Inject 17 | constructor( 18 | private val exoPlayer: ExoPlayer, 19 | private val sharedPreferences: LissenSharedPreferences, 20 | ) : RunningComponent { 21 | override fun onCreate() { 22 | exoPlayer.addListener( 23 | object : Player.Listener { 24 | override fun onPlayWhenReadyChanged( 25 | playWhenReady: Boolean, 26 | reason: Int, 27 | ) { 28 | super.onPlayWhenReadyChanged(playWhenReady, reason) 29 | 30 | if (playWhenReady) { 31 | exoPlayer.setPlaybackSpeed(sharedPreferences.getPlaybackSpeed()) 32 | } 33 | } 34 | 35 | override fun onPositionDiscontinuity( 36 | oldPosition: Player.PositionInfo, 37 | newPosition: Player.PositionInfo, 38 | reason: Int, 39 | ) { 40 | val previousIndex = oldPosition.mediaItemIndex 41 | val currentIndex = newPosition.mediaItemIndex 42 | 43 | if (exoPlayer.currentMediaItem?.mediaId != SilenceMediaSource::class.simpleName) { 44 | return 45 | } 46 | 47 | if (currentIndex != previousIndex) { 48 | val direction = 49 | when ( 50 | currentIndex > previousIndex || 51 | (currentIndex == 0 && previousIndex == exoPlayer.mediaItemCount - 1) 52 | ) { 53 | true -> Direction.FORWARD 54 | false -> Direction.BACKWARD 55 | } 56 | 57 | val nextTrack = 58 | findAvailableTrackIndex(exoPlayer.currentMediaItemIndex, direction, exoPlayer, 0) 59 | nextTrack?.let { exoPlayer.seekTo(it, 0) } 60 | 61 | if (nextTrack == null || nextTrack < currentIndex) { 62 | exoPlayer.pause() 63 | } 64 | } 65 | } 66 | }, 67 | ) 68 | } 69 | 70 | private fun findAvailableTrackIndex( 71 | currentItem: Int, 72 | direction: Direction, 73 | exoPlayer: ExoPlayer, 74 | iteration: Int, 75 | ): Int? { 76 | if (exoPlayer.getMediaItemAt(currentItem).mediaId != SilenceMediaSource::class.simpleName) { 77 | return currentItem 78 | } 79 | 80 | if (iteration > 4096) { 81 | return null 82 | } 83 | 84 | val foundItem = 85 | when (direction) { 86 | Direction.FORWARD -> (currentItem + 1) % exoPlayer.mediaItemCount 87 | Direction.BACKWARD -> if (currentItem - 1 < 0) exoPlayer.mediaItemCount - 1 else currentItem - 1 88 | } 89 | 90 | return findAvailableTrackIndex(foundItem, direction, exoPlayer, iteration + 1) 91 | } 92 | } 93 | 94 | private enum class Direction { 95 | FORWARD, 96 | BACKWARD, 97 | } 98 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/shortcuts/ContinuePlaybackShortcut.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.shortcuts 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.content.pm.ShortcutInfo 6 | import android.content.pm.ShortcutManager 7 | import android.graphics.drawable.Icon 8 | import android.util.Log 9 | import dagger.hilt.android.qualifiers.ApplicationContext 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.SupervisorJob 13 | import kotlinx.coroutines.launch 14 | import org.grakovne.lissen.R 15 | import org.grakovne.lissen.common.RunningComponent 16 | import org.grakovne.lissen.domain.DetailedItem 17 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 18 | import org.grakovne.lissen.ui.activity.AppActivity 19 | import javax.inject.Inject 20 | import javax.inject.Singleton 21 | 22 | @Singleton 23 | class ContinuePlaybackShortcut 24 | @Inject 25 | constructor( 26 | @ApplicationContext private val context: Context, 27 | private val sharedPreferences: LissenSharedPreferences, 28 | ) : RunningComponent { 29 | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 30 | 31 | override fun onCreate() { 32 | Log.d(TAG, "ContinuePlaybackShortcut registered") 33 | 34 | scope.launch { 35 | sharedPreferences 36 | .playingBookFlow 37 | .collect { updateShortcut(it) } 38 | } 39 | } 40 | 41 | private fun updateShortcut(playingBook: DetailedItem?) { 42 | Log.d(TAG, "ContinuePlaybackShortcut is updating") 43 | 44 | val shortcutManager = context.getSystemService(ShortcutManager::class.java) 45 | 46 | if (playingBook == null) { 47 | shortcutManager.removeDynamicShortcuts(listOf(SHORTCUT_TAG)) 48 | return 49 | } 50 | 51 | val shortcut = 52 | ShortcutInfo 53 | .Builder(context, SHORTCUT_TAG) 54 | .setShortLabel(context.getString(R.string.continue_playback_shortcut_title)) 55 | .setLongLabel(context.getString(R.string.continue_playback_shortcut_description)) 56 | .setIcon(Icon.createWithResource(context, R.drawable.ic_play)) 57 | .setIntent( 58 | Intent(context, AppActivity::class.java).apply { 59 | action = "continue_playback" 60 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) 61 | }, 62 | ).build() 63 | 64 | shortcutManager.dynamicShortcuts = listOf(shortcut) 65 | } 66 | 67 | companion object { 68 | private const val SHORTCUT_TAG = "continue_playback_shortcut" 69 | private const val TAG = "ContinuePlaybackShortcut" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/shortcuts/ShortcutsModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.shortcuts 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dagger.multibindings.IntoSet 8 | import org.grakovne.lissen.common.RunningComponent 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | interface ShortcutsModule { 13 | @Binds 14 | @IntoSet 15 | fun bindPlaybackNotificationService(service: ContinuePlaybackShortcut): RunningComponent 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/activity/AppActivity.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.activity 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.enableEdgeToEdge 8 | import androidx.compose.runtime.collectAsState 9 | import androidx.compose.runtime.getValue 10 | import androidx.navigation.compose.rememberNavController 11 | import coil.ImageLoader 12 | import dagger.hilt.android.AndroidEntryPoint 13 | import org.grakovne.lissen.common.NetworkQualityService 14 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 15 | import org.grakovne.lissen.ui.navigation.AppLaunchAction 16 | import org.grakovne.lissen.ui.navigation.AppNavHost 17 | import org.grakovne.lissen.ui.navigation.AppNavigationService 18 | import org.grakovne.lissen.ui.theme.LissenTheme 19 | import javax.inject.Inject 20 | 21 | @AndroidEntryPoint 22 | class AppActivity : ComponentActivity() { 23 | @Inject 24 | lateinit var preferences: LissenSharedPreferences 25 | 26 | @Inject 27 | lateinit var imageLoader: ImageLoader 28 | 29 | @Inject 30 | lateinit var networkQualityService: NetworkQualityService 31 | 32 | private lateinit var appNavigationService: AppNavigationService 33 | 34 | override fun onCreate(savedInstanceState: Bundle?) { 35 | super.onCreate(savedInstanceState) 36 | enableEdgeToEdge() 37 | 38 | setContent { 39 | val colorScheme by preferences 40 | .colorSchemeFlow 41 | .collectAsState(initial = preferences.getColorScheme()) 42 | 43 | LissenTheme(colorScheme) { 44 | val navController = rememberNavController() 45 | appNavigationService = AppNavigationService(navController) 46 | 47 | AppNavHost( 48 | navController = navController, 49 | navigationService = appNavigationService, 50 | preferences = preferences, 51 | imageLoader = imageLoader, 52 | networkQualityService = networkQualityService, 53 | appLaunchAction = getLaunchAction(intent), 54 | ) 55 | } 56 | } 57 | } 58 | 59 | private fun getLaunchAction(intent: Intent?) = 60 | when (intent?.action) { 61 | "continue_playback" -> AppLaunchAction.CONTINUE_PLAYBACK 62 | else -> AppLaunchAction.DEFAULT 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/components/AsyncShimmeringImage.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableStateOf 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.setValue 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.graphics.painter.Painter 15 | import androidx.compose.ui.layout.ContentScale 16 | import coil.ImageLoader 17 | import coil.compose.AsyncImage 18 | import coil.request.ImageRequest 19 | import com.valentinilk.shimmer.shimmer 20 | 21 | @Composable 22 | fun AsyncShimmeringImage( 23 | imageRequest: ImageRequest, 24 | imageLoader: ImageLoader, 25 | contentDescription: String, 26 | contentScale: ContentScale, 27 | modifier: Modifier = Modifier, 28 | error: Painter, 29 | onLoadingStateChanged: (Boolean) -> Unit = {}, 30 | ) { 31 | var isLoading by remember { mutableStateOf(true) } 32 | onLoadingStateChanged(isLoading) 33 | 34 | Box( 35 | modifier = modifier, 36 | contentAlignment = Alignment.Center, 37 | ) { 38 | if (isLoading) { 39 | Box( 40 | modifier = 41 | Modifier 42 | .fillMaxSize() 43 | .shimmer() 44 | .background(Color.Gray), 45 | ) 46 | } 47 | 48 | AsyncImage( 49 | model = imageRequest, 50 | imageLoader = imageLoader, 51 | contentDescription = contentDescription, 52 | contentScale = contentScale, 53 | modifier = Modifier.fillMaxSize(), 54 | onSuccess = { 55 | isLoading = false 56 | onLoadingStateChanged(false) 57 | }, 58 | onError = { 59 | isLoading = false 60 | onLoadingStateChanged(false) 61 | }, 62 | error = error, 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/components/BookCoverFetcher.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.components 2 | 3 | import android.content.Context 4 | import android.net.Uri 5 | import coil.ImageLoader 6 | import coil.decode.ImageSource 7 | import coil.fetch.FetchResult 8 | import coil.fetch.Fetcher 9 | import coil.fetch.SourceResult 10 | import coil.request.Options 11 | import dagger.Module 12 | import dagger.Provides 13 | import dagger.hilt.InstallIn 14 | import dagger.hilt.android.qualifiers.ApplicationContext 15 | import dagger.hilt.components.SingletonComponent 16 | import org.grakovne.lissen.channel.common.ApiResult 17 | import org.grakovne.lissen.content.LissenMediaProvider 18 | import javax.inject.Singleton 19 | 20 | class BookCoverFetcher( 21 | private val mediaChannel: LissenMediaProvider, 22 | private val uri: Uri, 23 | private val context: Context, 24 | ) : Fetcher { 25 | override suspend fun fetch(): FetchResult? = 26 | when (val response = mediaChannel.fetchBookCover(uri.toString())) { 27 | is ApiResult.Error -> null 28 | is ApiResult.Success -> { 29 | val stream = response.data 30 | val imageSource = ImageSource(stream, context) 31 | 32 | SourceResult( 33 | source = imageSource, 34 | mimeType = null, 35 | dataSource = coil.decode.DataSource.NETWORK, 36 | ) 37 | } 38 | } 39 | } 40 | 41 | class BookCoverFetcherFactory( 42 | private val dataProvider: LissenMediaProvider, 43 | private val context: Context, 44 | ) : Fetcher.Factory { 45 | override fun create( 46 | data: Uri, 47 | options: Options, 48 | imageLoader: ImageLoader, 49 | ) = BookCoverFetcher(dataProvider, data, context) 50 | } 51 | 52 | @Module 53 | @InstallIn(SingletonComponent::class) 54 | object ImageLoaderModule { 55 | @Singleton 56 | @Provides 57 | fun provideBookCoverFetcherFactory( 58 | mediaChannel: LissenMediaProvider, 59 | @ApplicationContext context: Context, 60 | ): BookCoverFetcherFactory = BookCoverFetcherFactory(mediaChannel, context) 61 | 62 | @Singleton 63 | @Provides 64 | fun provideCustomImageLoader( 65 | @ApplicationContext context: Context, 66 | bookCoverFetcherFactory: BookCoverFetcherFactory, 67 | ): ImageLoader = 68 | ImageLoader 69 | .Builder(context) 70 | .components { add(bookCoverFetcherFactory) } 71 | .build() 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/components/ImageLoaderEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.components 2 | 3 | import coil.ImageLoader 4 | import dagger.hilt.EntryPoint 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | 8 | @EntryPoint 9 | @InstallIn(SingletonComponent::class) 10 | interface ImageLoaderEntryPoint { 11 | fun getImageLoader(): ImageLoader 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/extensions/AsyncExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.extensions 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.coroutineScope 5 | import kotlinx.coroutines.delay 6 | import kotlin.system.measureTimeMillis 7 | 8 | suspend fun withMinimumTime( 9 | minimumTimeMillis: Long, 10 | block: suspend CoroutineScope.() -> T, 11 | ): T { 12 | var result: T 13 | val elapsedTime = 14 | measureTimeMillis { 15 | result = coroutineScope { block() } 16 | } 17 | val remainingTime = minimumTimeMillis - elapsedTime 18 | if (remainingTime > 0) { 19 | delay(remainingTime) 20 | } 21 | return result 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/extensions/TimeExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.extensions 2 | 3 | import java.util.Locale 4 | 5 | fun Int.formatLeadingMinutes(): String { 6 | val minutes = this / 60 7 | val seconds = this % 60 8 | 9 | return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) 10 | } 11 | 12 | fun Int.formatFully(): String { 13 | val hours = this / 3600 14 | val minutes = (this % 3600) / 60 15 | val seconds = this % 60 16 | return if (hours > 0) { 17 | String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) 18 | } else { 19 | String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/icons/Search.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright @alexstyl 3 | * 4 | * Permission to use, copy, modify, and/or distribute this software for any 5 | * purpose with or without fee is hereby granted, provided that the above 6 | * copyright notice and this permission notice appear in all copies. 7 | * 8 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | * 16 | * File has been copied from https://composeicons.com/icons/lucide/search-check under ISC Licence 17 | */ 18 | package org.grakovne.lissen.ui.icons 19 | 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.graphics.PathFillType 22 | import androidx.compose.ui.graphics.SolidColor 23 | import androidx.compose.ui.graphics.StrokeCap 24 | import androidx.compose.ui.graphics.StrokeJoin 25 | import androidx.compose.ui.graphics.vector.ImageVector 26 | import androidx.compose.ui.graphics.vector.path 27 | import androidx.compose.ui.unit.dp 28 | 29 | val Search: ImageVector 30 | get() { 31 | if (_Search != null) { 32 | return _Search!! 33 | } 34 | _Search = 35 | ImageVector 36 | .Builder( 37 | name = "Search", 38 | defaultWidth = 24.dp, 39 | defaultHeight = 24.dp, 40 | viewportWidth = 24f, 41 | viewportHeight = 24f, 42 | ).apply { 43 | path( 44 | fill = null, 45 | fillAlpha = 1.0f, 46 | stroke = SolidColor(Color(0xFF000000)), 47 | strokeAlpha = 1.0f, 48 | strokeLineWidth = 2f, 49 | strokeLineCap = StrokeCap.Round, 50 | strokeLineJoin = StrokeJoin.Round, 51 | strokeLineMiter = 1.0f, 52 | pathFillType = PathFillType.NonZero, 53 | ) { 54 | moveTo(19f, 11f) 55 | arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 11f, 19f) 56 | arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 3f, 11f) 57 | arcTo(8f, 8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 19f, 11f) 58 | close() 59 | } 60 | path( 61 | fill = null, 62 | fillAlpha = 1.0f, 63 | stroke = SolidColor(Color(0xFF000000)), 64 | strokeAlpha = 1.0f, 65 | strokeLineWidth = 2f, 66 | strokeLineCap = StrokeCap.Round, 67 | strokeLineJoin = StrokeJoin.Round, 68 | strokeLineMiter = 1.0f, 69 | pathFillType = PathFillType.NonZero, 70 | ) { 71 | moveTo(21f, 21f) 72 | lineToRelative(-4.3f, -4.3f) 73 | } 74 | }.build() 75 | return _Search!! 76 | } 77 | 78 | private var _Search: ImageVector? = null 79 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/navigation/AppLaunchAction.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.navigation 2 | 3 | enum class AppLaunchAction { 4 | CONTINUE_PLAYBACK, 5 | DEFAULT, 6 | } 7 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/navigation/AppNavigationService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.navigation 2 | 3 | import android.net.Uri 4 | import androidx.navigation.NavHostController 5 | 6 | class AppNavigationService( 7 | private val host: NavHostController, 8 | ) { 9 | fun showLibrary(clearHistory: Boolean = false) { 10 | host.navigate(ROUTE_LIBRARY) { 11 | launchSingleTop = true 12 | popUpTo(host.graph.startDestinationId) { inclusive = clearHistory } 13 | } 14 | } 15 | 16 | fun showPlayer( 17 | bookId: String, 18 | bookTitle: String, 19 | bookSubtitle: String?, 20 | startInstantly: Boolean = false, 21 | ) { 22 | val route = 23 | buildString { 24 | append("$ROUTE_PLAYER/$bookId") 25 | append("?bookTitle=${Uri.encode(bookTitle)}") 26 | append("&bookSubtitle=${Uri.encode(bookSubtitle ?: "")}") 27 | append("&startInstantly=$startInstantly") 28 | } 29 | host.navigate(route) { launchSingleTop = true } 30 | } 31 | 32 | fun showSettings() = host.navigate(ROUTE_SETTINGS) 33 | 34 | fun showCustomHeadersSettings() = host.navigate("$ROUTE_SETTINGS/custom_headers") 35 | 36 | fun showSeekSettings() = host.navigate("$ROUTE_SETTINGS/seek_settings") 37 | 38 | fun showCachedItemsSettings() = host.navigate("$ROUTE_SETTINGS/cached_items") 39 | 40 | fun showLogin() { 41 | host.navigate(ROUTE_LOGIN) { 42 | popUpTo(0) { inclusive = true } 43 | launchSingleTop = true 44 | } 45 | } 46 | 47 | private companion object { 48 | const val ROUTE_LIBRARY = "library_screen" 49 | const val ROUTE_PLAYER = "player_screen" 50 | const val ROUTE_SETTINGS = "settings_screen" 51 | const val ROUTE_LOGIN = "login_screen" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/common/RequestNotificationPermissions.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.common 2 | 3 | import android.Manifest 4 | import android.content.pm.PackageManager 5 | import android.os.Build 6 | import androidx.activity.compose.rememberLauncherForActivityResult 7 | import androidx.activity.result.contract.ActivityResultContracts 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.LaunchedEffect 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.core.content.ContextCompat 12 | 13 | @Composable 14 | fun RequestNotificationPermissions() { 15 | val context = LocalContext.current 16 | 17 | val permissionRequestLauncher = 18 | rememberLauncherForActivityResult( 19 | contract = ActivityResultContracts.RequestPermission(), 20 | onResult = { }, 21 | ) 22 | LaunchedEffect(Unit) { 23 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 24 | val permissionStatus = 25 | ContextCompat.checkSelfPermission( 26 | context, 27 | Manifest.permission.POST_NOTIFICATIONS, 28 | ) 29 | 30 | when (permissionStatus == PackageManager.PERMISSION_GRANTED) { 31 | true -> {} 32 | false -> permissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/library/PreferredLibrarySettingComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.library 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.outlined.NotInterested 5 | import androidx.compose.material.icons.outlined.Podcasts 6 | import androidx.compose.runtime.Composable 7 | import org.grakovne.lissen.channel.common.LibraryType 8 | import org.grakovne.lissen.domain.Library 9 | import org.grakovne.lissen.ui.icons.BookHeadphones 10 | import org.grakovne.lissen.ui.screens.settings.composable.CommonSettingsItem 11 | import org.grakovne.lissen.ui.screens.settings.composable.CommonSettingsItemComposable 12 | 13 | @Composable 14 | fun PreferredLibrarySettingComposable( 15 | libraries: List, 16 | preferredLibrary: Library?, 17 | onDismissRequest: () -> Unit, 18 | onItemSelected: (Library) -> Unit, 19 | ) { 20 | CommonSettingsItemComposable( 21 | items = libraries.map { CommonSettingsItem(it.id, it.title, it.type.provideIcon()) }, 22 | selectedItem = preferredLibrary?.let { CommonSettingsItem(it.id, it.title, it.type.provideIcon()) }, 23 | onDismissRequest = { onDismissRequest() }, 24 | onItemSelected = { item -> 25 | val selectedItem = 26 | libraries.find { it.id == item.id } 27 | ?: return@CommonSettingsItemComposable 28 | 29 | if (selectedItem != preferredLibrary) { 30 | onItemSelected(selectedItem) 31 | } 32 | }, 33 | ) 34 | } 35 | 36 | fun LibraryType.provideIcon() = 37 | when (this) { 38 | LibraryType.LIBRARY -> BookHeadphones 39 | LibraryType.PODCAST -> Icons.Outlined.Podcasts 40 | LibraryType.UNKNOWN -> Icons.Outlined.NotInterested 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/LibrarySwitchComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.library.composables 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.width 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.outlined.ArrowDropDown 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.clip 13 | import androidx.compose.ui.unit.dp 14 | 15 | @Composable 16 | fun LibrarySwitchComposable(onclick: () -> Unit) { 17 | Spacer(modifier = Modifier.width(4.dp)) 18 | 19 | Icon( 20 | modifier = 21 | Modifier 22 | .clip(RoundedCornerShape(12.dp)) 23 | .clickable { onclick() }, 24 | imageVector = Icons.Outlined.ArrowDropDown, 25 | contentDescription = null, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/library/composables/placeholder/LibraryPlaceholderComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.library.composables.placeholder 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.aspectRatio 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.layout.width 14 | import androidx.compose.foundation.shape.RoundedCornerShape 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.clip 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.unit.dp 21 | import com.valentinilk.shimmer.shimmer 22 | 23 | @Composable 24 | fun LibraryPlaceholderComposable(itemCount: Int = 15) { 25 | Column( 26 | modifier = Modifier.fillMaxWidth(), 27 | ) { 28 | repeat(itemCount) { LibraryItemPlaceholderComposable() } 29 | } 30 | } 31 | 32 | @Composable 33 | fun LibraryItemPlaceholderComposable() { 34 | Row( 35 | modifier = 36 | Modifier 37 | .fillMaxWidth() 38 | .padding(horizontal = 4.dp, vertical = 8.dp), 39 | verticalAlignment = Alignment.CenterVertically, 40 | ) { 41 | Box( 42 | modifier = 43 | Modifier 44 | .size(64.dp) 45 | .aspectRatio(1f) 46 | .clip(RoundedCornerShape(4.dp)) 47 | .shimmer() 48 | .background(Color.Gray), 49 | ) 50 | 51 | Spacer(modifier = Modifier.width(16.dp)) 52 | 53 | Column(modifier = Modifier.weight(1f)) { 54 | Box( 55 | modifier = 56 | Modifier 57 | .fillMaxWidth() 58 | .height(16.dp) 59 | .clip(RoundedCornerShape(4.dp)) 60 | .shimmer() 61 | .background(Color.Gray), 62 | ) 63 | 64 | Spacer(modifier = Modifier.height(8.dp)) 65 | 66 | Box( 67 | modifier = 68 | Modifier 69 | .fillMaxWidth(0.6f) 70 | .height(12.dp) 71 | .clip(RoundedCornerShape(4.dp)) 72 | .shimmer() 73 | .background(Color.Gray), 74 | ) 75 | } 76 | 77 | Spacer(modifier = Modifier.width(16.dp)) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.library.paging 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import org.grakovne.lissen.content.LissenMediaProvider 6 | import org.grakovne.lissen.domain.Book 7 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 8 | 9 | class LibraryDefaultPagingSource( 10 | private val preferences: LissenSharedPreferences, 11 | private val mediaChannel: LissenMediaProvider, 12 | ) : PagingSource() { 13 | override fun getRefreshKey(state: PagingState) = 14 | state 15 | .anchorPosition 16 | ?.let { anchorPosition -> 17 | state 18 | .closestPageToPosition(anchorPosition) 19 | ?.prevKey 20 | ?.plus(1) 21 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) 22 | } 23 | 24 | override suspend fun load(params: LoadParams): LoadResult { 25 | val libraryId = 26 | preferences 27 | .getPreferredLibrary() 28 | ?.id 29 | ?: return LoadResult.Page(emptyList(), null, null) 30 | 31 | return mediaChannel 32 | .fetchBooks( 33 | libraryId = libraryId, 34 | pageSize = params.loadSize, 35 | pageNumber = params.key ?: 0, 36 | ).fold( 37 | onSuccess = { result -> 38 | val nextPage = if (result.items.isEmpty()) null else result.currentPage + 1 39 | val prevKey = if (result.currentPage == 0) null else result.currentPage - 1 40 | 41 | LoadResult.Page( 42 | data = result.items, 43 | prevKey = prevKey, 44 | nextKey = nextPage, 45 | ) 46 | }, 47 | onFailure = { 48 | LoadResult.Page(emptyList(), null, null) 49 | }, 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.library.paging 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import org.grakovne.lissen.content.LissenMediaProvider 6 | import org.grakovne.lissen.domain.Book 7 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 8 | 9 | class LibrarySearchPagingSource( 10 | private val preferences: LissenSharedPreferences, 11 | private val mediaChannel: LissenMediaProvider, 12 | private val searchToken: String, 13 | private val limit: Int, 14 | ) : PagingSource() { 15 | override fun getRefreshKey(state: PagingState) = null 16 | 17 | override suspend fun load(params: LoadParams): LoadResult { 18 | val libraryId = 19 | preferences 20 | .getPreferredLibrary() 21 | ?.id 22 | ?: return LoadResult.Page(emptyList(), null, null) 23 | 24 | if (searchToken.isBlank()) { 25 | return LoadResult.Page(emptyList(), null, null) 26 | } 27 | 28 | return mediaChannel 29 | .searchBooks(libraryId, searchToken, limit) 30 | .fold( 31 | onSuccess = { LoadResult.Page(it, null, null) }, 32 | onFailure = { LoadResult.Page(emptyList(), null, null) }, 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/player/ChapterSearchActionComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.player 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.height 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.foundation.text.BasicTextField 9 | import androidx.compose.foundation.text.KeyboardOptions 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.outlined.Clear 12 | import androidx.compose.material3.Icon 13 | import androidx.compose.material3.IconButton 14 | import androidx.compose.material3.MaterialTheme.colorScheme 15 | import androidx.compose.material3.MaterialTheme.typography 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.LaunchedEffect 19 | import androidx.compose.runtime.mutableStateOf 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.focus.FocusRequester 24 | import androidx.compose.ui.focus.focusRequester 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.text.input.ImeAction 27 | import androidx.compose.ui.unit.dp 28 | import org.grakovne.lissen.R 29 | 30 | @Composable 31 | fun ChapterSearchActionComposable(onSearchRequested: (String) -> Unit) { 32 | val focusRequester = remember { FocusRequester() } 33 | val searchText = remember { mutableStateOf("") } 34 | 35 | fun updateSearchText(text: String) { 36 | searchText.value = text 37 | onSearchRequested(searchText.value) 38 | } 39 | 40 | LaunchedEffect(Unit) { 41 | focusRequester.requestFocus() 42 | } 43 | 44 | Row( 45 | verticalAlignment = Alignment.CenterVertically, 46 | modifier = 47 | Modifier 48 | .padding(start = 48.dp, end = 8.dp) 49 | .height(40.dp), 50 | ) { 51 | Row( 52 | verticalAlignment = Alignment.CenterVertically, 53 | modifier = 54 | Modifier 55 | .weight(1f) 56 | .height(36.dp) 57 | .background(colorScheme.surfaceContainer, RoundedCornerShape(36.dp)) 58 | .padding(start = 16.dp, end = 4.dp), 59 | ) { 60 | BasicTextField( 61 | value = searchText.value, 62 | onValueChange = { updateSearchText(it) }, 63 | modifier = 64 | Modifier 65 | .weight(1f) 66 | .focusRequester(focusRequester), 67 | textStyle = typography.bodyLarge.copy(color = colorScheme.onBackground), 68 | singleLine = true, 69 | keyboardOptions = 70 | KeyboardOptions.Default.copy( 71 | imeAction = ImeAction.Search, 72 | ), 73 | decorationBox = { innerTextField -> 74 | if (searchText.value.isEmpty()) { 75 | Text( 76 | text = stringResource(R.string.chapter_search_hint), 77 | color = colorScheme.onSurfaceVariant, 78 | style = typography.bodyLarge, 79 | ) 80 | } 81 | innerTextField() 82 | }, 83 | ) 84 | 85 | if (searchText.value.isNotEmpty()) { 86 | IconButton( 87 | modifier = Modifier.height(36.dp), 88 | onClick = { updateSearchText("") }, 89 | ) { 90 | Icon( 91 | imageVector = Icons.Outlined.Clear, 92 | contentDescription = "Clear", 93 | ) 94 | } 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/common/ProvideForwardIcon.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.player.composable.common 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.rounded.FastForward 5 | import org.grakovne.lissen.domain.SeekTime 6 | 7 | fun provideForwardIcon(seekTime: SeekTime) = Icons.Rounded.FastForward 8 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/common/ProvideNowPlayingTitle.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.player.composable.common 2 | 3 | import android.content.Context 4 | import org.grakovne.lissen.R 5 | import org.grakovne.lissen.channel.common.LibraryType 6 | 7 | fun provideNowPlayingTitle( 8 | libraryType: LibraryType, 9 | context: Context, 10 | ) = when (libraryType) { 11 | LibraryType.LIBRARY -> context.getString(R.string.player_screen_library_playing_title) 12 | LibraryType.PODCAST -> context.getString(R.string.player_screen_podcast_playing_title) 13 | LibraryType.UNKNOWN -> context.getString(R.string.player_screen_items_playing_title) 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/common/ProvideReplayIcon.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.player.composable.common 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.rounded.FastRewind 5 | import org.grakovne.lissen.domain.SeekTime 6 | 7 | fun provideReplayIcon(seekTime: SeekTime) = Icons.Rounded.FastRewind 8 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/fallback/PlayingQueueFallbackComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.player.composable.fallback 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.res.stringResource 13 | import androidx.compose.ui.text.style.TextAlign 14 | import androidx.compose.ui.unit.dp 15 | import androidx.compose.ui.unit.sp 16 | import org.grakovne.lissen.R 17 | import org.grakovne.lissen.channel.common.LibraryType 18 | import org.grakovne.lissen.viewmodel.LibraryViewModel 19 | 20 | @Composable 21 | fun PlayingQueueFallbackComposable( 22 | modifier: Modifier = Modifier, 23 | libraryViewModel: LibraryViewModel, 24 | ) { 25 | Column( 26 | modifier = 27 | modifier 28 | .fillMaxSize() 29 | .padding(horizontal = 16.dp), 30 | verticalArrangement = Arrangement.Center, 31 | horizontalAlignment = Alignment.CenterHorizontally, 32 | ) { 33 | Text( 34 | textAlign = TextAlign.Center, 35 | text = 36 | when (libraryViewModel.fetchPreferredLibraryType()) { 37 | LibraryType.LIBRARY -> stringResource(R.string.chapters_list_empty) 38 | LibraryType.PODCAST -> stringResource(R.string.episodes_list_empty) 39 | LibraryType.UNKNOWN -> stringResource(R.string.items_list_empty) 40 | }, 41 | style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp), 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/PlayingQueuePlaceholderComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.player.composable.placeholder 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material3.HorizontalDivider 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.MaterialTheme.typography 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.draw.clip 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.platform.LocalContext 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.unit.dp 23 | import com.valentinilk.shimmer.shimmer 24 | import org.grakovne.lissen.ui.screens.player.composable.common.provideNowPlayingTitle 25 | import org.grakovne.lissen.viewmodel.LibraryViewModel 26 | 27 | @Composable 28 | fun PlayingQueuePlaceholderComposable( 29 | libraryViewModel: LibraryViewModel, 30 | modifier: Modifier = Modifier, 31 | ) { 32 | val context = LocalContext.current 33 | 34 | Column(modifier = modifier.padding(horizontal = 16.dp)) { 35 | Text( 36 | text = provideNowPlayingTitle(libraryViewModel.fetchPreferredLibraryType(), context), 37 | fontSize = typography.titleMedium.fontSize * 1.25f, 38 | fontWeight = FontWeight.SemiBold, 39 | color = MaterialTheme.colorScheme.primary, 40 | modifier = Modifier.padding(horizontal = 6.dp), 41 | ) 42 | 43 | Spacer(modifier = Modifier.height(12.dp)) 44 | 45 | LazyColumn( 46 | modifier = 47 | Modifier 48 | .fillMaxWidth(), 49 | ) { 50 | items(10) { 51 | Box( 52 | modifier = 53 | Modifier 54 | .fillMaxWidth() 55 | .height(36.dp) 56 | .clip(RoundedCornerShape(8.dp)) 57 | .shimmer() 58 | .background(Color.Gray), 59 | ) 60 | 61 | Spacer(Modifier.height(8.dp)) 62 | 63 | HorizontalDivider( 64 | thickness = 1.dp, 65 | modifier = Modifier.padding(horizontal = 4.dp), 66 | ) 67 | 68 | Spacer(Modifier.height(8.dp)) 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/player/composable/placeholder/TrackDetailsPlaceholderComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.player.composable.placeholder 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.aspectRatio 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.heightIn 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material3.MaterialTheme.colorScheme 14 | import androidx.compose.material3.MaterialTheme.typography 15 | import androidx.compose.material3.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.draw.clip 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.platform.LocalConfiguration 22 | import androidx.compose.ui.res.stringResource 23 | import androidx.compose.ui.text.font.FontWeight 24 | import androidx.compose.ui.text.style.TextAlign 25 | import androidx.compose.ui.text.style.TextOverflow 26 | import androidx.compose.ui.unit.dp 27 | import com.valentinilk.shimmer.shimmer 28 | import org.grakovne.lissen.R 29 | 30 | @Composable 31 | fun TrackDetailsPlaceholderComposable( 32 | bookTitle: String, 33 | bookSubtitle: String?, 34 | modifier: Modifier = Modifier, 35 | ) { 36 | val configuration = LocalConfiguration.current 37 | val screenHeight = configuration.screenHeightDp.dp 38 | val maxImageHeight = screenHeight * 0.33f 39 | 40 | Column( 41 | horizontalAlignment = Alignment.CenterHorizontally, 42 | modifier = modifier, 43 | ) { 44 | Box( 45 | modifier = 46 | Modifier 47 | .heightIn(max = maxImageHeight) 48 | .aspectRatio(1f) 49 | .clip(RoundedCornerShape(8.dp)) 50 | .shimmer() 51 | .background(Color.Gray), 52 | ) 53 | 54 | Spacer(modifier = Modifier.height(12.dp)) 55 | 56 | Text( 57 | text = bookTitle, 58 | style = typography.headlineSmall, 59 | fontWeight = FontWeight.SemiBold, 60 | color = colorScheme.onBackground, 61 | textAlign = TextAlign.Center, 62 | overflow = TextOverflow.Ellipsis, 63 | maxLines = 2, 64 | modifier = Modifier.padding(horizontal = 16.dp), 65 | ) 66 | 67 | Spacer(modifier = Modifier.height(2.dp)) 68 | 69 | bookSubtitle 70 | ?.takeIf { it.isNotBlank() } 71 | ?.let { 72 | Text( 73 | text = it, 74 | style = typography.bodyMedium, 75 | color = colorScheme.onBackground.copy(alpha = 0.6f), 76 | textAlign = TextAlign.Center, 77 | overflow = TextOverflow.Ellipsis, 78 | maxLines = 1, 79 | modifier = 80 | Modifier 81 | .fillMaxWidth() 82 | .padding(horizontal = 16.dp), 83 | ) 84 | 85 | Spacer(modifier = Modifier.height(2.dp)) 86 | } 87 | 88 | Text( 89 | text = stringResource(R.string.player_screen_now_playing_title_chapter_of, 100, "1000"), 90 | style = typography.bodyMedium, 91 | color = Color.Transparent, 92 | textAlign = TextAlign.Center, 93 | modifier = 94 | Modifier 95 | .clip(RoundedCornerShape(8.dp)) 96 | .shimmer() 97 | .background(Color.Gray), 98 | ) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/CustomHeaderComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.advanced 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.DeleteOutline 12 | import androidx.compose.material3.Card 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.IconButton 15 | import androidx.compose.material3.MaterialTheme.colorScheme 16 | import androidx.compose.material3.OutlinedTextField 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.stringResource 22 | import androidx.compose.ui.unit.dp 23 | import org.grakovne.lissen.R 24 | import org.grakovne.lissen.domain.connection.ServerRequestHeader 25 | import org.grakovne.lissen.domain.connection.ServerRequestHeader.Companion.clean 26 | 27 | @Composable 28 | fun CustomHeaderComposable( 29 | header: ServerRequestHeader, 30 | onChanged: (ServerRequestHeader) -> Unit, 31 | onDelete: (ServerRequestHeader) -> Unit, 32 | ) { 33 | Card( 34 | shape = RoundedCornerShape(12.dp), 35 | modifier = 36 | Modifier 37 | .fillMaxWidth() 38 | .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 16.dp), 39 | ) { 40 | Row( 41 | modifier = 42 | Modifier 43 | .fillMaxWidth() 44 | .background(colorScheme.background), 45 | verticalAlignment = Alignment.CenterVertically, 46 | ) { 47 | Column( 48 | modifier = Modifier.weight(1f), 49 | ) { 50 | OutlinedTextField( 51 | value = header.name, 52 | onValueChange = { onChanged(header.copy(name = it, value = header.value).clean()) }, 53 | label = { Text(stringResource(R.string.custom_header_hint_name)) }, 54 | singleLine = true, 55 | shape = RoundedCornerShape(16.dp), 56 | modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), 57 | ) 58 | 59 | OutlinedTextField( 60 | value = header.value, 61 | onValueChange = { onChanged(header.copy(name = header.name, value = it).clean()) }, 62 | label = { Text(stringResource(R.string.custom_header_hint_value)) }, 63 | singleLine = true, 64 | shape = RoundedCornerShape(16.dp), 65 | modifier = Modifier.fillMaxWidth(), 66 | ) 67 | } 68 | 69 | IconButton( 70 | onClick = { onDelete(header) }, 71 | ) { 72 | Icon( 73 | imageVector = Icons.Default.DeleteOutline, 74 | contentDescription = "Delete from cache", 75 | tint = colorScheme.error, 76 | modifier = Modifier.size(32.dp), 77 | ) 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsFallbackComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.advanced.cache 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Arrangement 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.lazy.LazyColumn 13 | import androidx.compose.foundation.shape.CircleShape 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.automirrored.filled.LibraryBooks 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Text 19 | import androidx.compose.runtime.Composable 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.draw.clip 23 | import androidx.compose.ui.graphics.Color 24 | import androidx.compose.ui.platform.LocalConfiguration 25 | import androidx.compose.ui.res.stringResource 26 | import androidx.compose.ui.text.style.TextAlign 27 | import androidx.compose.ui.unit.dp 28 | import org.grakovne.lissen.R 29 | 30 | @Composable 31 | fun CachedItemsFallbackComposable() { 32 | LazyColumn( 33 | modifier = Modifier.fillMaxSize(), 34 | horizontalAlignment = Alignment.CenterHorizontally, 35 | verticalArrangement = Arrangement.Center, 36 | ) { 37 | item { 38 | val configuration = LocalConfiguration.current 39 | val screenHeight = configuration.screenHeightDp.dp 40 | 41 | Column( 42 | horizontalAlignment = Alignment.CenterHorizontally, 43 | modifier = 44 | Modifier 45 | .fillMaxWidth() 46 | .height(screenHeight / 2), 47 | ) { 48 | Box( 49 | modifier = 50 | Modifier 51 | .size(120.dp) 52 | .clip(CircleShape) 53 | .background(MaterialTheme.colorScheme.surfaceContainer), 54 | contentAlignment = Alignment.Center, 55 | ) { 56 | Icon( 57 | imageVector = Icons.AutoMirrored.Filled.LibraryBooks, 58 | contentDescription = "Library placeholder", 59 | tint = Color.White, 60 | modifier = Modifier.size(64.dp), 61 | ) 62 | } 63 | 64 | Text( 65 | text = stringResource(R.string.offline_cache_is_empty), 66 | style = MaterialTheme.typography.headlineSmall, 67 | textAlign = TextAlign.Center, 68 | modifier = Modifier.padding(top = 36.dp), 69 | ) 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/advanced/cache/CachedItemsPageSource.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.advanced.cache 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import org.grakovne.lissen.content.cache.LocalCacheRepository 6 | import org.grakovne.lissen.domain.DetailedItem 7 | 8 | class CachedItemsPageSource( 9 | private val localCacheRepository: LocalCacheRepository, 10 | ) : PagingSource() { 11 | override fun getRefreshKey(state: PagingState): Int? = 12 | state 13 | .anchorPosition 14 | ?.let { anchorPosition -> 15 | state 16 | .closestPageToPosition(anchorPosition) 17 | ?.prevKey 18 | ?.plus(1) 19 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) 20 | } 21 | 22 | override suspend fun load(params: LoadParams): LoadResult = 23 | localCacheRepository 24 | .fetchDetailedItems( 25 | pageSize = params.loadSize, 26 | pageNumber = params.key ?: 0, 27 | ).fold( 28 | onSuccess = { result -> 29 | val nextPage = if (result.items.isEmpty()) null else result.currentPage + 1 30 | val prevKey = if (result.currentPage == 0) null else result.currentPage - 1 31 | 32 | LoadResult.Page( 33 | data = result.items, 34 | prevKey = prevKey, 35 | nextKey = nextPage, 36 | ) 37 | }, 38 | onFailure = { 39 | LoadResult.Page(emptyList(), null, null) 40 | }, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdditionalComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.composable 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.HorizontalDivider 7 | import androidx.compose.material3.MaterialTheme.colorScheme 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.text.TextStyle 13 | import androidx.compose.ui.text.font.FontFamily 14 | import androidx.compose.ui.text.style.TextAlign 15 | import androidx.compose.ui.unit.dp 16 | import org.grakovne.lissen.BuildConfig 17 | 18 | @Composable 19 | fun AdditionalComposable() { 20 | Column( 21 | modifier = 22 | Modifier 23 | .fillMaxWidth() 24 | .padding(16.dp), 25 | ) { 26 | HorizontalDivider( 27 | modifier = Modifier.padding(horizontal = 12.dp), 28 | color = colorScheme.onSurface.copy(alpha = 0.2f), 29 | ) 30 | 31 | Text( 32 | modifier = 33 | Modifier 34 | .fillMaxWidth() 35 | .padding(top = 16.dp) 36 | .align(Alignment.CenterHorizontally), 37 | text = "Lissen ${BuildConfig.VERSION_NAME}", 38 | style = 39 | TextStyle( 40 | fontFamily = FontFamily.Monospace, 41 | textAlign = TextAlign.Center, 42 | ), 43 | ) 44 | Text( 45 | modifier = 46 | Modifier 47 | .fillMaxWidth() 48 | .padding(top = 8.dp) 49 | .align(Alignment.CenterHorizontally), 50 | text = "© 2024-2025 Max Grakov. MIT License", 51 | style = 52 | TextStyle( 53 | fontFamily = FontFamily.Monospace, 54 | textAlign = TextAlign.Center, 55 | ), 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/AdvancedSettingsItemComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.composable 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.IconButton 12 | import androidx.compose.material3.MaterialTheme.colorScheme 13 | import androidx.compose.material3.MaterialTheme.typography 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.text.font.FontWeight 19 | import androidx.compose.ui.text.style.TextOverflow 20 | import androidx.compose.ui.unit.dp 21 | 22 | @Composable 23 | fun AdvancedSettingsItemComposable( 24 | title: String, 25 | description: String, 26 | onclick: () -> Unit, 27 | ) { 28 | Row( 29 | modifier = 30 | Modifier 31 | .fillMaxWidth() 32 | .clickable { onclick() } 33 | .padding(start = 24.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), 34 | verticalAlignment = Alignment.CenterVertically, 35 | ) { 36 | Column(modifier = Modifier.weight(1f)) { 37 | Text( 38 | text = title, 39 | style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), 40 | modifier = Modifier.padding(bottom = 4.dp), 41 | maxLines = 1, 42 | overflow = TextOverflow.Ellipsis, 43 | ) 44 | Text( 45 | text = description, 46 | style = typography.bodyMedium, 47 | color = colorScheme.onSurfaceVariant, 48 | maxLines = 1, 49 | overflow = TextOverflow.Ellipsis, 50 | ) 51 | } 52 | IconButton( 53 | onClick = { onclick() }, 54 | ) { 55 | Icon( 56 | imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, 57 | contentDescription = "Logout", 58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/ColorSchemeSettingsComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.composable 2 | 3 | import android.content.Context 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.MaterialTheme.colorScheme 10 | import androidx.compose.material3.MaterialTheme.typography 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.getValue 14 | import androidx.compose.runtime.livedata.observeAsState 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.platform.LocalContext 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.text.font.FontWeight 22 | import androidx.compose.ui.unit.dp 23 | import org.grakovne.lissen.R 24 | import org.grakovne.lissen.common.ColorScheme 25 | import org.grakovne.lissen.viewmodel.SettingsViewModel 26 | 27 | @Composable 28 | fun ColorSchemeSettingsComposable(viewModel: SettingsViewModel) { 29 | val context = LocalContext.current 30 | var colorSchemeExpanded by remember { mutableStateOf(false) } 31 | val preferredColorScheme by viewModel.preferredColorScheme.observeAsState() 32 | 33 | Row( 34 | modifier = 35 | Modifier 36 | .fillMaxWidth() 37 | .clickable { colorSchemeExpanded = true } 38 | .padding(horizontal = 24.dp, vertical = 12.dp), 39 | ) { 40 | Column( 41 | modifier = Modifier.weight(1f), 42 | ) { 43 | Text( 44 | text = stringResource(R.string.settings_screen_color_scheme_title), 45 | style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), 46 | modifier = Modifier.padding(bottom = 4.dp), 47 | ) 48 | Text( 49 | text = preferredColorScheme?.toItem(context)?.name ?: "", 50 | style = typography.bodyMedium, 51 | color = colorScheme.onSurfaceVariant, 52 | ) 53 | } 54 | } 55 | 56 | if (colorSchemeExpanded) { 57 | CommonSettingsItemComposable( 58 | items = 59 | listOf( 60 | ColorScheme.FOLLOW_SYSTEM.toItem(context), 61 | ColorScheme.LIGHT.toItem(context), 62 | ColorScheme.DARK.toItem(context), 63 | ColorScheme.BLACK.toItem(context), 64 | ), 65 | selectedItem = preferredColorScheme?.toItem(context), 66 | onDismissRequest = { colorSchemeExpanded = false }, 67 | onItemSelected = { item -> 68 | ColorScheme 69 | .entries 70 | .find { it.name == item.id } 71 | ?.let { viewModel.preferColorScheme(it) } 72 | }, 73 | ) 74 | } 75 | } 76 | 77 | private fun ColorScheme.toItem(context: Context): CommonSettingsItem { 78 | val id = this.name 79 | val name = 80 | when (this) { 81 | ColorScheme.FOLLOW_SYSTEM -> context.getString(R.string.color_scheme_follow_system) 82 | ColorScheme.LIGHT -> context.getString(R.string.color_scheme_light) 83 | ColorScheme.DARK -> context.getString(R.string.color_scheme_dark) 84 | ColorScheme.BLACK -> context.getString(R.string.color_scheme_black) 85 | } 86 | 87 | return CommonSettingsItem(id, name, null) 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/CommonSettingsItem.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.composable 2 | 3 | import androidx.annotation.Keep 4 | import androidx.compose.ui.graphics.vector.ImageVector 5 | 6 | @Keep 7 | data class CommonSettingsItem( 8 | val id: String, 9 | val name: String, 10 | val icon: ImageVector?, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/GitHubLinkComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.composable 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.MaterialTheme.colorScheme 9 | import androidx.compose.material3.MaterialTheme.typography 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalUriHandler 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.text.font.FontWeight 16 | import androidx.compose.ui.text.style.TextOverflow 17 | import androidx.compose.ui.unit.dp 18 | import org.grakovne.lissen.R 19 | 20 | @Composable 21 | fun GitHubLinkComposable() { 22 | val uriHandler = LocalUriHandler.current 23 | 24 | Row( 25 | modifier = 26 | Modifier 27 | .fillMaxWidth() 28 | .clickable { uriHandler.openUri("https://github.com/GrakovNe/lissen-android") } 29 | .padding(horizontal = 24.dp, vertical = 12.dp), 30 | ) { 31 | Column( 32 | modifier = Modifier.weight(1f), 33 | ) { 34 | Text( 35 | text = stringResource(R.string.source_code_on_github_title), 36 | style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), 37 | modifier = Modifier.padding(bottom = 4.dp), 38 | ) 39 | Text( 40 | text = stringResource(R.string.source_code_on_github_subtitle), 41 | style = typography.bodyMedium, 42 | color = colorScheme.onSurfaceVariant, 43 | maxLines = 1, 44 | modifier = Modifier.padding(bottom = 4.dp), 45 | overflow = TextOverflow.Ellipsis, 46 | ) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/screens/settings/composable/SettingsToggleItem.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.composable 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.MaterialTheme.colorScheme 9 | import androidx.compose.material3.MaterialTheme.typography 10 | import androidx.compose.material3.Switch 11 | import androidx.compose.material3.SwitchDefaults 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.unit.dp 18 | 19 | @Composable 20 | fun SettingsToggleItem( 21 | title: String, 22 | description: String, 23 | checked: Boolean, 24 | onCheckedChange: (Boolean) -> Unit, 25 | ) { 26 | Row( 27 | modifier = 28 | Modifier 29 | .fillMaxWidth() 30 | .clickable { onCheckedChange(!checked) } 31 | .padding(horizontal = 24.dp, vertical = 12.dp), 32 | verticalAlignment = Alignment.CenterVertically, 33 | ) { 34 | Column( 35 | modifier = Modifier.weight(1f), 36 | ) { 37 | Text( 38 | text = title, 39 | style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), 40 | modifier = Modifier.padding(bottom = 2.dp), 41 | ) 42 | Text( 43 | text = description, 44 | style = typography.bodyMedium, 45 | color = colorScheme.onSurfaceVariant, 46 | ) 47 | } 48 | 49 | Switch( 50 | checked = checked, 51 | onCheckedChange = null, 52 | colors = 53 | SwitchDefaults.colors( 54 | uncheckedTrackColor = colorScheme.background, 55 | checkedBorderColor = colorScheme.onSurface, 56 | checkedThumbColor = colorScheme.onSurface, 57 | checkedTrackColor = colorScheme.background, 58 | ), 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val FoxOrange = Color(0xFFFF6F3F) 6 | val FoxOrangeDimmed = Color(0xFFCC5A33) 7 | val Dark = Color(0xFF1C1B1F) 8 | val Black = Color(0xFF000000) 9 | val LightBackground = Color(0xFFFAFAFA) 10 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.theme 2 | 3 | import android.app.Activity 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.lightColorScheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.SideEffect 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.platform.LocalView 12 | import androidx.core.view.WindowCompat 13 | import org.grakovne.lissen.common.ColorScheme 14 | 15 | private val LightColorScheme = 16 | lightColorScheme( 17 | primary = FoxOrange, 18 | secondary = Dark, 19 | tertiary = FoxOrange, 20 | tertiaryContainer = LightBackground, 21 | background = LightBackground, 22 | surface = LightBackground, 23 | surfaceContainer = Color(0xFFEEEEEE), 24 | ) 25 | 26 | private val DarkColorScheme = 27 | darkColorScheme( 28 | primary = FoxOrangeDimmed, 29 | tertiaryContainer = Color(0xFF1A1A1A), 30 | ) 31 | 32 | private val BlackColorScheme = 33 | darkColorScheme( 34 | primary = FoxOrangeDimmed, 35 | background = Black, 36 | surface = Black, 37 | tertiaryContainer = Black, 38 | ) 39 | 40 | @Composable 41 | fun LissenTheme( 42 | colorSchemePreference: ColorScheme, 43 | content: @Composable () -> Unit, 44 | ) { 45 | val view = LocalView.current 46 | val window = (view.context as? Activity)?.window 47 | 48 | val isDarkTheme = 49 | when (colorSchemePreference) { 50 | ColorScheme.FOLLOW_SYSTEM -> isSystemInDarkTheme() 51 | ColorScheme.LIGHT -> false 52 | ColorScheme.DARK -> true 53 | ColorScheme.BLACK -> true 54 | } 55 | 56 | SideEffect { 57 | window?.let { 58 | WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = !isDarkTheme 59 | } 60 | } 61 | 62 | val colors = 63 | when (isDarkTheme) { 64 | true -> { 65 | if (colorSchemePreference == ColorScheme.BLACK) { 66 | BlackColorScheme 67 | } else { 68 | DarkColorScheme 69 | } 70 | } 71 | false -> LightColorScheme 72 | } 73 | 74 | MaterialTheme( 75 | colorScheme = colors, 76 | content = content, 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.widget 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import dagger.multibindings.IntoSet 8 | import org.grakovne.lissen.common.RunningComponent 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | interface PlayerWidgetModule { 13 | @Binds 14 | @IntoSet 15 | fun bindPlayerWidgetStateService(service: PlayerWidgetStateService): RunningComponent 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/widget/PlayerWidgetReceiver.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.widget 2 | 3 | import androidx.glance.appwidget.GlanceAppWidget 4 | import androidx.glance.appwidget.GlanceAppWidgetReceiver 5 | 6 | class PlayerWidgetReceiver : GlanceAppWidgetReceiver() { 7 | override val glanceAppWidget: GlanceAppWidget = PlayerWidget() 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/widget/WidgetControlButton.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.widget 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.unit.Dp 5 | import androidx.compose.ui.unit.dp 6 | import androidx.glance.ColorFilter 7 | import androidx.glance.GlanceModifier 8 | import androidx.glance.Image 9 | import androidx.glance.ImageProvider 10 | import androidx.glance.action.Action 11 | import androidx.glance.action.clickable 12 | import androidx.glance.appwidget.cornerRadius 13 | import androidx.glance.layout.Alignment 14 | import androidx.glance.layout.Row 15 | import androidx.glance.layout.size 16 | import androidx.glance.unit.ColorProvider 17 | 18 | @Composable 19 | fun WidgetControlButton( 20 | icon: ImageProvider, 21 | contentColor: ColorProvider, 22 | onClick: Action, 23 | modifier: GlanceModifier, 24 | size: Dp, 25 | ) { 26 | Row( 27 | modifier = modifier, 28 | verticalAlignment = Alignment.Vertical.CenterVertically, 29 | horizontalAlignment = Alignment.Horizontal.CenterHorizontally, 30 | ) { 31 | Image( 32 | provider = icon, 33 | contentDescription = null, 34 | colorFilter = ColorFilter.tint(contentColor), 35 | modifier = 36 | GlanceModifier 37 | .size(size) 38 | .cornerRadius(16.dp) 39 | .clickable(onClick = onClick), 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackController.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.widget 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.content.IntentFilter 7 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 8 | import dagger.hilt.android.qualifiers.ApplicationContext 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.launch 12 | import org.grakovne.lissen.domain.DetailedItem 13 | import org.grakovne.lissen.playback.MediaRepository 14 | import org.grakovne.lissen.playback.service.PlaybackService.Companion.BOOK_EXTRA 15 | import org.grakovne.lissen.playback.service.PlaybackService.Companion.PLAYBACK_READY 16 | import javax.inject.Inject 17 | import javax.inject.Singleton 18 | 19 | @Singleton 20 | class WidgetPlaybackController 21 | @Inject 22 | constructor( 23 | @ApplicationContext private val context: Context, 24 | private val mediaRepository: MediaRepository, 25 | ) { 26 | private var playbackReadyAction: () -> Unit = {} 27 | 28 | private val bookDetailsReadyReceiver = 29 | object : BroadcastReceiver() { 30 | @Suppress("DEPRECATION") 31 | override fun onReceive( 32 | context: Context?, 33 | intent: Intent?, 34 | ) { 35 | if (intent?.action == PLAYBACK_READY) { 36 | val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem 37 | 38 | book?.let { 39 | CoroutineScope(Dispatchers.Main).launch { 40 | playbackReadyAction 41 | .invoke() 42 | .also { playbackReadyAction = { } } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | init { 50 | LocalBroadcastManager 51 | .getInstance(context) 52 | .registerReceiver(bookDetailsReadyReceiver, IntentFilter(PLAYBACK_READY)) 53 | } 54 | 55 | fun providePlayingItem() = mediaRepository.playingBook.value 56 | 57 | fun togglePlayPause() = mediaRepository.togglePlayPause() 58 | 59 | fun nextTrack() = mediaRepository.nextTrack() 60 | 61 | fun previousTrack() = mediaRepository.previousTrack(false) 62 | 63 | fun rewind() = mediaRepository.rewind() 64 | 65 | fun forward() = mediaRepository.forward() 66 | 67 | suspend fun prepareAndRun( 68 | itemId: String, 69 | onPlaybackReady: () -> Unit, 70 | ) { 71 | playbackReadyAction = onPlaybackReady 72 | mediaRepository.preparePlayback(bookId = itemId) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/widget/WidgetPlaybackControllerEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.widget 2 | 3 | import dagger.hilt.EntryPoint 4 | import dagger.hilt.InstallIn 5 | import dagger.hilt.components.SingletonComponent 6 | 7 | @EntryPoint 8 | @InstallIn(SingletonComponent::class) 9 | interface WidgetPlaybackControllerEntryPoint { 10 | fun widgetPlaybackController(): WidgetPlaybackController 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/grakovne/lissen/widget/WidgetPreferencesEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.widget 2 | 3 | import dagger.hilt.EntryPoint 4 | import dagger.hilt.InstallIn 5 | import dagger.hilt.components.SingletonComponent 6 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 7 | 8 | @EntryPoint 9 | @InstallIn(SingletonComponent::class) 10 | interface WidgetPreferencesEntryPoint { 11 | fun lissenSharedPreferences(): LissenSharedPreferences 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_downloading.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-hdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-hdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-mdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-mdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-xhdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-xhdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-xxhdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-xxhdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable-xxxhdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/available_offline_filled.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/available_offline_outline.xml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 20 | 27 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/cover_fallback_png.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/drawable/cover_fallback_png.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/widget_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values-hr/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Nastavi slušati 4 | Postavke 5 | Server URL 6 | Prijava 7 | Lozinka 8 | Brzina 9 | Poglavlja 10 | Sljedeča poglavlja 11 | Sljedeće epizode 12 | Poglavlje %1$d od %2$s 13 | Epizoda %1$d od %2$s 14 | Spojen kao 15 | Server veza 16 | Host je dolje 17 | Korisničko ime nedostaje 18 | Lozinka nedostaje 19 | Pokaži lozinku 20 | Postavke 21 | Host URL mora biti https:// ili http:// 22 | Poveži 23 | Host URL nedostaje 24 | Stavka %1$d od %2$s 25 | Poveži se na server 26 | Knjižnjica 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values-zh-rCN/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 书库 4 | 继续播放 5 | 登录 6 | 连接服务器 7 | 偏好设置 8 | 密码 9 | 显示密码 10 | 服务器URL 11 | 连接 12 | 播放速度 13 | 章节 14 | 播放章节 15 | 设置 16 | 连接错误 17 | 没有互联网连接 18 | 播放速度 19 | 播客 20 | 播放单集 21 | 播放项目 22 | 无保存书籍 23 | 系统 24 | 作者 25 | 发行年份 26 | 服务器连接 27 | %1$d 分钟 28 | 服务器版本 29 | 关闭定时 30 | 睡眠定时 31 | 按章节搜索 32 | 下载播客 33 | 无保存播客 34 | 下载 35 | 下载书籍 36 | 下载 37 | 清除下载单集 38 | 清除下载章节 39 | 清除下载项目 40 | 关闭 41 | 出版社 42 | 系列 43 | 黑色的 44 | 旁白 45 | 时长 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #D7D3CB 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/xml/mini_player_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) apply false 3 | alias(libs.plugins.kotlin.android) apply false 4 | id("com.google.dagger.hilt.android") version "2.56.2" apply false 5 | id("com.google.devtools.ksp") version "2.1.21-2.0.1" apply false 6 | alias(libs.plugins.compose.compiler) apply false 7 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 2 | android.useAndroidX=true 3 | kotlin.code.style=official 4 | android.nonTransitiveRClass=true 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Nov 17 18:39:38 CET 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Listen to your favorite audiobooks through a minimalist app designed for one task 2 | 3 | Sync your listened books between devices and other Audiobookshelf players and download books to listen to them without the Internet 4 | -------------------------------------------------------------------------------- /metadata/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Clean Audiobookshelf Player 2 | -------------------------------------------------------------------------------- /metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Lissen: Audiobookshelf client 2 | -------------------------------------------------------------------------------- /metadata/ru-RU/full_description.txt: -------------------------------------------------------------------------------- 1 | Слушайте любимые аудиокниги через минималистичное приложение, спроектированное для одной задачи 2 | 3 | Синхронизируйте ваши прослушанные книги между устройствами и другими проигрывателями Audiobookshelf и скачивайте книги, чтобы слушать их без интернета -------------------------------------------------------------------------------- /metadata/ru-RU/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/ru-RU/images/featureGraphic.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/ru-RU/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/ru-RU/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/ru-RU/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/74cc98104509b5acb621a653e21382e96c7400b9/metadata/ru-RU/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /metadata/ru-RU/short_description.txt: -------------------------------------------------------------------------------- 1 | Минималистичный клиент для Audiobookshelf 2 | -------------------------------------------------------------------------------- /metadata/ru-RU/title.txt: -------------------------------------------------------------------------------- 1 | Lissen: Audiobookshelf client 2 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google { 4 | content { 5 | includeGroupByRegex("com\\.android.*") 6 | includeGroupByRegex("com\\.google.*") 7 | includeGroupByRegex("androidx.*") 8 | } 9 | } 10 | mavenCentral() 11 | gradlePluginPortal() 12 | } 13 | } 14 | dependencyResolutionManagement { 15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 16 | repositories { 17 | google() 18 | mavenCentral() 19 | } 20 | } 21 | 22 | rootProject.name = "Lissen" 23 | include(":app") 24 | --------------------------------------------------------------------------------