├── lib ├── .gitignore ├── consumer-rules.pro ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── org │ │ └── grakovne │ │ └── lissen │ │ └── lib │ │ └── domain │ │ ├── NetworkType.kt │ │ ├── SeekTimeOption.kt │ │ ├── FixScheme.kt │ │ ├── PlaybackSession.kt │ │ ├── Library.kt │ │ ├── PagedItems.kt │ │ ├── PlaybackProgress.kt │ │ ├── LibraryType.kt │ │ ├── Book.kt │ │ ├── CacheStatus.kt │ │ ├── TimerOption.kt │ │ ├── ContentCachingTask.kt │ │ ├── UserAccount.kt │ │ ├── RecentBook.kt │ │ ├── RewindOnPauseTime.kt │ │ ├── SeekTime.kt │ │ ├── connection │ │ ├── ServerRequestHeader.kt │ │ └── LocalUrl.kt │ │ ├── DownloadOption.kt │ │ └── DetailedItem.kt ├── proguard-rules.pro └── build.gradle.kts ├── metadata ├── en-US │ ├── title.txt │ ├── short_description.txt │ ├── images │ │ ├── icon.png │ │ ├── featureGraphic.png │ │ └── phoneScreenshots │ │ │ ├── 1.png │ │ │ ├── 2.png │ │ │ ├── 3.png │ │ │ └── 4.png │ └── full_description.txt └── ru-RU │ ├── title.txt │ ├── short_description.txt │ ├── images │ ├── featureGraphic.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ └── 4.png │ └── full_description.txt ├── FUNDING.yml ├── app ├── src │ ├── debug │ │ └── res │ │ │ └── values │ │ │ └── strings.xml │ └── main │ │ ├── ic_launcher-playstore.png │ │ ├── res │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.webp │ │ │ ├── ic_launcher_round.webp │ │ │ └── ic_launcher_monochrome.webp │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.webp │ │ │ ├── ic_launcher_round.webp │ │ │ └── ic_launcher_monochrome.webp │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.webp │ │ │ ├── ic_launcher_round.webp │ │ │ └── ic_launcher_monochrome.webp │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.webp │ │ │ ├── ic_launcher_round.webp │ │ │ └── ic_launcher_monochrome.webp │ │ ├── xml │ │ │ ├── automotive_app_desc.xml │ │ │ ├── network_security_config.xml │ │ │ ├── mini_player_widget_info.xml │ │ │ ├── backup_rules.xml │ │ │ └── data_extraction_rules.xml │ │ ├── drawable-hdpi │ │ │ ├── ic_downloading.png │ │ │ └── media3_notification_small_icon.png │ │ ├── drawable-mdpi │ │ │ ├── ic_downloading.png │ │ │ └── media3_notification_small_icon.png │ │ ├── drawable │ │ │ ├── cover_fallback_png.png │ │ │ ├── ic_play.xml │ │ │ ├── available_offline_filled.xml │ │ │ └── available_offline_outline.xml │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.webp │ │ │ ├── ic_launcher_round.webp │ │ │ └── ic_launcher_monochrome.webp │ │ ├── drawable-xhdpi │ │ │ ├── ic_downloading.png │ │ │ └── media3_notification_small_icon.png │ │ ├── drawable-xxhdpi │ │ │ ├── ic_downloading.png │ │ │ └── media3_notification_small_icon.png │ │ ├── values │ │ │ ├── ic_launcher_background.xml │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ ├── drawable-xxxhdpi │ │ │ └── media3_notification_small_icon.png │ │ ├── values-night │ │ │ └── styles.xml │ │ ├── layout │ │ │ └── widget_placeholder.xml │ │ ├── values-v31 │ │ │ └── styles.xml │ │ ├── mipmap-anydpi-v26 │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── drawable-anydpi │ │ │ └── ic_downloading.xml │ │ └── values-cy │ │ │ └── strings.xml │ │ └── kotlin │ │ └── org │ │ └── grakovne │ │ └── lissen │ │ ├── common │ │ ├── RunningComponent.kt │ │ ├── LibraryOrderingDirection.kt │ │ ├── NetworkTypeAutoCache.kt │ │ ├── ColorScheme.kt │ │ ├── LibraryOrderingOption.kt │ │ ├── PlaybackVolumeBoost.kt │ │ ├── LibraryPagingSource.kt │ │ ├── HapticAction.kt │ │ ├── NetworkModule.kt │ │ ├── Moshi.kt │ │ ├── LibraryOrderingConfiguration.kt │ │ └── CertificateExtension.kt │ │ ├── channel │ │ ├── common │ │ │ ├── ChannelCode.kt │ │ │ ├── ChannelProvider.kt │ │ │ ├── UserAgent.kt │ │ │ ├── ConnectionInfo.kt │ │ │ ├── AuthMethod.kt │ │ │ ├── OAuthContextCache.kt │ │ │ ├── ApiClient.kt │ │ │ ├── Pkce.kt │ │ │ ├── OperationResult.kt │ │ │ ├── ChannelAuthService.kt │ │ │ ├── MediaChannel.kt │ │ │ ├── OkHttpClient.kt │ │ │ └── OperationError.kt │ │ └── audiobookshelf │ │ │ ├── common │ │ │ ├── oauth │ │ │ │ ├── OAuthScheme.kt │ │ │ │ └── AudiobookshelfOAuthCallbackActivity.kt │ │ │ ├── model │ │ │ │ ├── playback │ │ │ │ │ ├── ProgressSyncRequest.kt │ │ │ │ │ ├── PlaybackSessionResponse.kt │ │ │ │ │ └── PlaybackStartRequest.kt │ │ │ │ ├── user │ │ │ │ │ ├── CredentialsLoginRequest.kt │ │ │ │ │ ├── UserResponse.kt │ │ │ │ │ ├── LoggedUserResponse.kt │ │ │ │ │ └── PersonalizedFeedResponse.kt │ │ │ │ ├── metadata │ │ │ │ │ ├── AuthorItemsResponse.kt │ │ │ │ │ └── LibraryResponse.kt │ │ │ │ ├── MediaProgressResponse.kt │ │ │ │ ├── auth │ │ │ │ │ └── AuthMethodResponse.kt │ │ │ │ └── connection │ │ │ │ │ └── ConnectionInfoResponse.kt │ │ │ ├── api │ │ │ │ ├── AudioBookshelfSyncService.kt │ │ │ │ ├── RequestHeadersProvider.kt │ │ │ │ ├── library │ │ │ │ │ └── AudioBookshelfLibrarySyncService.kt │ │ │ │ ├── podcast │ │ │ │ │ └── AudioBookshelfPodcastSyncService.kt │ │ │ │ └── SafeApiCall.kt │ │ │ └── converter │ │ │ │ ├── PlaybackSessionResponseConverter.kt │ │ │ │ ├── ConnectionInfoResponseConverter.kt │ │ │ │ ├── LoginResponseConverter.kt │ │ │ │ ├── LibraryResponseConverter.kt │ │ │ │ ├── AuthMethodResponseConverter.kt │ │ │ │ ├── LibraryPageResponseConverter.kt │ │ │ │ └── RecentListeningResponseConverter.kt │ │ │ ├── podcast │ │ │ ├── model │ │ │ │ ├── PodcastSearchResponse.kt │ │ │ │ ├── PodcastItemsResponse.kt │ │ │ │ └── PodcastResponse.kt │ │ │ └── converter │ │ │ │ ├── PodcastSearchItemsConverter.kt │ │ │ │ ├── PodcastPageResponseConverter.kt │ │ │ │ └── PodcastOrderingRequestConverter.kt │ │ │ ├── library │ │ │ ├── converter │ │ │ │ ├── LibrarySearchItemsConverter.kt │ │ │ │ └── LibraryOrderingRequestConverter.kt │ │ │ └── model │ │ │ │ ├── LibraryItemsResponse.kt │ │ │ │ ├── LibrarySearchResponse.kt │ │ │ │ └── BookResponse.kt │ │ │ ├── AudiobookshelfChannelProvider.kt │ │ │ └── AudiobookshelfHostProvider.kt │ │ ├── ui │ │ ├── navigation │ │ │ ├── Action.kt │ │ │ ├── AppLaunchAction.kt │ │ │ ├── Route.kt │ │ │ └── AppNavigationService.kt │ │ ├── screens │ │ │ ├── settings │ │ │ │ ├── composable │ │ │ │ │ ├── CommonSettingsItem.kt │ │ │ │ │ ├── LicenseFooterComposable.kt │ │ │ │ │ ├── GitHubLinkComposable.kt │ │ │ │ │ └── SettingsToggleItem.kt │ │ │ │ └── advanced │ │ │ │ │ ├── cache │ │ │ │ │ ├── CachedItemsPageSource.kt │ │ │ │ │ └── CachedItemsFallbackComposable.kt │ │ │ │ │ ├── AdvancedSettingsSimpleItemComposable.kt │ │ │ │ │ ├── AdvancedSettingsNavigationItemComposable.kt │ │ │ │ │ └── CustomHeaderComposable.kt │ │ │ ├── player │ │ │ │ └── composable │ │ │ │ │ ├── common │ │ │ │ │ ├── ProvideForwardIcon.kt │ │ │ │ │ ├── ProvideReplayIcon.kt │ │ │ │ │ └── ProvideNowPlayingTitle.kt │ │ │ │ │ ├── fallback │ │ │ │ │ └── PlayingQueueFallbackComposable.kt │ │ │ │ │ └── placeholder │ │ │ │ │ └── PlayingQueuePlaceholderComposable.kt │ │ │ ├── common │ │ │ │ ├── RequestLocationPermission.kt │ │ │ │ ├── RequestNotificationPermissions.kt │ │ │ │ └── DownloadOptionFormat.kt │ │ │ └── library │ │ │ │ ├── composables │ │ │ │ ├── LibrarySwitchComposable.kt │ │ │ │ └── placeholder │ │ │ │ │ └── LibraryPlaceholderComposable.kt │ │ │ │ ├── paging │ │ │ │ ├── LibrarySearchPagingSource.kt │ │ │ │ └── LibraryDefaultPagingSource.kt │ │ │ │ └── PreferredLibrarySettingComposable.kt │ │ ├── theme │ │ │ ├── Color.kt │ │ │ └── Theme.kt │ │ ├── components │ │ │ ├── ImageLoaderEntryPoint.kt │ │ │ └── AsyncShimmeringImage.kt │ │ ├── extensions │ │ │ ├── AsyncExtensions.kt │ │ │ └── TimeExtensions.kt │ │ ├── activity │ │ │ └── AppActivity.kt │ │ └── icons │ │ │ └── Search.kt │ │ ├── widget │ │ ├── PlayerWidgetReceiver.kt │ │ ├── WidgetPlaybackControllerEntryPoint.kt │ │ ├── PlayerWidgetModule.kt │ │ ├── WidgetControlButton.kt │ │ └── WidgetPlaybackController.kt │ │ ├── content │ │ ├── cache │ │ │ ├── common │ │ │ │ ├── BufferExtensions.kt │ │ │ │ ├── GetImageDimensions.kt │ │ │ │ ├── FindRelatedFiles.kt │ │ │ │ └── ImageBlur.kt │ │ │ ├── persistent │ │ │ │ ├── CacheState.kt │ │ │ │ ├── ContentAutoCachingModule.kt │ │ │ │ ├── entity │ │ │ │ │ └── CachedLibraryEntity.kt │ │ │ │ ├── converter │ │ │ │ │ ├── CachedLibraryEntityConverter.kt │ │ │ │ │ ├── CachedBookEntityRecentConverter.kt │ │ │ │ │ └── CachedBookEntityConverter.kt │ │ │ │ ├── ContentCachingProgress.kt │ │ │ │ ├── ContentCachingExecutor.kt │ │ │ │ ├── api │ │ │ │ │ ├── CachedLibraryRepository.kt │ │ │ │ │ ├── FetchRequestBuilder.kt │ │ │ │ │ └── SearchRequestBuilder.kt │ │ │ │ ├── LocalCacheStorage.kt │ │ │ │ ├── OfflineBookStorageProperties.kt │ │ │ │ ├── dao │ │ │ │ │ └── CachedLibraryDao.kt │ │ │ │ ├── CalculateRequestedChapters.kt │ │ │ │ └── LocalCacheModule.kt │ │ │ └── temporary │ │ │ │ ├── ShortTermCacheStorageProperties.kt │ │ │ │ └── CachedCoverProvider.kt │ │ └── LissenDataManagementActivity.kt │ │ ├── playback │ │ ├── service │ │ │ ├── MimeTypeProvider.kt │ │ │ ├── CalculateChapterIndex.kt │ │ │ ├── PlaybackNotificationModule.kt │ │ │ ├── CalculateChapterPosition.kt │ │ │ ├── LissenMediaSchemeConverter.kt │ │ │ ├── SuspendableCountDownTimer.kt │ │ │ └── PlaybackTimer.kt │ │ ├── PlaybackEnhancerModule.kt │ │ └── MediaModule.kt │ │ ├── shortcuts │ │ ├── ShortcutsModule.kt │ │ └── ContinuePlaybackShortcut.kt │ │ └── LissenApplication.kt ├── lint.xml ├── .editorconfig └── proguard-rules.pro ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── .gitignore ├── settings.gradle.kts ├── LICENSE └── .github └── workflows └── build_app.yml /lib/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /lib/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Lissen: Audiobookshelf client 2 | -------------------------------------------------------------------------------- /metadata/ru-RU/title.txt: -------------------------------------------------------------------------------- 1 | Lissen: Audiobookshelf client 2 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ["https://boosty.to/grakovne/donate"] 2 | -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Clean Audiobookshelf Player 2 | -------------------------------------------------------------------------------- /metadata/ru-RU/short_description.txt: -------------------------------------------------------------------------------- 1 | Минималистичный клиент для Audiobookshelf 2 | -------------------------------------------------------------------------------- /app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Lissen (DEBUG) 3 | -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /metadata/en-US/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/en-US/images/featureGraphic.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/featureGraphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/ru-RU/images/featureGraphic.png -------------------------------------------------------------------------------- /app/lint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/ru-RU/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/ru-RU/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/ru-RU/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /metadata/ru-RU/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/metadata/ru-RU/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/xml/automotive_app_desc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable-hdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable-mdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/cover_fallback_png.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable/cover_fallback_png.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable-xhdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/ic_downloading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable-xxhdpi/ic_downloading.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /lib/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/NetworkType.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | enum class NetworkType { 4 | WIFI, 5 | CELLULAR 6 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/common/RunningComponent.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | interface RunningComponent { 4 | fun onCreate() 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/res/mipmap-xxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #D7D3CB 4 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable-hdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable-mdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/app/src/main/res/drawable-xhdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/media3_notification_small_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GrakovNe/lissen-android/HEAD/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/HEAD/app/src/main/res/drawable-xxxhdpi/media3_notification_small_icon.png -------------------------------------------------------------------------------- /app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/PagedItems.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PagedItems( 7 | val items: List, 8 | val currentPage: Int, 9 | val totalItems: Int 10 | ) 11 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/PlaybackProgress.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class PlaybackProgress( 7 | val currentChapterTime: Double, 8 | val currentTotalTime: Double, 9 | ) 10 | -------------------------------------------------------------------------------- /metadata/ru-RU/full_description.txt: -------------------------------------------------------------------------------- 1 | Слушайте любимые аудиокниги через минималистичное приложение, спроектированное для одной задачи 2 | 3 | Синхронизируйте ваши прослушанные книги между устройствами и другими проигрывателями Audiobookshelf и скачивайте книги, чтобы слушать их без интернета -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/common/LibraryPagingSource.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import androidx.paging.PagingSource 4 | 5 | abstract class LibraryPagingSource( 6 | protected val onTotalCountChanged: (Int) -> Unit, 7 | ) : PagingSource() 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/navigation/Route.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.navigation 2 | 3 | const val ROUTE_LIBRARY = "library_screen" 4 | const val ROUTE_PLAYER = "player_screen" 5 | const val ROUTE_SETTINGS = "settings_screen" 6 | const val ROUTE_LOGIN = "login_screen" 7 | -------------------------------------------------------------------------------- /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-9.2.0-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/LibraryType.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | enum class LibraryType { 4 | LIBRARY, 5 | PODCAST, 6 | UNKNOWN; 7 | 8 | 9 | companion object { 10 | val meaningfulTypes = listOf(LIBRARY, PODCAST) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Book.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.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 | ) 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/CacheStatus.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.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/kotlin/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 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/TimerOption.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | import java.io.Serializable 4 | 5 | sealed interface TimerOption : Serializable 6 | 7 | class DurationTimerOption( 8 | val duration: Int, 9 | ) : TimerOption 10 | 11 | data object CurrentEpisodeTimerOption : TimerOption 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 withHaptic( 7 | view: View, 8 | action: () -> Unit, 9 | ) { 10 | action() 11 | view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) 12 | } 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/ContentCachingTask.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | import androidx.annotation.Keep 4 | import java.io.Serializable 5 | 6 | @Keep 7 | data class ContentCachingTask( 8 | val item: DetailedItem, 9 | val options: DownloadOption, 10 | val currentPosition: Double, 11 | ) : Serializable 12 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/UserAccount.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class UserAccount( 7 | val token: String?, 8 | val accessToken: String?, 9 | val refreshToken: String?, 10 | val username: String, 11 | val preferredLibraryId: String?, 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/RecentBook.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.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/kotlin/org/grakovne/lissen/channel/common/ConnectionInfo.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class ConnectionInfo( 9 | val username: String, 10 | val serverVersion: String?, 11 | val buildNumber: String?, 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/common/BufferExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.common 2 | 3 | import okio.Buffer 4 | import okio.buffer 5 | import okio.sink 6 | import java.io.File 7 | 8 | fun Buffer.writeToFile(file: File) { 9 | file.sink().buffer().use { sink -> 10 | sink.write(this, size) 11 | sink.flush() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CacheState.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | import org.grakovne.lissen.lib.domain.CacheStatus 6 | 7 | @Keep 8 | data class CacheState( 9 | val status: CacheStatus, 10 | val progress: Double = 0.0, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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 | val MediumBackground = Color(0xFFDADADA) 11 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.domain.SeekTime 6 | 7 | fun provideForwardIcon(seekTime: SeekTime) = Icons.Rounded.FastForward 8 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.domain.SeekTime 6 | 7 | fun provideReplayIcon(seekTime: SeekTime) = Icons.Rounded.FastRewind 8 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class ProgressSyncRequest( 9 | val timeListened: Int, 10 | val currentTime: Double, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class CredentialsLoginRequest( 9 | val username: String, 10 | val password: String, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/res/xml/mini_player_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class PlaybackSessionResponse( 9 | val id: String, 10 | val libraryItemId: String, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/components/ImageLoaderEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.components 2 | 3 | import coil3.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/kotlin/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/kotlin/org/grakovne/lissen/channel/common/AuthMethod.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | 5 | @Keep 6 | data class AuthData( 7 | val methods: List, 8 | val oauthLoginText: String?, 9 | ) { 10 | companion object { 11 | val empty = AuthData(emptyList(), null) 12 | } 13 | } 14 | 15 | enum class AuthMethod { 16 | CREDENTIALS, 17 | O_AUTH, 18 | } 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/RewindOnPauseTime.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.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/kotlin/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.OperationResult 4 | import org.grakovne.lissen.lib.domain.PlaybackProgress 5 | 6 | interface AudioBookshelfSyncService { 7 | suspend fun syncProgress( 8 | itemId: String, 9 | progress: PlaybackProgress, 10 | ): OperationResult 11 | } 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/common/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 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 | 9 | @Module 10 | @InstallIn(SingletonComponent::class) 11 | interface NetworkModule { 12 | @Binds 13 | @IntoSet 14 | fun bindNetworkService(service: NetworkService): RunningComponent 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 com.squareup.moshi.JsonClass 5 | import org.grakovne.lissen.channel.audiobookshelf.library.model.LibraryItem 6 | 7 | @Keep 8 | @JsonClass(generateAdapter = true) 9 | data class AuthorItemsResponse( 10 | val libraryItems: List, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/model/user/UserResponse.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.model.user 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | import org.grakovne.lissen.channel.audiobookshelf.common.model.MediaProgressResponse 6 | 7 | @Keep 8 | @JsonClass(generateAdapter = true) 9 | data class UserResponse( 10 | val mediaProgress: List?, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class MediaProgressResponse( 9 | val libraryItemId: String, 10 | val episodeId: String?, 11 | val currentTime: Double, 12 | val isFinished: Boolean, 13 | val lastUpdate: Long, 14 | val progress: Double, 15 | ) 16 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/SeekTime.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class SeekTime( 9 | val rewind: SeekTimeOption, 10 | val forward: SeekTimeOption, 11 | ) { 12 | companion object { 13 | val Default = 14 | SeekTime( 15 | rewind = SeekTimeOption.SEEK_10, 16 | forward = SeekTimeOption.SEEK_30, 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/opus", 14 | "audio/ogg", 15 | "audio/vorbis", 16 | "audio/x-matroska", 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class PodcastSearchResponse( 9 | val podcast: List, 10 | ) 11 | 12 | @Keep 13 | @JsonClass(generateAdapter = true) 14 | data class PodcastSearchItemResponse( 15 | val libraryItem: PodcastItem, 16 | ) 17 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class AuthMethodResponse( 9 | val authMethods: List = emptyList(), 10 | val authFormData: AuthFormData?, 11 | ) 12 | 13 | @Keep 14 | @JsonClass(generateAdapter = true) 15 | data class AuthFormData( 16 | val authOpenIDButtonText: String?, 17 | ) 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/playback/PlaybackEnhancerModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback 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 PlaybackEnhancerModule { 13 | @Binds 14 | @IntoSet 15 | fun bindPlaybackEnhancerService(service: PlaybackEnhancerService): RunningComponent 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/playback/service/CalculateChapterIndex.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import org.grakovne.lissen.lib.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/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class LibraryResponse( 9 | val libraries: List, 10 | ) 11 | 12 | @Keep 13 | @JsonClass(generateAdapter = true) 14 | data class LibraryItemResponse( 15 | val id: String, 16 | val name: String, 17 | val mediaType: String, 18 | val displayOrder: Int?, 19 | ) 20 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/org/grakovne/lissen/content/cache/persistent/ContentAutoCachingModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 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 ContentAutoCachingModule { 13 | @Binds 14 | @IntoSet 15 | fun bindContentAutoCachingService(service: ContentAutoCachingService): RunningComponent 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/RequestLocationPermission.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.common 2 | 3 | import android.Manifest 4 | import android.content.Context 5 | import android.content.pm.PackageManager 6 | import android.os.Build 7 | import androidx.core.content.ContextCompat 8 | 9 | fun hasLocationPermission(context: Context): Boolean = 10 | when (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 11 | true -> ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED 12 | false -> true 13 | } 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/common/GetImageDimensions.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.common 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/kotlin/org/grakovne/lissen/playback/service/CalculateChapterPosition.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import org.grakovne.lissen.lib.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/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class LoggedUserResponse( 9 | val user: User, 10 | val userDefaultLibraryId: String?, 11 | ) 12 | 13 | @Keep 14 | @JsonClass(generateAdapter = true) 15 | data class User( 16 | val id: String, 17 | val token: String?, 18 | val refreshToken: String?, 19 | val accessToken: String?, 20 | val username: String = "username", 21 | ) 22 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/entity/CachedLibraryEntity.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.entity 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import com.squareup.moshi.JsonClass 7 | import org.grakovne.lissen.lib.domain.LibraryType 8 | import java.io.Serializable 9 | 10 | @Keep 11 | @Entity( 12 | tableName = "libraries", 13 | ) 14 | @JsonClass(generateAdapter = true) 15 | data class CachedLibraryEntity( 16 | @PrimaryKey 17 | val id: String, 18 | val title: String, 19 | val type: LibraryType, 20 | ) : Serializable 21 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedLibraryEntityConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.converter 2 | 3 | import org.grakovne.lissen.content.cache.persistent.entity.CachedLibraryEntity 4 | import org.grakovne.lissen.lib.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/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /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 | include(":lib") 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.domain.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/kotlin/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/kotlin/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.lib.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 | itemId = response.libraryItemId, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class PlaybackStartRequest( 9 | val deviceInfo: DeviceInfo, 10 | val supportedMimeTypes: List, 11 | val mediaPlayer: String, 12 | val forceTranscode: Boolean, 13 | val forceDirectPlay: Boolean, 14 | ) 15 | 16 | @Keep 17 | @JsonClass(generateAdapter = true) 18 | data class DeviceInfo( 19 | val clientName: String, 20 | val deviceId: String, 21 | val deviceName: String, 22 | ) 23 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class ConnectionInfoResponse( 9 | val user: ConnectionInfoUserResponse, 10 | val serverSettings: ConnectionInfoServerResponse?, 11 | ) 12 | 13 | @Keep 14 | @JsonClass(generateAdapter = true) 15 | data class ConnectionInfoUserResponse( 16 | val username: String, 17 | ) 18 | 19 | @Keep 20 | @JsonClass(generateAdapter = true) 21 | data class ConnectionInfoServerResponse( 22 | val version: String?, 23 | val buildNumber: String?, 24 | ) 25 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/playback/service/LissenMediaSchemeConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import android.net.Uri 4 | 5 | fun apply( 6 | mediaItemId: String, 7 | fileId: String, 8 | ): Uri = 9 | Uri 10 | .Builder() 11 | .scheme("lissen") 12 | .appendPath(mediaItemId) 13 | .appendPath(fileId) 14 | .build() 15 | 16 | fun unapply(uri: Uri): Pair? { 17 | if (uri.scheme != "lissen") return null 18 | 19 | val segments = uri.pathSegments 20 | if (segments.size != 2) return null 21 | 22 | val mediaItemId = segments[0].takeIf { it.isNotEmpty() } ?: return null 23 | val fileId = segments[1].takeIf { it.isNotEmpty() } ?: return null 24 | 25 | return mediaItemId to fileId 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingProgress.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 2 | 3 | import kotlinx.coroutines.flow.MutableSharedFlow 4 | import kotlinx.coroutines.flow.asSharedFlow 5 | import org.grakovne.lissen.lib.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/kotlin/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/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/main/kotlin/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.lib.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 | accessToken = response.user.accessToken, 16 | refreshToken = response.user.refreshToken, 17 | username = response.user.username, 18 | preferredLibraryId = response.userDefaultLibraryId, 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /lib/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.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/kotlin/org/grakovne/lissen/content/cache/persistent/ContentCachingExecutor.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.grakovne.lissen.channel.common.MediaChannel 5 | import org.grakovne.lissen.lib.domain.DetailedItem 6 | import org.grakovne.lissen.lib.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/kotlin/org/grakovne/lissen/content/cache/persistent/api/CachedLibraryRepository.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.api 2 | 3 | import org.grakovne.lissen.content.cache.persistent.converter.CachedLibraryEntityConverter 4 | import org.grakovne.lissen.content.cache.persistent.dao.CachedLibraryDao 5 | import org.grakovne.lissen.lib.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/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class PodcastItemsResponse( 9 | val results: List, 10 | val page: Int, 11 | val total: Int, 12 | ) 13 | 14 | @Keep 15 | @JsonClass(generateAdapter = true) 16 | data class PodcastItem( 17 | val id: String, 18 | val media: PodcastItemMedia, 19 | ) 20 | 21 | @Keep 22 | @JsonClass(generateAdapter = true) 23 | data class PodcastItemMedia( 24 | val numEpisodes: Int?, 25 | val metadata: PodcastMetadata, 26 | ) 27 | 28 | @Keep 29 | @JsonClass(generateAdapter = true) 30 | data class PodcastMetadata( 31 | val title: String?, 32 | val author: String?, 33 | ) 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/LissenDataManagementActivity.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import dagger.hilt.android.AndroidEntryPoint 7 | import org.grakovne.lissen.ui.activity.AppActivity 8 | import org.grakovne.lissen.ui.navigation.SHOW_DOWNLOADS 9 | 10 | @AndroidEntryPoint 11 | class LissenDataManagementActivity : ComponentActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | 15 | val intent = 16 | Intent(this, AppActivity::class.java).apply { 17 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or 18 | Intent.FLAG_ACTIVITY_CLEAR_TASK 19 | action = SHOW_DOWNLOADS 20 | } 21 | 22 | startActivity(intent) 23 | finish() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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.lib.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 | ) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.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 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class LibraryItemsResponse( 9 | val results: List, 10 | val page: Int, 11 | val total: Int, 12 | ) 13 | 14 | @Keep 15 | @JsonClass(generateAdapter = true) 16 | data class LibraryItem( 17 | val id: String, 18 | val media: Media, 19 | ) 20 | 21 | @Keep 22 | @JsonClass(generateAdapter = true) 23 | data class Media( 24 | val numChapters: Int?, 25 | val metadata: LibraryMetadata, 26 | ) 27 | 28 | @Keep 29 | @JsonClass(generateAdapter = true) 30 | data class LibraryMetadata( 31 | val title: String?, 32 | val subtitle: String?, 33 | val seriesName: String?, 34 | val authorName: String?, 35 | ) 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class LibrarySearchResponse( 9 | val book: List, 10 | val authors: List, 11 | val series: List, 12 | ) 13 | 14 | @Keep 15 | @JsonClass(generateAdapter = true) 16 | data class LibrarySearchItemResponse( 17 | val libraryItem: LibraryItem, 18 | ) 19 | 20 | @Keep 21 | @JsonClass(generateAdapter = true) 22 | data class LibrarySearchAuthorResponse( 23 | val id: String, 24 | val name: String, 25 | ) 26 | 27 | @Keep 28 | @JsonClass(generateAdapter = true) 29 | data class LibrarySearchSeriesResponse( 30 | val books: List, 31 | ) 32 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/common/Moshi.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import com.squareup.moshi.FromJson 4 | import com.squareup.moshi.JsonAdapter 5 | import com.squareup.moshi.JsonReader 6 | import com.squareup.moshi.JsonWriter 7 | import com.squareup.moshi.Moshi 8 | import com.squareup.moshi.ToJson 9 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 10 | import java.util.UUID 11 | 12 | val moshi: Moshi = 13 | Moshi 14 | .Builder() 15 | .add( 16 | UUID::class.java, 17 | object : JsonAdapter() { 18 | @FromJson 19 | override fun fromJson(reader: JsonReader): UUID? = reader.nextString()?.let { UUID.fromString(it) } 20 | 21 | @ToJson 22 | override fun toJson( 23 | writer: JsonWriter, 24 | value: UUID?, 25 | ) { 26 | writer.value(value?.toString()) 27 | } 28 | }, 29 | ).build() 30 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/extensions/TimeExtensions.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.extensions 2 | 3 | import java.util.Locale 4 | 5 | fun Int.formatTime(forceLeadingHours: Boolean): String = 6 | when (forceLeadingHours) { 7 | true -> this.formatLeadingHours() 8 | false -> this.formatTime() 9 | } 10 | 11 | private fun Int.formatLeadingHours(): String { 12 | val hours = this / 3600 13 | val minutes = (this % 3600) / 60 14 | val seconds = this % 60 15 | 16 | return String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) 17 | } 18 | 19 | fun Int.formatTime(): String { 20 | val hours = this / 3600 21 | val minutes = (this % 3600) / 60 22 | val seconds = this % 60 23 | return if (hours > 0) { 24 | String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds) 25 | } else { 26 | String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityRecentConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.converter 2 | 3 | import org.grakovne.lissen.content.cache.persistent.entity.BookEntity 4 | import org.grakovne.lissen.lib.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/kotlin/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.LibraryItemResponse 4 | import org.grakovne.lissen.lib.domain.Library 5 | import org.grakovne.lissen.lib.domain.LibraryType 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class LibraryResponseConverter 11 | @Inject 12 | constructor() { 13 | fun apply(response: List): List = 14 | response 15 | .map { 16 | it 17 | .mediaType 18 | .toLibraryType() 19 | .let { type -> Library(it.id, it.name, type) } 20 | } 21 | 22 | private fun String.toLibraryType() = 23 | when (this) { 24 | "podcast" -> LibraryType.PODCAST 25 | "book" -> LibraryType.LIBRARY 26 | else -> LibraryType.UNKNOWN 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-anydpi/ic_downloading.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/playback/service/SuspendableCountDownTimer.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import android.os.CountDownTimer 4 | 5 | class SuspendableCountDownTimer( 6 | totalMillis: Long, 7 | private val intervalMillis: Long, 8 | private val onTickSeconds: (Long) -> Unit, 9 | private val onFinished: () -> Unit, 10 | ) : CountDownTimer(totalMillis, intervalMillis) { 11 | private var remainingMillis: Long = totalMillis 12 | 13 | override fun onTick(millisUntilFinished: Long) { 14 | remainingMillis = millisUntilFinished 15 | onTickSeconds(millisUntilFinished / 1000) 16 | } 17 | 18 | override fun onFinish() { 19 | remainingMillis = 0L 20 | onFinished() 21 | } 22 | 23 | fun pause(): Long { 24 | cancel() 25 | return remainingMillis 26 | } 27 | 28 | fun resume(): SuspendableCountDownTimer { 29 | val timer = SuspendableCountDownTimer(remainingMillis, intervalMillis, onTickSeconds, onFinished) 30 | timer.start() 31 | 32 | return timer 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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.AuthData 5 | import org.grakovne.lissen.channel.common.AuthMethod 6 | import javax.inject.Inject 7 | import javax.inject.Singleton 8 | 9 | @Singleton 10 | class AuthMethodResponseConverter 11 | @Inject 12 | constructor() { 13 | fun apply(response: AuthMethodResponse): AuthData { 14 | val methods = 15 | response 16 | .authMethods 17 | .mapNotNull { 18 | when (it) { 19 | "local" -> AuthMethod.CREDENTIALS 20 | "openid" -> AuthMethod.O_AUTH 21 | else -> null 22 | } 23 | } 24 | 25 | return AuthData( 26 | methods = methods, 27 | oauthLoginText = response.authFormData?.authOpenIDButtonText, 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class PersonalizedFeedResponse( 9 | val id: String, 10 | val labelStringKey: String, 11 | val entities: List, 12 | ) 13 | 14 | @Keep 15 | @JsonClass(generateAdapter = true) 16 | data class PersonalizedFeedItemResponse( 17 | val id: String, 18 | val libraryId: String, 19 | val media: PersonalizedFeedItemMediaResponse?, 20 | ) 21 | 22 | @Keep 23 | @JsonClass(generateAdapter = true) 24 | data class PersonalizedFeedItemMediaResponse( 25 | val id: String, 26 | val metadata: PersonalizedFeedItemMetadataResponse, 27 | ) 28 | 29 | @Keep 30 | @JsonClass(generateAdapter = true) 31 | data class PersonalizedFeedItemMetadataResponse( 32 | val title: String, 33 | val subtitle: String?, 34 | val authorName: String?, 35 | ) 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 6 | 7 | @Keep 8 | @JsonClass(generateAdapter = true) 9 | data class LibraryOrderingConfiguration( 10 | val option: LibraryOrderingOption, 11 | val direction: LibraryOrderingDirection, 12 | ) { 13 | companion object { 14 | val default = 15 | LibraryOrderingConfiguration( 16 | option = LibraryOrderingOption.TITLE, 17 | direction = LibraryOrderingDirection.ASCENDING, 18 | ) 19 | 20 | val saver: Saver = 21 | Saver( 22 | save = { 23 | listOf(it.option.name, it.direction.name) 24 | }, 25 | restore = { 26 | LibraryOrderingConfiguration( 27 | option = LibraryOrderingOption.valueOf(it[0]), 28 | direction = LibraryOrderingDirection.valueOf(it[1]), 29 | ) 30 | }, 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/common/FindRelatedFiles.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.common 2 | 3 | import org.grakovne.lissen.lib.domain.BookFile 4 | import org.grakovne.lissen.lib.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 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/connection/ServerRequestHeader.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain.connection 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | import java.util.UUID 6 | 7 | @Keep 8 | @JsonClass(generateAdapter = true) 9 | data class ServerRequestHeader( 10 | val name: String, 11 | val value: String, 12 | val id: UUID = UUID.randomUUID(), 13 | ) { 14 | companion object { 15 | fun empty() = ServerRequestHeader("", "") 16 | 17 | fun ServerRequestHeader.clean(): ServerRequestHeader { 18 | val name = this.name.clean() 19 | val value = this.value.clean() 20 | 21 | return this.copy(name = name, value = value) 22 | } 23 | 24 | /** 25 | * Cleans this string to contain only valid tchar characters for HTTP header names as per RFC 7230. 26 | * 27 | * @return A string containing only allowed tchar characters. 28 | */ 29 | private fun String.clean(): String { 30 | val invalidCharacters = Regex("[^!#\$%&'*+\\-.^_`|~0-9A-Za-z]") 31 | return this.replace(invalidCharacters, "").trim() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.domain.Book 5 | import org.grakovne.lissen.lib.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 | ) 26 | }.let { 27 | PagedItems( 28 | items = it, 29 | currentPage = response.page, 30 | totalItems = response.total, 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/channel/common/ApiClient.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory 5 | import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader 6 | import org.grakovne.lissen.lib.domain.fixUriScheme 7 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 8 | import retrofit2.Retrofit 9 | import retrofit2.converter.moshi.MoshiConverterFactory 10 | 11 | class ApiClient( 12 | host: String, 13 | requestHeaders: List?, 14 | preferences: LissenSharedPreferences, 15 | ) { 16 | private val httpClient = createOkHttpClient(requestHeaders, preferences = preferences) 17 | 18 | val retrofit: Retrofit = 19 | Retrofit 20 | .Builder() 21 | .baseUrl(host.fixUriScheme()) 22 | .client(httpClient) 23 | .addConverterFactory(MoshiConverterFactory.create(moshi)) 24 | .build() 25 | 26 | companion object { 27 | private val moshi: Moshi = 28 | Moshi 29 | .Builder() 30 | .add(KotlinJsonAdapterFactory()) 31 | .build() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheStorage.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import org.grakovne.lissen.content.cache.persistent.dao.CachedBookDao 6 | import org.grakovne.lissen.content.cache.persistent.dao.CachedLibraryDao 7 | import org.grakovne.lissen.content.cache.persistent.entity.BookChapterEntity 8 | import org.grakovne.lissen.content.cache.persistent.entity.BookEntity 9 | import org.grakovne.lissen.content.cache.persistent.entity.BookFileEntity 10 | import org.grakovne.lissen.content.cache.persistent.entity.CachedLibraryEntity 11 | import org.grakovne.lissen.content.cache.persistent.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 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/connection/LocalUrl.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain.connection 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | import org.grakovne.lissen.lib.domain.fixUriScheme 6 | import java.net.URI 7 | import java.util.UUID 8 | 9 | @Keep 10 | @JsonClass(generateAdapter = true) 11 | data class LocalUrl( 12 | val ssid: String, 13 | val route: String, 14 | val id: UUID = UUID.randomUUID(), 15 | ) { 16 | companion object { 17 | fun empty() = LocalUrl("", "") 18 | 19 | fun LocalUrl.clean(): LocalUrl { 20 | val name = this.ssid.cleanSsid() 21 | val value = this.route.cleanUrl() 22 | 23 | return this.copy(ssid = name, route = value) 24 | } 25 | 26 | private fun String.cleanSsid(): String { 27 | val validCharacters = Regex("[\\x20-\\x7E]") 28 | return this 29 | .filter { validCharacters.matches(it.toString()) } 30 | .trim() 31 | } 32 | 33 | private fun String.cleanUrl(): String { 34 | val validCharacters = Regex("[\\x20-\\x7E]") 35 | return this 36 | .filter { validCharacters.matches(it.toString()) } 37 | .trim() 38 | .fixUriScheme() 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | 3 | plugins { 4 | alias(libs.plugins.android.library) 5 | alias(libs.plugins.kotlin.android) 6 | 7 | id("com.google.devtools.ksp") 8 | } 9 | 10 | android { 11 | namespace = "org.grakovne.lissen.lib" 12 | compileSdk = 36 13 | 14 | defaultConfig { 15 | minSdk = 28 16 | 17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 18 | consumerProguardFiles("consumer-rules.pro") 19 | } 20 | 21 | buildTypes { 22 | release { 23 | isMinifyEnabled = false 24 | proguardFiles( 25 | getDefaultProguardFile("proguard-android-optimize.txt"), 26 | "proguard-rules.pro" 27 | ) 28 | } 29 | } 30 | compileOptions { 31 | sourceCompatibility = JavaVersion.VERSION_21 32 | targetCompatibility = JavaVersion.VERSION_21 33 | } 34 | kotlin { 35 | compilerOptions { 36 | jvmTarget.set(JvmTarget.JVM_21) 37 | } 38 | } 39 | } 40 | 41 | dependencies { 42 | implementation(libs.androidx.core.ktx) 43 | implementation(libs.material) 44 | 45 | implementation(libs.converter.moshi) 46 | implementation(libs.moshi) 47 | implementation(libs.moshi.kotlin) 48 | 49 | ksp(libs.moshi.kotlin.codegen) 50 | } -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.domain.Book 5 | import org.grakovne.lissen.lib.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 | ) 26 | }.let { 27 | PagedItems( 28 | items = it, 29 | currentPage = response.page, 30 | totalItems = response.total, 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DownloadOption.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.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 | 19 | fun DownloadOption?.makeId() = when (this) { 20 | null -> "disabled" 21 | AllItemsDownloadOption -> "all_items" 22 | CurrentItemDownloadOption -> "current_item" 23 | is NumberItemDownloadOption -> "number_items_$itemsNumber" 24 | RemainingItemsDownloadOption -> "remaining_items" 25 | } 26 | 27 | fun String?.makeDownloadOption(): DownloadOption? = when { 28 | this == null -> null 29 | this == "all_items" -> AllItemsDownloadOption 30 | this == "current_item" -> CurrentItemDownloadOption 31 | this == "remaining_items" -> RemainingItemsDownloadOption 32 | startsWith("number_items_") -> NumberItemDownloadOption(substringAfter("number_items_").toInt()) 33 | else -> null 34 | } 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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/res/values-cy/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Llyfrgell 4 | Cyfrinair 5 | Dangos y cyfrinair 6 | Dewisiadau 7 | Mewngofnodi 8 | Eitemau 9 | Dewisiadau 10 | Parhau wrando 11 | Penodau 12 | Pennod %1$d o %2$s 13 | Rhan %1$d o %2$s 14 | Eitem %1$d o %2$s 15 | Rhannau 16 | Cyflymder 17 | 18 | -------------------------------------------------------------------------------- /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/kotlin/org/grakovne/lissen/channel/common/Pkce.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | import java.nio.charset.StandardCharsets 6 | import java.security.MessageDigest 7 | import java.util.Base64 8 | 9 | fun randomPkce(): Pkce { 10 | val verifier = generateRandomHexString(42) 11 | val challenge = base64UrlEncode(sha256(verifier)) 12 | val state = generateRandomHexString(42) 13 | 14 | return Pkce( 15 | verifier = verifier, 16 | challenge = challenge, 17 | state = state, 18 | ) 19 | } 20 | 21 | private fun generateRandomHexString(byteCount: Int = 42): String { 22 | val array = ByteArray(byteCount) 23 | java.security.SecureRandom().nextBytes(array) 24 | 25 | return array.joinToString("") { "%02x".format(it) } 26 | } 27 | 28 | private fun sha256(input: String): ByteArray { 29 | val digest = MessageDigest.getInstance("SHA-256") 30 | return digest.digest(input.toByteArray(StandardCharsets.US_ASCII)) 31 | } 32 | 33 | private fun base64UrlEncode(bytes: ByteArray) = 34 | Base64 35 | .getUrlEncoder() 36 | .withoutPadding() 37 | .encodeToString(bytes) 38 | 39 | @Keep 40 | @JsonClass(generateAdapter = true) 41 | data class Pkce( 42 | val verifier: String, 43 | val challenge: String, 44 | val state: String, 45 | ) 46 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class PodcastResponse( 9 | val id: String, 10 | val ino: String, 11 | val libraryId: String, 12 | val media: PodcastMedia, 13 | val addedAt: Long, 14 | val ctimeMs: Long, 15 | ) 16 | 17 | @Keep 18 | @JsonClass(generateAdapter = true) 19 | data class PodcastMedia( 20 | val metadata: PodcastMediaMetadataResponse, 21 | val episodes: List?, 22 | ) 23 | 24 | @Keep 25 | @JsonClass(generateAdapter = true) 26 | data class PodcastMediaMetadataResponse( 27 | val title: String, 28 | val author: String?, 29 | val description: String?, 30 | val publisher: String?, 31 | ) 32 | 33 | @Keep 34 | @JsonClass(generateAdapter = true) 35 | data class PodcastEpisodeResponse( 36 | val id: String, 37 | val season: String?, 38 | val episode: String?, 39 | val pubDate: String?, 40 | val title: String, 41 | val audioFile: PodcastAudioFileResponse, 42 | ) 43 | 44 | @Keep 45 | @JsonClass(generateAdapter = true) 46 | data class PodcastAudioFileResponse( 47 | val ino: String, 48 | val duration: Double, 49 | val mimeType: String, 50 | ) 51 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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/kotlin/org/grakovne/lissen/channel/common/OperationResult.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | sealed class OperationResult { 8 | data class Success( 9 | val data: T, 10 | ) : OperationResult() 11 | 12 | data class Error( 13 | val code: OperationError, 14 | val message: String? = null, 15 | ) : OperationResult() 16 | 17 | fun fold( 18 | onSuccess: (T) -> R, 19 | onFailure: (Error) -> R, 20 | ): R = 21 | when (this) { 22 | is Success -> onSuccess(this.data) 23 | is Error -> onFailure(this) 24 | } 25 | 26 | suspend fun foldAsync( 27 | onSuccess: suspend (T) -> R, 28 | onFailure: suspend (Error) -> R, 29 | ): R = 30 | when (this) { 31 | is Success -> onSuccess(this.data) 32 | is Error -> onFailure(this) 33 | } 34 | 35 | suspend fun map(transform: suspend (T) -> R): OperationResult = 36 | when (this) { 37 | is Success -> Success(transform(this.data)) 38 | is Error -> Error(this.code, this.message) 39 | } 40 | 41 | suspend fun flatMap(transform: suspend (T) -> OperationResult): OperationResult = 42 | when (this) { 43 | is Success -> transform(this.data) 44 | is Error -> Error(this.code, this.message) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.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 | ?.mapNotNull { 21 | val media = it.media ?: return@mapNotNull null 22 | 23 | RecentBook( 24 | id = it.id, 25 | title = media.metadata.title, 26 | subtitle = media.metadata.subtitle, 27 | author = media.metadata.authorName, 28 | listenedPercentage = progress[it.id]?.second?.let { it * 100 }?.toInt(), 29 | listenedLastUpdate = progress[it.id]?.first, 30 | ) 31 | } ?: emptyList() 32 | 33 | companion object { 34 | private const val LABEL_CONTINUE_LISTENING = "LabelContinueListening" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/OfflineBookStorageProperties.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 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 OfflineBookStorageProperties 11 | @Inject 12 | constructor( 13 | @ApplicationContext private val context: Context, 14 | ) { 15 | private fun baseFolder(): File = 16 | context 17 | .getExternalFilesDir(MEDIA_CACHE_FOLDER) 18 | ?.takeIf { it.exists() || it.mkdirs() && it.canWrite() } 19 | ?: context 20 | .cacheDir 21 | .resolve(MEDIA_CACHE_FOLDER) 22 | .apply { 23 | if (exists().not()) { 24 | mkdirs() 25 | } 26 | } 27 | 28 | fun provideBookCache(bookId: String): File = baseFolder().resolve(bookId) 29 | 30 | fun provideMediaCachePatch( 31 | bookId: String, 32 | fileId: String, 33 | ): File = 34 | baseFolder() 35 | .resolve(bookId) 36 | .resolve(fileId) 37 | 38 | fun provideBookCoverPath(bookId: String): File = 39 | baseFolder() 40 | .resolve(bookId) 41 | .resolve("cover.img") 42 | 43 | companion object { 44 | const val MEDIA_CACHE_FOLDER = "media_cache" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/dao/CachedLibraryDao.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.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.persistent.entity.CachedLibraryEntity 9 | import org.grakovne.lissen.lib.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/kotlin/org/grakovne/lissen/content/cache/persistent/converter/CachedBookEntityConverter.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.converter 2 | 3 | import com.squareup.moshi.Moshi 4 | import com.squareup.moshi.Types 5 | import org.grakovne.lissen.common.moshi 6 | import org.grakovne.lissen.content.cache.persistent.entity.BookEntity 7 | import org.grakovne.lissen.content.cache.persistent.entity.BookSeriesDto 8 | import org.grakovne.lissen.lib.domain.Book 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | @Singleton 13 | class CachedBookEntityConverter 14 | @Inject 15 | constructor() { 16 | fun apply(entity: BookEntity): Book = 17 | Book( 18 | id = entity.id, 19 | title = entity.title, 20 | subtitle = entity.subtitle, 21 | author = entity.author, 22 | series = 23 | entity 24 | .seriesJson 25 | ?.let { 26 | val type = Types.newParameterizedType(List::class.java, BookSeriesDto::class.java) 27 | val adapter = moshi.adapter>(type) 28 | adapter.fromJson(it) 29 | }?.joinToString(", ") { series -> 30 | buildString { 31 | append(series.title) 32 | series.sequence 33 | ?.takeIf(String::isNotBlank) 34 | ?.let { append(" #$it") } 35 | } 36 | }, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/CalculateRequestedChapters.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 2 | 3 | import org.grakovne.lissen.lib.domain.AllItemsDownloadOption 4 | import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption 5 | import org.grakovne.lissen.lib.domain.DetailedItem 6 | import org.grakovne.lissen.lib.domain.DownloadOption 7 | import org.grakovne.lissen.lib.domain.NumberItemDownloadOption 8 | import org.grakovne.lissen.lib.domain.PlayingChapter 9 | import org.grakovne.lissen.lib.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/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibrarySearchPagingSource.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.library.paging 2 | 3 | import androidx.paging.PagingState 4 | import org.grakovne.lissen.common.LibraryPagingSource 5 | import org.grakovne.lissen.content.LissenMediaProvider 6 | import org.grakovne.lissen.lib.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 | onTotalCountChanged: (Int) -> Unit, 15 | ) : LibraryPagingSource(onTotalCountChanged) { 16 | override fun getRefreshKey(state: PagingState) = null 17 | 18 | override suspend fun load(params: LoadParams): LoadResult { 19 | val libraryId = 20 | preferences 21 | .getPreferredLibrary() 22 | ?.id 23 | ?: return LoadResult.Page(emptyList(), null, null) 24 | 25 | if (searchToken.isBlank()) { 26 | return LoadResult.Page(emptyList(), null, null) 27 | } 28 | 29 | return mediaChannel 30 | .searchBooks(libraryId, searchToken, limit) 31 | .fold( 32 | onSuccess = { 33 | onTotalCountChanged.invoke(it.size) 34 | LoadResult.Page(it, null, null) 35 | }, 36 | onFailure = { LoadResult.Page(emptyList(), null, null) }, 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.PagingState 4 | import org.grakovne.lissen.common.LibraryPagingSource 5 | import org.grakovne.lissen.content.cache.persistent.LocalCacheRepository 6 | import org.grakovne.lissen.lib.domain.DetailedItem 7 | 8 | class CachedItemsPageSource( 9 | private val localCacheRepository: LocalCacheRepository, 10 | onTotalCountChanged: (Int) -> Unit, 11 | ) : LibraryPagingSource(onTotalCountChanged) { 12 | override fun getRefreshKey(state: PagingState): Int? = 13 | state 14 | .anchorPosition 15 | ?.let { anchorPosition -> 16 | state 17 | .closestPageToPosition(anchorPosition) 18 | ?.prevKey 19 | ?.plus(1) 20 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) 21 | } 22 | 23 | override suspend fun load(params: LoadParams): LoadResult = 24 | localCacheRepository 25 | .fetchDetailedItems( 26 | pageSize = params.loadSize, 27 | pageNumber = params.key ?: 0, 28 | ).fold( 29 | onSuccess = { result -> 30 | val nextPage = if (result.items.isEmpty()) null else result.currentPage + 1 31 | val prevKey = if (result.currentPage == 0) null else result.currentPage - 1 32 | 33 | LoadResult.Page( 34 | data = result.items, 35 | prevKey = prevKey, 36 | nextKey = nextPage, 37 | ) 38 | }, 39 | onFailure = { 40 | LoadResult.Page(emptyList(), null, null) 41 | }, 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.domain.Library 8 | import org.grakovne.lissen.lib.domain.LibraryType 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/kotlin/org/grakovne/lissen/channel/common/ChannelAuthService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import org.grakovne.lissen.lib.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 | ): OperationResult 15 | 16 | abstract suspend fun startOAuth( 17 | host: String, 18 | onSuccess: () -> Unit, 19 | onFailure: (OperationError) -> 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): OperationResult 30 | 31 | fun persistCredentials( 32 | host: String, 33 | username: String, 34 | token: String?, 35 | accessToken: String?, 36 | refreshToken: String?, 37 | ) { 38 | preferences.saveHost(host) 39 | preferences.saveUsername(username) 40 | 41 | token?.let { preferences.saveToken(it) } 42 | accessToken?.let { preferences.saveAccessToken(it) } 43 | refreshToken?.let { preferences.saveRefreshToken(it) } 44 | } 45 | 46 | fun examineError(raw: String): OperationError = 47 | when { 48 | raw.contains("Invalid redirect_uri") -> OperationError.InvalidRedirectUri 49 | raw.contains("invalid_host") -> OperationError.MissingCredentialsHost 50 | else -> OperationError.OAuthFlowFailed 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.AudioBookshelfRepository 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.OperationResult 7 | import org.grakovne.lissen.lib.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: AudioBookshelfRepository, 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 | ): OperationResult { 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/kotlin/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.AudioBookshelfRepository 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.OperationResult 7 | import org.grakovne.lissen.lib.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: AudioBookshelfRepository, 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 | ): OperationResult { 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 | -------------------------------------------------------------------------------- /lib/src/main/kotlin/org/grakovne/lissen/lib/domain/DetailedItem.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.lib.domain 2 | 3 | import androidx.annotation.Keep 4 | import com.squareup.moshi.JsonClass 5 | import java.io.Serializable 6 | 7 | @Keep 8 | @JsonClass(generateAdapter = true) 9 | data class DetailedItem( 10 | val id: String, 11 | val title: String, 12 | val subtitle: String?, 13 | val author: String?, 14 | val narrator: String?, 15 | val publisher: String?, 16 | val series: List, 17 | val year: String?, 18 | val abstract: String?, 19 | val files: List, 20 | val chapters: List, 21 | val progress: MediaProgress?, 22 | val libraryId: String?, 23 | val localProvided: Boolean, 24 | val createdAt: Long, 25 | val updatedAt: Long, 26 | ) : Serializable 27 | 28 | @Keep 29 | @JsonClass(generateAdapter = true) 30 | data class BookFile( 31 | val id: String, 32 | val name: String, 33 | val duration: Double, 34 | val mimeType: String, 35 | ) : Serializable 36 | 37 | @Keep 38 | @JsonClass(generateAdapter = true) 39 | data class MediaProgress( 40 | val currentTime: Double, 41 | val isFinished: Boolean, 42 | val lastUpdate: Long, 43 | ) : Serializable 44 | 45 | @Keep 46 | @JsonClass(generateAdapter = true) 47 | data class PlayingChapter( 48 | val available: Boolean, 49 | val podcastEpisodeState: BookChapterState?, 50 | val duration: Double, 51 | val start: Double, 52 | val end: Double, 53 | val title: String, 54 | val id: String, 55 | ) : Serializable 56 | 57 | @Keep 58 | @JsonClass(generateAdapter = true) 59 | data class BookSeries( 60 | val serialNumber: String?, 61 | val name: String, 62 | ) : Serializable 63 | 64 | @Keep 65 | enum class BookChapterState { 66 | FINISHED, 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/api/SafeApiCall.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf.common.api 2 | 3 | import org.grakovne.lissen.channel.common.OperationError 4 | import org.grakovne.lissen.channel.common.OperationResult 5 | import retrofit2.Response 6 | import timber.log.Timber 7 | import java.io.IOException 8 | import kotlin.coroutines.cancellation.CancellationException 9 | 10 | suspend fun safeApiCall(apiCall: suspend () -> Response): OperationResult { 11 | return try { 12 | val response = apiCall.invoke() 13 | 14 | return when (response.code()) { 15 | 200 -> 16 | when (val body = response.body()) { 17 | null -> OperationResult.Error(OperationError.InternalError) 18 | else -> OperationResult.Success(body) 19 | } 20 | 21 | 400 -> OperationResult.Error(OperationError.InternalError) 22 | 401 -> OperationResult.Error(OperationError.Unauthorized) 23 | 403 -> OperationResult.Error(OperationError.Unauthorized) 24 | 404 -> OperationResult.Error(OperationError.NotFoundError) 25 | 500 -> OperationResult.Error(OperationError.InternalError) 26 | else -> OperationResult.Error(OperationError.InternalError) 27 | } 28 | } catch (e: IOException) { 29 | Timber.e("Unable to make network api call due to: $e") 30 | OperationResult.Error(OperationError.NetworkError) 31 | } catch (e: CancellationException) { 32 | Timber.d("Api call was cancelled. Skipping") 33 | // https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/ 34 | throw e 35 | } catch (e: Exception) { 36 | Timber.e("Unable to make network api call due to: $e") 37 | OperationResult.Error(OperationError.InternalError) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.lib.domain.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/kotlin/org/grakovne/lissen/content/cache/temporary/ShortTermCacheStorageProperties.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.temporary 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 ShortTermCacheStorageProperties 11 | @Inject 12 | constructor( 13 | @ApplicationContext private val context: Context, 14 | ) { 15 | fun provideCoverCacheFolder(): File { 16 | val baseFolder = 17 | context 18 | .externalCacheDir 19 | ?.takeIf { it.exists() && it.canWrite() } 20 | ?: context.cacheDir 21 | 22 | return baseFolder 23 | ?.resolve(SHORT_TERM_CACHE_FOLDER) 24 | ?.resolve(COVER_CACHE_FOLDER_NAME) 25 | ?: throw IllegalStateException("Unable to resole cache cover path. Seems like there is no externalCacheDir") 26 | } 27 | 28 | fun provideCoverPath( 29 | itemId: String, 30 | width: Int?, 31 | ): File { 32 | val baseFolder = 33 | context 34 | .externalCacheDir 35 | ?.takeIf { it.exists() && it.canWrite() } 36 | ?: context.cacheDir 37 | 38 | return baseFolder 39 | ?.resolve(SHORT_TERM_CACHE_FOLDER) 40 | ?.resolve(COVER_CACHE_FOLDER_NAME) 41 | ?.resolve(width.toPath()) 42 | ?.resolve(itemId) 43 | ?: throw IllegalStateException("Unable to resole cache cover path. Seems like there is no externalCacheDir") 44 | } 45 | 46 | companion object { 47 | const val SHORT_TERM_CACHE_FOLDER = "short_term_cache" 48 | const val COVER_CACHE_FOLDER_NAME = "cover_cache" 49 | } 50 | } 51 | 52 | private fun Int?.toPath() = 53 | when (this) { 54 | null -> "raw" 55 | else -> "crop_$this" 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsSimpleItemComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.advanced 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.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.text.font.FontWeight 15 | import androidx.compose.ui.text.style.TextOverflow 16 | import androidx.compose.ui.unit.dp 17 | 18 | @Composable 19 | fun AdvancedSettingsSimpleItemComposable( 20 | title: String, 21 | description: String, 22 | onclick: () -> Unit, 23 | ) { 24 | Row( 25 | modifier = 26 | Modifier 27 | .fillMaxWidth() 28 | .clickable { onclick() } 29 | .padding(start = 24.dp, end = 12.dp, top = 12.dp, bottom = 12.dp), 30 | verticalAlignment = Alignment.CenterVertically, 31 | ) { 32 | Column( 33 | modifier = Modifier.weight(1f), 34 | ) { 35 | Text( 36 | text = title, 37 | style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), 38 | modifier = Modifier.padding(bottom = 4.dp), 39 | maxLines = 1, 40 | overflow = TextOverflow.Ellipsis, 41 | ) 42 | Text( 43 | text = description, 44 | style = typography.bodyMedium, 45 | color = colorScheme.onSurfaceVariant, 46 | maxLines = 1, 47 | overflow = TextOverflow.Ellipsis, 48 | ) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/FetchRequestBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.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/kotlin/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfChannelProvider.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf 2 | 3 | import org.grakovne.lissen.channel.audiobookshelf.common.api.AudiobookshelfAuthService 4 | import org.grakovne.lissen.channel.audiobookshelf.library.LibraryAudiobookshelfChannel 5 | import org.grakovne.lissen.channel.audiobookshelf.podcast.PodcastAudiobookshelfChannel 6 | import org.grakovne.lissen.channel.common.ChannelAuthService 7 | import org.grakovne.lissen.channel.common.ChannelCode 8 | import org.grakovne.lissen.channel.common.ChannelProvider 9 | import org.grakovne.lissen.channel.common.MediaChannel 10 | import org.grakovne.lissen.lib.domain.LibraryType 11 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 12 | import javax.inject.Inject 13 | import javax.inject.Singleton 14 | 15 | @Singleton 16 | class AudiobookshelfChannelProvider 17 | @Inject 18 | constructor( 19 | private val podcastAudiobookshelfChannel: PodcastAudiobookshelfChannel, 20 | private val libraryAudiobookshelfChannel: LibraryAudiobookshelfChannel, 21 | private val audiobookshelfAuthService: AudiobookshelfAuthService, 22 | private val sharedPreferences: LissenSharedPreferences, 23 | ) : ChannelProvider { 24 | override fun provideMediaChannel(): MediaChannel { 25 | val libraryType = 26 | sharedPreferences 27 | .getPreferredLibrary() 28 | ?.type 29 | ?: LibraryType.UNKNOWN 30 | 31 | return when (libraryType) { 32 | LibraryType.LIBRARY -> libraryAudiobookshelfChannel 33 | LibraryType.PODCAST -> podcastAudiobookshelfChannel 34 | LibraryType.UNKNOWN -> libraryAudiobookshelfChannel 35 | } 36 | } 37 | 38 | override fun provideChannelAuth(): ChannelAuthService = audiobookshelfAuthService 39 | 40 | override fun getChannelCode(): ChannelCode = ChannelCode.AUDIOBOOKSHELF 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppNavigationService.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.navigation 2 | 3 | import android.net.Uri 4 | import androidx.navigation.NavGraph.Companion.findStartDestination 5 | import androidx.navigation.NavHostController 6 | 7 | class AppNavigationService( 8 | private val host: NavHostController, 9 | ) { 10 | fun showLibrary(clearHistory: Boolean = false) { 11 | host.navigate(ROUTE_LIBRARY) { 12 | val startId = host.graph.findStartDestination().id 13 | popUpTo(startId) { inclusive = clearHistory } 14 | 15 | launchSingleTop = true 16 | } 17 | } 18 | 19 | fun showPlayer( 20 | bookId: String, 21 | bookTitle: String, 22 | bookSubtitle: String?, 23 | startInstantly: Boolean = false, 24 | ) { 25 | val route = 26 | buildString { 27 | append("$ROUTE_PLAYER/$bookId") 28 | append("?bookTitle=${Uri.encode(bookTitle)}") 29 | append("&bookSubtitle=${Uri.encode(bookSubtitle ?: "")}") 30 | append("&startInstantly=$startInstantly") 31 | } 32 | host.navigate(route) { launchSingleTop = true } 33 | } 34 | 35 | fun showSettings() = host.navigate(ROUTE_SETTINGS) 36 | 37 | fun showCustomHeadersSettings() = host.navigate("$ROUTE_SETTINGS/custom_headers") 38 | 39 | fun showLocalUrlSettings() = host.navigate("$ROUTE_SETTINGS/local_url") 40 | 41 | fun showSeekSettings() = host.navigate("$ROUTE_SETTINGS/seek_settings") 42 | 43 | fun showCachedItemsSettings() = host.navigate("$ROUTE_SETTINGS/cached_items") 44 | 45 | fun showCacheSettings() = host.navigate("$ROUTE_SETTINGS/cache_settings") 46 | 47 | fun showAdvancedSettings() = host.navigate("$ROUTE_SETTINGS/advanced_settings") 48 | 49 | fun showLogin() { 50 | host.navigate(ROUTE_LOGIN) { 51 | val startId = host.graph.findStartDestination().id 52 | popUpTo(startId) { inclusive = true } 53 | 54 | launchSingleTop = true 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/LocalCacheModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent 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.persistent.dao.CachedBookDao 11 | import org.grakovne.lissen.content.cache.persistent.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/kotlin/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.channel.audiobookshelf.Host 6 | import org.grakovne.lissen.lib.domain.Book 7 | import org.grakovne.lissen.lib.domain.DetailedItem 8 | import org.grakovne.lissen.lib.domain.Library 9 | import org.grakovne.lissen.lib.domain.LibraryType 10 | import org.grakovne.lissen.lib.domain.PagedItems 11 | import org.grakovne.lissen.lib.domain.PlaybackProgress 12 | import org.grakovne.lissen.lib.domain.PlaybackSession 13 | import org.grakovne.lissen.lib.domain.RecentBook 14 | 15 | interface MediaChannel { 16 | fun getLibraryType(): LibraryType 17 | 18 | fun provideFileUri( 19 | libraryItemId: String, 20 | fileId: String, 21 | ): Uri 22 | 23 | suspend fun syncProgress( 24 | sessionId: String, 25 | progress: PlaybackProgress, 26 | ): OperationResult 27 | 28 | suspend fun fetchBookCover( 29 | bookId: String, 30 | width: Int? = null, 31 | ): OperationResult 32 | 33 | suspend fun fetchBooks( 34 | libraryId: String, 35 | pageSize: Int, 36 | pageNumber: Int, 37 | ): OperationResult> 38 | 39 | suspend fun searchBooks( 40 | libraryId: String, 41 | query: String, 42 | limit: Int, 43 | ): OperationResult> 44 | 45 | suspend fun fetchLibraries(): OperationResult> 46 | 47 | suspend fun startPlayback( 48 | bookId: String, 49 | episodeId: String, 50 | supportedMimeTypes: List, 51 | deviceId: String, 52 | ): OperationResult 53 | 54 | fun fetchConnectionHost(): OperationResult 55 | 56 | suspend fun fetchConnectionInfo(): OperationResult 57 | 58 | suspend fun fetchRecentListenedBooks(libraryId: String): OperationResult> 59 | 60 | suspend fun fetchBook(bookId: String): OperationResult 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/persistent/api/SearchRequestBuilder.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.persistent.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/kotlin/org/grakovne/lissen/ui/screens/settings/composable/LicenseFooterComposable.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 LicenseFooterComposable() { 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-2026 Max Grakov. MIT License", 51 | style = 52 | TextStyle( 53 | fontFamily = FontFamily.Monospace, 54 | textAlign = TextAlign.Center, 55 | ), 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/content/cache/common/ImageBlur.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.common 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_GAUSSIAN 11 | import com.hoko.blur.HokoBlur.SCHEME_OPENGL 12 | import okio.Buffer 13 | import okio.BufferedSource 14 | 15 | fun Buffer.withBlur(context: Context): Buffer { 16 | val dimensions: Pair? = getImageDimensions(this) 17 | 18 | return when (dimensions?.first == dimensions?.second) { 19 | true -> this 20 | false -> runCatching { sourceWithBackdropBlur(this, context) }.getOrElse { this } 21 | } 22 | } 23 | 24 | private fun sourceWithBackdropBlur( 25 | source: BufferedSource, 26 | context: Context, 27 | ): Buffer { 28 | val peeked = source.peek() 29 | 30 | val original = BitmapFactory.decodeStream(peeked.inputStream()) 31 | val width = original.width 32 | val height = original.height 33 | 34 | val size = maxOf(width, height) 35 | 36 | val radius = 32 37 | val padding = radius * 2 38 | 39 | val scaled = original.scale(size + padding, size + padding) 40 | 41 | val blurredPadded = 42 | HokoBlur 43 | .with(context) 44 | .scheme(SCHEME_OPENGL) 45 | .mode(MODE_GAUSSIAN) 46 | .radius(radius) 47 | .forceCopy(true) 48 | .blur(scaled) 49 | 50 | val backdrop = Bitmap.createBitmap(blurredPadded, padding / 2, padding / 2, size, size) 51 | 52 | val result = createBitmap(size, size, Bitmap.Config.RGB_565) 53 | 54 | val canvas = Canvas(result) 55 | canvas.drawBitmap(backdrop, 0f, 0f, null) 56 | 57 | val left = ((size - width) / 2f) 58 | val top = ((size - height) / 2f) 59 | 60 | canvas.drawBitmap(original, left, top, null) 61 | 62 | return Buffer().apply { result.compress(Bitmap.CompressFormat.JPEG, 90, this.outputStream()) } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/screens/library/paging/LibraryDefaultPagingSource.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.library.paging 2 | 3 | import androidx.paging.PagingState 4 | import org.grakovne.lissen.common.LibraryPagingSource 5 | import org.grakovne.lissen.content.LissenMediaProvider 6 | import org.grakovne.lissen.lib.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 | onTotalCountChanged: (Int) -> Unit, 13 | ) : LibraryPagingSource(onTotalCountChanged) { 14 | override fun getRefreshKey(state: PagingState) = 15 | state 16 | .anchorPosition 17 | ?.let { anchorPosition -> 18 | state 19 | .closestPageToPosition(anchorPosition) 20 | ?.prevKey 21 | ?.plus(1) 22 | ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) 23 | } 24 | 25 | override suspend fun load(params: LoadParams): LoadResult { 26 | val libraryId = 27 | preferences 28 | .getPreferredLibrary() 29 | ?.id 30 | ?: return LoadResult.Page(emptyList(), null, null) 31 | 32 | return mediaChannel 33 | .fetchBooks( 34 | libraryId = libraryId, 35 | pageSize = params.loadSize, 36 | pageNumber = params.key ?: 0, 37 | ).fold( 38 | onSuccess = { result -> 39 | val nextPage = if (result.items.isEmpty()) null else result.currentPage + 1 40 | val prevKey = if (result.currentPage == 0) null else result.currentPage - 1 41 | 42 | onTotalCountChanged.invoke(result.totalItems) 43 | 44 | LoadResult.Page( 45 | data = result.items, 46 | prevKey = prevKey, 47 | nextKey = nextPage, 48 | ) 49 | }, 50 | onFailure = { 51 | LoadResult.Page(emptyList(), null, null) 52 | }, 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/channel/common/OkHttpClient.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.common 2 | 3 | import okhttp3.Cache 4 | import okhttp3.Interceptor 5 | import okhttp3.OkHttpClient 6 | import okhttp3.Request 7 | import okhttp3.Response 8 | import okhttp3.logging.HttpLoggingInterceptor 9 | import org.grakovne.lissen.common.withSslBypass 10 | import org.grakovne.lissen.common.withTrustedCertificates 11 | import org.grakovne.lissen.lib.domain.connection.ServerRequestHeader 12 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 13 | import java.util.concurrent.TimeUnit 14 | 15 | fun createOkHttpClient( 16 | requestHeaders: List?, 17 | preferences: LissenSharedPreferences, 18 | ): OkHttpClient { 19 | var builder = OkHttpClient.Builder() 20 | 21 | builder = 22 | when (preferences.getSslBypass()) { 23 | true -> builder.withSslBypass() 24 | false -> builder.withTrustedCertificates() 25 | } 26 | 27 | return builder 28 | .addInterceptor(loggingInterceptor()) 29 | .addInterceptor { chain -> authInterceptor(chain, preferences, requestHeaders) } 30 | .connectTimeout(60, TimeUnit.SECONDS) 31 | .readTimeout(120, TimeUnit.SECONDS) 32 | .build() 33 | } 34 | 35 | private fun loggingInterceptor() = 36 | HttpLoggingInterceptor().apply { 37 | level = HttpLoggingInterceptor.Level.NONE 38 | } 39 | 40 | private fun authInterceptor( 41 | chain: Interceptor.Chain, 42 | preferences: LissenSharedPreferences, 43 | requestHeaders: List?, 44 | ): Response { 45 | val original: Request = chain.request() 46 | val requestBuilder: Request.Builder = original.newBuilder() 47 | 48 | val bearer = preferences.getAccessToken() ?: preferences.getToken() 49 | bearer?.let { requestBuilder.header("Authorization", "Bearer $it") } 50 | 51 | requestHeaders 52 | ?.filter { it.name.isNotEmpty() } 53 | ?.filter { it.value.isNotEmpty() } 54 | ?.forEach { requestBuilder.header(it.name, it.value) } 55 | 56 | return chain.proceed(requestBuilder.build()) 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 coil3.ImageLoader 17 | import coil3.compose.AsyncImage 18 | import coil3.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/kotlin/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 | import timber.log.Timber 20 | 21 | @Composable 22 | fun GitHubLinkComposable() { 23 | val uriHandler = LocalUriHandler.current 24 | 25 | Row( 26 | modifier = 27 | Modifier 28 | .fillMaxWidth() 29 | .clickable { 30 | try { 31 | uriHandler.openUri("https://github.com/GrakovNe/lissen-android") 32 | } catch (ex: Exception) { 33 | Timber.d("Unable to open Github Link due to ${ex.message}") 34 | } 35 | }.padding(horizontal = 24.dp, vertical = 12.dp), 36 | ) { 37 | Column( 38 | modifier = Modifier.weight(1f), 39 | ) { 40 | Text( 41 | text = stringResource(R.string.source_code_on_github_title), 42 | style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), 43 | modifier = Modifier.padding(bottom = 4.dp), 44 | ) 45 | Text( 46 | text = stringResource(R.string.source_code_on_github_subtitle), 47 | style = typography.bodyMedium, 48 | color = colorScheme.onSurfaceVariant, 49 | maxLines = 1, 50 | modifier = Modifier.padding(bottom = 4.dp), 51 | overflow = TextOverflow.Ellipsis, 52 | ) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/AudiobookshelfHostProvider.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.channel.audiobookshelf 2 | 3 | import org.grakovne.lissen.common.NetworkService 4 | import org.grakovne.lissen.lib.domain.NetworkType 5 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 6 | import timber.log.Timber 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | class AudiobookshelfHostProvider 12 | @Inject 13 | constructor( 14 | private val sharedPreferences: LissenSharedPreferences, 15 | private val networkService: NetworkService, 16 | ) { 17 | fun provideHost(): Host? { 18 | val externalHost = 19 | sharedPreferences 20 | .getHost() 21 | ?.let(Host.Companion::external) 22 | ?: return null 23 | 24 | if (sharedPreferences.getLocalUrls().isEmpty()) { 25 | Timber.d("Using external host: ${externalHost.url}, no local routes") 26 | return externalHost 27 | } 28 | 29 | if (networkService.getCurrentNetworkType() == NetworkType.CELLULAR) { 30 | Timber.d("Using external host: ${externalHost.url}, no WiFi connection") 31 | return externalHost 32 | } 33 | 34 | val currentNetwork = 35 | networkService 36 | .getCurrentWifiSSID() 37 | ?: return externalHost.also { Timber.d("Using external host: ${externalHost.url}, can't detect WiFi network") } 38 | 39 | return sharedPreferences 40 | .getLocalUrls() 41 | .find { it.ssid.equals(currentNetwork, ignoreCase = true) } 42 | ?.route 43 | ?.let(Host.Companion::internal) 44 | ?.also { Timber.d("Using internal host: ${it.url}") } 45 | ?: externalHost.also { Timber.d("Using external host: ${it.url}, no internal matches") } 46 | } 47 | } 48 | 49 | enum class HostType { 50 | INTERNAL, 51 | EXTERNAL, 52 | } 53 | 54 | data class Host( 55 | val url: String, 56 | val type: HostType, 57 | ) { 58 | companion object { 59 | fun external(url: String) = Host(url, HostType.EXTERNAL) 60 | 61 | fun internal(url: String) = Host(url, HostType.INTERNAL) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/common/CertificateExtension.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.common 2 | 3 | import android.annotation.SuppressLint 4 | import okhttp3.OkHttpClient 5 | import java.security.KeyStore 6 | import java.security.SecureRandom 7 | import java.security.cert.X509Certificate 8 | import javax.net.ssl.SSLContext 9 | import javax.net.ssl.TrustManager 10 | import javax.net.ssl.TrustManagerFactory 11 | import javax.net.ssl.TrustManagerFactory.getInstance 12 | import javax.net.ssl.X509TrustManager 13 | 14 | private val systemTrustManager: X509TrustManager by lazy { 15 | val keyStore = KeyStore.getInstance("AndroidCAStore") 16 | keyStore.load(null) 17 | 18 | val trustManagerFactory = getInstance(TrustManagerFactory.getDefaultAlgorithm()) 19 | trustManagerFactory.init(keyStore) 20 | 21 | trustManagerFactory 22 | .trustManagers 23 | .first { it is X509TrustManager } as X509TrustManager 24 | } 25 | 26 | private val systemSSLContext: SSLContext by lazy { 27 | SSLContext.getInstance("TLS").apply { 28 | init(null, arrayOf(systemTrustManager), null) 29 | } 30 | } 31 | 32 | fun OkHttpClient.Builder.withTrustedCertificates(): OkHttpClient.Builder = 33 | try { 34 | sslSocketFactory(systemSSLContext.socketFactory, systemTrustManager) 35 | } catch (ex: Exception) { 36 | this 37 | } 38 | 39 | @SuppressLint("TrustAllX509TrustManager", "CustomX509TrustManager") 40 | fun OkHttpClient.Builder.withSslBypass(): OkHttpClient.Builder { 41 | val trustAll = 42 | object : X509TrustManager { 43 | override fun checkClientTrusted( 44 | chain: Array, 45 | authType: String, 46 | ) {} 47 | 48 | override fun checkServerTrusted( 49 | chain: Array, 50 | authType: String, 51 | ) {} 52 | 53 | override fun getAcceptedIssuers(): Array = arrayOf() 54 | } 55 | 56 | val sslContext = 57 | SSLContext.getInstance("TLS").apply { 58 | init(null, arrayOf(trustAll), SecureRandom()) 59 | } 60 | 61 | return this 62 | .sslSocketFactory(sslContext.socketFactory, trustAll) 63 | .hostnameVerifier { _, _ -> true } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 | import com.squareup.moshi.JsonClass 5 | 6 | @Keep 7 | @JsonClass(generateAdapter = true) 8 | data class BookResponse( 9 | val id: String, 10 | val ino: String, 11 | val libraryId: String, 12 | val media: BookMedia, 13 | val addedAt: Long, 14 | val ctimeMs: Long, 15 | ) 16 | 17 | @Keep 18 | @JsonClass(generateAdapter = true) 19 | data class BookMedia( 20 | val metadata: LibraryMetadataResponse, 21 | val audioFiles: List?, 22 | val chapters: List?, 23 | ) 24 | 25 | @Keep 26 | @JsonClass(generateAdapter = true) 27 | data class LibraryMetadataResponse( 28 | val title: String, 29 | val subtitle: String?, 30 | val authors: List?, 31 | val narrators: List?, 32 | val series: List?, 33 | val description: String?, 34 | val publisher: String?, 35 | val publishedYear: String?, 36 | ) 37 | 38 | @Keep 39 | @JsonClass(generateAdapter = true) 40 | data class LibrarySeriesResponse( 41 | val id: String, 42 | val name: String, 43 | val sequence: String?, 44 | ) 45 | 46 | @Keep 47 | @JsonClass(generateAdapter = true) 48 | data class LibraryAuthorResponse( 49 | val id: String, 50 | val name: String, 51 | ) 52 | 53 | @Keep 54 | @JsonClass(generateAdapter = true) 55 | data class BookAudioFileResponse( 56 | val index: Int, 57 | val ino: String, 58 | val duration: Double, 59 | val metadata: AudioFileMetadata, 60 | val metaTags: AudioFileTag?, 61 | val mimeType: String, 62 | ) 63 | 64 | @Keep 65 | @JsonClass(generateAdapter = true) 66 | data class AudioFileMetadata( 67 | val filename: String, 68 | val ext: String, 69 | val size: Long, 70 | ) 71 | 72 | @Keep 73 | @JsonClass(generateAdapter = true) 74 | data class AudioFileTag( 75 | val tagTitle: String?, 76 | ) 77 | 78 | @Keep 79 | @JsonClass(generateAdapter = true) 80 | data class LibraryChapterResponse( 81 | val start: Double, 82 | val end: Double, 83 | val title: String, 84 | val id: String, 85 | ) 86 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/screens/settings/advanced/AdvancedSettingsNavigationItemComposable.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.settings.advanced 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 AdvancedSettingsNavigationItemComposable( 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 = "Forward", 58 | ) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/org/grakovne/lissen/channel/common/OperationError.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 OperationError { 9 | data object Unauthorized : OperationError() 10 | 11 | data object NetworkError : OperationError() 12 | 13 | data object InvalidCredentialsHost : OperationError() 14 | 15 | data object MissingCredentialsHost : OperationError() 16 | 17 | data object MissingCredentialsUsername : OperationError() 18 | 19 | data object MissingCredentialsPassword : OperationError() 20 | 21 | data object InternalError : OperationError() 22 | 23 | data object NotFoundError : OperationError() 24 | 25 | data object InvalidRedirectUri : OperationError() 26 | 27 | data object OAuthFlowFailed : OperationError() 28 | 29 | data object UnsupportedError : OperationError() 30 | } 31 | 32 | fun OperationError.makeText(context: Context) = 33 | when (this) { 34 | OperationError.InternalError -> context.getString(R.string.login_error_host_is_down) 35 | OperationError.MissingCredentialsHost -> context.getString(R.string.login_error_host_url_is_missing) 36 | OperationError.MissingCredentialsPassword -> context.getString(R.string.login_error_username_is_missing) 37 | OperationError.MissingCredentialsUsername -> context.getString(R.string.login_error_password_is_missing) 38 | OperationError.Unauthorized -> context.getString(R.string.login_error_credentials_are_invalid) 39 | OperationError.InvalidCredentialsHost -> context.getString(R.string.login_error_host_url_shall_be_https_or_http) 40 | OperationError.NetworkError -> context.getString(R.string.login_error_connection_error) 41 | OperationError.InvalidRedirectUri -> 42 | context.getString( 43 | R.string.login_error_lissen_auth_scheme_must_be_whitelisted, 44 | AuthScheme, 45 | AuthHost, 46 | ) 47 | OperationError.UnsupportedError -> context.getString(R.string.login_error_connection_error) 48 | OperationError.OAuthFlowFailed -> context.getString(R.string.login_error_lissen_auth_failed) 49 | OperationError.NotFoundError -> context.getString(R.string.login_error_lissen_not_found) 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.security.TLS 12 | import org.acra.sender.HttpSender 13 | import org.grakovne.lissen.common.RunningComponent 14 | import timber.log.Timber 15 | import javax.inject.Inject 16 | 17 | @HiltAndroidApp 18 | class LissenApplication : Application() { 19 | @Inject 20 | lateinit var runningComponents: Set<@JvmSuppressWildcards RunningComponent> 21 | 22 | override fun attachBaseContext(base: Context) { 23 | super.attachBaseContext(base) 24 | initCrashReporting() 25 | } 26 | 27 | override fun onCreate() { 28 | super.onCreate() 29 | appContext = applicationContext 30 | 31 | if (BuildConfig.DEBUG) { 32 | Timber.plant(Timber.DebugTree()) 33 | } 34 | 35 | runningComponents.forEach { 36 | try { 37 | it.onCreate() 38 | } catch (ex: Exception) { 39 | Timber.e("Unable to register Running component due to: ${ex.message}") 40 | } 41 | } 42 | } 43 | 44 | private fun initCrashReporting() { 45 | initAcra { 46 | sharedPreferencesName = "secure_prefs" 47 | 48 | buildConfigClass = BuildConfig::class.java 49 | reportFormat = StringFormat.JSON 50 | 51 | httpSender { 52 | uri = "https://acrarium.grakovne.org/report" 53 | basicAuthLogin = BuildConfig.ACRA_REPORT_LOGIN 54 | basicAuthPassword = BuildConfig.ACRA_REPORT_PASSWORD 55 | httpMethod = HttpSender.Method.POST 56 | dropReportsOnTimeout = false 57 | tlsProtocols = listOf(TLS.V1_3, TLS.V1_2) 58 | } 59 | 60 | toast { 61 | text = getString(R.string.app_crach_toast) 62 | } 63 | 64 | reportContent = 65 | listOf( 66 | ReportField.APP_VERSION_NAME, 67 | ReportField.APP_VERSION_CODE, 68 | ReportField.ANDROID_VERSION, 69 | ReportField.PHONE_MODEL, 70 | ReportField.STACK_TRACE, 71 | ReportField.ENVIRONMENT, 72 | ) 73 | } 74 | } 75 | 76 | companion object { 77 | lateinit var appContext: Context 78 | private set 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 coil3.ImageLoader 12 | import dagger.hilt.android.AndroidEntryPoint 13 | import org.grakovne.lissen.common.NetworkService 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.navigation.CONTINUE_PLAYBACK 19 | import org.grakovne.lissen.ui.navigation.SHOW_DOWNLOADS 20 | import org.grakovne.lissen.ui.theme.LissenTheme 21 | import javax.inject.Inject 22 | 23 | @AndroidEntryPoint 24 | class AppActivity : ComponentActivity() { 25 | @Inject 26 | lateinit var preferences: LissenSharedPreferences 27 | 28 | @Inject 29 | lateinit var imageLoader: ImageLoader 30 | 31 | @Inject 32 | lateinit var networkService: NetworkService 33 | 34 | private lateinit var appNavigationService: AppNavigationService 35 | 36 | override fun onCreate(savedInstanceState: Bundle?) { 37 | super.onCreate(savedInstanceState) 38 | enableEdgeToEdge() 39 | 40 | setContent { 41 | val colorScheme by preferences 42 | .colorSchemeFlow 43 | .collectAsState(initial = preferences.getColorScheme()) 44 | 45 | LissenTheme(colorScheme) { 46 | val navController = rememberNavController() 47 | appNavigationService = AppNavigationService(navController) 48 | 49 | AppNavHost( 50 | navController = navController, 51 | navigationService = appNavigationService, 52 | preferences = preferences, 53 | imageLoader = imageLoader, 54 | networkService = networkService, 55 | appLaunchAction = getLaunchAction(intent), 56 | ) 57 | } 58 | } 59 | } 60 | 61 | private fun getLaunchAction(intent: Intent?) = 62 | when (intent?.action) { 63 | CONTINUE_PLAYBACK -> AppLaunchAction.CONTINUE_PLAYBACK 64 | SHOW_DOWNLOADS -> AppLaunchAction.MANAGE_DOWNLOADS 65 | else -> AppLaunchAction.DEFAULT 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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 | initialState: Boolean, 24 | enabled: Boolean = true, 25 | onCheckedChange: (Boolean) -> Unit, 26 | ) { 27 | Row( 28 | modifier = 29 | Modifier 30 | .fillMaxWidth() 31 | .let { 32 | when (enabled) { 33 | true -> it.clickable { onCheckedChange(initialState.not()) } 34 | false -> it 35 | } 36 | }.padding(horizontal = 24.dp, vertical = 12.dp), 37 | verticalAlignment = Alignment.CenterVertically, 38 | ) { 39 | Column( 40 | modifier = Modifier.weight(1f), 41 | ) { 42 | Text( 43 | text = title, 44 | style = typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), 45 | modifier = Modifier.padding(bottom = 2.dp), 46 | color = 47 | when (enabled) { 48 | true -> colorScheme.onBackground 49 | false -> colorScheme.onBackground.copy(alpha = 0.4f) 50 | }, 51 | ) 52 | Text( 53 | text = description, 54 | style = typography.bodyMedium, 55 | color = 56 | when (enabled) { 57 | true -> colorScheme.onSurfaceVariant 58 | false -> colorScheme.onSurfaceVariant.copy(alpha = 0.4f) 59 | }, 60 | ) 61 | } 62 | 63 | Switch( 64 | enabled = enabled, 65 | checked = initialState, 66 | onCheckedChange = null, 67 | colors = 68 | SwitchDefaults.colors( 69 | uncheckedTrackColor = colorScheme.background, 70 | checkedBorderColor = colorScheme.onSurface, 71 | checkedThumbColor = colorScheme.onSurface, 72 | checkedTrackColor = colorScheme.background, 73 | ), 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/ui/screens/common/DownloadOptionFormat.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.ui.screens.common 2 | 3 | import android.content.Context 4 | import org.grakovne.lissen.R 5 | import org.grakovne.lissen.lib.domain.AllItemsDownloadOption 6 | import org.grakovne.lissen.lib.domain.CurrentItemDownloadOption 7 | import org.grakovne.lissen.lib.domain.DownloadOption 8 | import org.grakovne.lissen.lib.domain.LibraryType 9 | import org.grakovne.lissen.lib.domain.NumberItemDownloadOption 10 | import org.grakovne.lissen.lib.domain.RemainingItemsDownloadOption 11 | 12 | fun DownloadOption?.makeText( 13 | context: Context, 14 | libraryType: LibraryType, 15 | ): String = 16 | when (this) { 17 | null -> context.getString(R.string.downloads_menu_download_option_disable) 18 | 19 | CurrentItemDownloadOption -> { 20 | when (libraryType) { 21 | LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_current_chapter) 22 | LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_current_episode) 23 | LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_current_item) 24 | } 25 | } 26 | 27 | AllItemsDownloadOption -> { 28 | when (libraryType) { 29 | LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_entire_book) 30 | LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_entire_podcast) 31 | LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_entire_item) 32 | } 33 | } 34 | 35 | RemainingItemsDownloadOption -> { 36 | when (libraryType) { 37 | LibraryType.LIBRARY -> context.getString(R.string.downloads_menu_download_option_remaining_chapters) 38 | LibraryType.PODCAST -> context.getString(R.string.downloads_menu_download_option_remaining_episodes) 39 | LibraryType.UNKNOWN -> context.getString(R.string.downloads_menu_download_option_remaining_items) 40 | } 41 | } 42 | 43 | is NumberItemDownloadOption -> { 44 | when (libraryType) { 45 | LibraryType.LIBRARY -> 46 | context.getString( 47 | R.string.downloads_menu_download_option_next_chapters, 48 | itemsNumber, 49 | ) 50 | 51 | LibraryType.PODCAST -> 52 | context.getString( 53 | R.string.downloads_menu_download_option_next_episodes, 54 | itemsNumber, 55 | ) 56 | 57 | LibraryType.UNKNOWN -> 58 | context.getString( 59 | R.string.downloads_menu_download_option_next_items, 60 | itemsNumber, 61 | ) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/playback/MediaModule.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback 2 | 3 | import android.content.Context 4 | import androidx.annotation.OptIn 5 | import androidx.media3.common.AudioAttributes 6 | import androidx.media3.common.C 7 | import androidx.media3.common.util.UnstableApi 8 | import androidx.media3.database.StandaloneDatabaseProvider 9 | import androidx.media3.datasource.cache.Cache 10 | import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor 11 | import androidx.media3.datasource.cache.SimpleCache 12 | import androidx.media3.exoplayer.ExoPlayer 13 | import dagger.Module 14 | import dagger.Provides 15 | import dagger.hilt.InstallIn 16 | import dagger.hilt.android.qualifiers.ApplicationContext 17 | import dagger.hilt.components.SingletonComponent 18 | import java.io.File 19 | import javax.inject.Singleton 20 | 21 | @Module 22 | @InstallIn(SingletonComponent::class) 23 | object MediaModule { 24 | @OptIn(UnstableApi::class) 25 | @Provides 26 | @Singleton 27 | fun provideMediaCache( 28 | @ApplicationContext context: Context, 29 | ): Cache { 30 | val baseFolder = 31 | context 32 | .externalCacheDir 33 | ?.takeIf { it.exists() && it.canWrite() } 34 | ?: context.cacheDir 35 | 36 | return SimpleCache( 37 | File(baseFolder, "playback_cache"), 38 | LeastRecentlyUsedCacheEvictor(buildPlaybackCacheLimit(context)), 39 | StandaloneDatabaseProvider(context), 40 | ) 41 | } 42 | 43 | @OptIn(UnstableApi::class) 44 | @Provides 45 | @Singleton 46 | fun provideExoPlayer( 47 | @ApplicationContext context: Context, 48 | ): ExoPlayer { 49 | val player = 50 | ExoPlayer 51 | .Builder(context) 52 | .setHandleAudioBecomingNoisy(true) 53 | .setAudioAttributes( 54 | AudioAttributes 55 | .Builder() 56 | .setUsage(C.USAGE_MEDIA) 57 | .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) 58 | .build(), 59 | true, 60 | ).build() 61 | 62 | return player 63 | } 64 | 65 | private fun buildPlaybackCacheLimit(ctx: Context): Long { 66 | val baseFolder = 67 | ctx 68 | .externalCacheDir 69 | ?.takeIf { it.exists() && it.canWrite() } 70 | ?: ctx.cacheDir 71 | 72 | val stat = android.os.StatFs(baseFolder.path) 73 | val available = stat.availableBytes 74 | val dynamicCap = (available - KEEP_FREE_BYTES).coerceAtLeast(MIN_CACHE_BYTES) 75 | 76 | return minOf(MAX_CACHE_BYTES, dynamicCap) 77 | } 78 | 79 | private const val MAX_CACHE_BYTES = 512L * 1024 * 1024 80 | private const val KEEP_FREE_BYTES = 20L * 1024 * 1024 81 | private const val MIN_CACHE_BYTES = 10L * 1024 * 1024 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/org/grakovne/lissen/content/cache/temporary/CachedCoverProvider.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.content.cache.temporary 2 | 3 | import android.content.Context 4 | import dagger.hilt.android.qualifiers.ApplicationContext 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import org.grakovne.lissen.channel.common.MediaChannel 8 | import org.grakovne.lissen.channel.common.OperationError 9 | import org.grakovne.lissen.channel.common.OperationResult 10 | import org.grakovne.lissen.content.cache.common.withBlur 11 | import org.grakovne.lissen.content.cache.common.writeToFile 12 | import timber.log.Timber 13 | import java.io.File 14 | import javax.inject.Inject 15 | import javax.inject.Singleton 16 | 17 | @Singleton 18 | class CachedCoverProvider 19 | @Inject 20 | constructor( 21 | @ApplicationContext private val context: Context, 22 | private val properties: ShortTermCacheStorageProperties, 23 | ) { 24 | suspend fun provideCover( 25 | channel: MediaChannel, 26 | itemId: String, 27 | width: Int?, 28 | ): OperationResult = 29 | when (val cover = fetchCachedCover(itemId, width)) { 30 | null -> cacheCover(channel, itemId, width).also { Timber.d("Caching cover $itemId with width: $width") } 31 | else -> cover.let { OperationResult.Success(it) }.also { Timber.d("Fetched cached $itemId with width: $width") } 32 | } 33 | 34 | fun clearCache() = 35 | properties 36 | .provideCoverCacheFolder() 37 | .deleteRecursively() 38 | .also { Timber.d("Clear cover short-term cache") } 39 | 40 | private fun fetchCachedCover( 41 | itemId: String, 42 | width: Int?, 43 | ): File? { 44 | val file = properties.provideCoverPath(itemId, width) 45 | 46 | return when (file.exists()) { 47 | true -> file 48 | else -> null 49 | } 50 | } 51 | 52 | private suspend fun cacheCover( 53 | channel: MediaChannel, 54 | itemId: String, 55 | width: Int?, 56 | ): OperationResult { 57 | val dest = properties.provideCoverPath(itemId, width) 58 | 59 | return withContext(Dispatchers.IO) { 60 | channel 61 | .fetchBookCover(itemId) 62 | .fold( 63 | onSuccess = { source -> 64 | source.withBlur(context) 65 | 66 | val blurred = source.withBlur(context) 67 | dest.parentFile?.mkdirs() 68 | 69 | blurred.writeToFile(dest) 70 | OperationResult.Success(dest) 71 | }, 72 | onFailure = { return@fold OperationResult.Error(OperationError.InternalError, it.message) }, 73 | ) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/kotlin/org/grakovne/lissen/widget/WidgetPlaybackController.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("DEPRECATION") 2 | 3 | package org.grakovne.lissen.widget 4 | 5 | import android.content.BroadcastReceiver 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.content.IntentFilter 9 | import androidx.annotation.OptIn 10 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 11 | import androidx.media3.common.util.UnstableApi 12 | import dagger.hilt.android.qualifiers.ApplicationContext 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.launch 16 | import org.grakovne.lissen.lib.domain.DetailedItem 17 | import org.grakovne.lissen.playback.MediaRepository 18 | import org.grakovne.lissen.playback.service.PlaybackService.Companion.BOOK_EXTRA 19 | import org.grakovne.lissen.playback.service.PlaybackService.Companion.PLAYBACK_READY 20 | import javax.inject.Inject 21 | import javax.inject.Singleton 22 | 23 | @Singleton 24 | @OptIn(UnstableApi::class) 25 | class WidgetPlaybackController 26 | @Inject 27 | constructor( 28 | @ApplicationContext context: Context, 29 | private val mediaRepository: MediaRepository, 30 | ) { 31 | private var playbackReadyAction: () -> Unit = {} 32 | 33 | private val bookDetailsReadyReceiver = 34 | object : BroadcastReceiver() { 35 | @Suppress("DEPRECATION") 36 | override fun onReceive( 37 | context: Context?, 38 | intent: Intent?, 39 | ) { 40 | if (intent?.action == PLAYBACK_READY) { 41 | val book = intent.getSerializableExtra(BOOK_EXTRA) as? DetailedItem 42 | 43 | book?.let { 44 | CoroutineScope(Dispatchers.Main).launch { 45 | playbackReadyAction 46 | .invoke() 47 | .also { playbackReadyAction = { } } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | init { 55 | LocalBroadcastManager 56 | .getInstance(context) 57 | .registerReceiver(bookDetailsReadyReceiver, IntentFilter(PLAYBACK_READY)) 58 | } 59 | 60 | fun providePlayingItem() = mediaRepository.playingBook.value 61 | 62 | fun togglePlayPause() = mediaRepository.togglePlayPause() 63 | 64 | fun nextTrack() = mediaRepository.nextTrack() 65 | 66 | fun previousTrack() = mediaRepository.previousTrack(false) 67 | 68 | fun rewind() = mediaRepository.rewind() 69 | 70 | fun forward() = mediaRepository.forward() 71 | 72 | suspend fun prepareAndRun( 73 | itemId: String, 74 | onPlaybackReady: () -> Unit, 75 | ) { 76 | playbackReadyAction = onPlaybackReady 77 | mediaRepository.preparePlayback(bookId = itemId) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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 dagger.hilt.android.qualifiers.ApplicationContext 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.SupervisorJob 12 | import kotlinx.coroutines.launch 13 | import org.grakovne.lissen.R 14 | import org.grakovne.lissen.common.RunningComponent 15 | import org.grakovne.lissen.lib.domain.DetailedItem 16 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 17 | import org.grakovne.lissen.ui.activity.AppActivity 18 | import org.grakovne.lissen.ui.navigation.CONTINUE_PLAYBACK 19 | import timber.log.Timber 20 | import javax.inject.Inject 21 | import javax.inject.Singleton 22 | 23 | @Singleton 24 | class ContinuePlaybackShortcut 25 | @Inject 26 | constructor( 27 | @ApplicationContext private val context: Context, 28 | private val sharedPreferences: LissenSharedPreferences, 29 | ) : RunningComponent { 30 | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) 31 | 32 | override fun onCreate() { 33 | Timber.d("ContinuePlaybackShortcut registered") 34 | 35 | scope.launch { 36 | sharedPreferences 37 | .playingBookFlow 38 | .collect { updateShortcut(it) } 39 | } 40 | } 41 | 42 | private fun updateShortcut(playingBook: DetailedItem?) { 43 | Timber.d("ContinuePlaybackShortcut is updating") 44 | 45 | val shortcutManager = context.getSystemService(ShortcutManager::class.java) 46 | 47 | if (playingBook == null) { 48 | shortcutManager.removeDynamicShortcuts(listOf(SHORTCUT_TAG)) 49 | return 50 | } 51 | 52 | val shortcut = 53 | ShortcutInfo 54 | .Builder(context, SHORTCUT_TAG) 55 | .setShortLabel(context.getString(R.string.continue_playback_shortcut_title)) 56 | .setLongLabel(context.getString(R.string.continue_playback_shortcut_description)) 57 | .setIcon(Icon.createWithResource(context, R.drawable.ic_play)) 58 | .setIntent( 59 | Intent(context, AppActivity::class.java).apply { 60 | action = CONTINUE_PLAYBACK 61 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) 62 | }, 63 | ).build() 64 | 65 | shortcutManager.dynamicShortcuts = listOf(shortcut) 66 | shortcutManager.reportShortcutUsed(shortcut.id) 67 | } 68 | 69 | companion object { 70 | private const val SHORTCUT_TAG = "continue_playback_shortcut" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/org/grakovne/lissen/playback/service/PlaybackTimer.kt: -------------------------------------------------------------------------------- 1 | package org.grakovne.lissen.playback.service 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import androidx.annotation.OptIn 6 | import androidx.localbroadcastmanager.content.LocalBroadcastManager 7 | import androidx.media3.common.Player 8 | import androidx.media3.common.util.UnstableApi 9 | import androidx.media3.exoplayer.ExoPlayer 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import org.grakovne.lissen.lib.domain.CurrentEpisodeTimerOption 12 | import org.grakovne.lissen.lib.domain.TimerOption 13 | import javax.inject.Inject 14 | import javax.inject.Singleton 15 | 16 | @Singleton 17 | class PlaybackTimer 18 | @Inject 19 | constructor( 20 | @ApplicationContext private val applicationContext: Context, 21 | private val exoPlayer: ExoPlayer, 22 | ) { 23 | private val localBroadcastManager = LocalBroadcastManager.getInstance(applicationContext) 24 | 25 | private var option: TimerOption? = null 26 | private var timer: SuspendableCountDownTimer? = null 27 | 28 | private val playerListener = 29 | object : Player.Listener { 30 | override fun onIsPlayingChanged(isPlaying: Boolean) { 31 | val currentTimer = timer ?: return 32 | 33 | if (option == CurrentEpisodeTimerOption) { 34 | when (isPlaying) { 35 | true -> timer = currentTimer.resume() 36 | false -> currentTimer.pause() 37 | } 38 | } 39 | } 40 | } 41 | 42 | @OptIn(UnstableApi::class) 43 | fun startTimer( 44 | delayInSeconds: Double, 45 | option: TimerOption, 46 | ) { 47 | stopTimer() 48 | 49 | val totalMillis = (delayInSeconds * 1000).toLong() 50 | if (totalMillis <= 0L) return 51 | 52 | broadcastRemaining(delayInSeconds.toLong()) 53 | 54 | timer = 55 | SuspendableCountDownTimer( 56 | totalMillis = totalMillis, 57 | intervalMillis = 500L, 58 | onTickSeconds = { seconds -> broadcastRemaining(seconds) }, 59 | onFinished = { 60 | localBroadcastManager.sendBroadcast(Intent(PlaybackService.TIMER_EXPIRED)) 61 | stopTimer() 62 | }, 63 | ).also { it.start() } 64 | 65 | exoPlayer.removeListener(playerListener) 66 | exoPlayer.addListener(playerListener) 67 | 68 | this.option = option 69 | if (exoPlayer.isPlaying.not() && option == CurrentEpisodeTimerOption) { 70 | timer?.pause() 71 | } 72 | } 73 | 74 | @OptIn(UnstableApi::class) 75 | private fun broadcastRemaining(seconds: Long) { 76 | localBroadcastManager.sendBroadcast( 77 | Intent(PlaybackService.TIMER_TICK) 78 | .putExtra(PlaybackService.TIMER_REMAINING, seconds), 79 | ) 80 | } 81 | 82 | fun stopTimer() { 83 | timer?.cancel() 84 | timer = null 85 | 86 | exoPlayer.removeListener(playerListener) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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.widget.Toast 6 | import android.widget.Toast.LENGTH_SHORT 7 | import androidx.activity.ComponentActivity 8 | import androidx.lifecycle.lifecycleScope 9 | import dagger.hilt.android.AndroidEntryPoint 10 | import kotlinx.coroutines.launch 11 | import org.grakovne.lissen.channel.audiobookshelf.AudiobookshelfHostProvider 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.lib.domain.UserAccount 17 | import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences 18 | import org.grakovne.lissen.ui.activity.AppActivity 19 | import timber.log.Timber 20 | import javax.inject.Inject 21 | 22 | @AndroidEntryPoint 23 | class AudiobookshelfOAuthCallbackActivity : ComponentActivity() { 24 | @Inject 25 | lateinit var contextCache: OAuthContextCache 26 | 27 | @Inject 28 | lateinit var authService: AudiobookshelfAuthService 29 | 30 | @Inject 31 | lateinit var mediaProvider: LissenMediaProvider 32 | 33 | @Inject 34 | lateinit var preferences: LissenSharedPreferences 35 | 36 | @Inject 37 | lateinit var hostProvider: AudiobookshelfHostProvider 38 | 39 | override fun onCreate(savedInstanceState: Bundle?) { 40 | super.onCreate(savedInstanceState) 41 | val data = intent?.data 42 | 43 | if (null == data) { 44 | finish() 45 | return 46 | } 47 | 48 | if (intent?.action == Intent.ACTION_VIEW && data.scheme == AuthScheme) { 49 | val code = data.getQueryParameter("code") ?: "" 50 | Timber.d("Got Exchange code from ABS") 51 | 52 | lifecycleScope.launch { 53 | authService.exchangeToken( 54 | host = 55 | hostProvider.provideHost()?.url ?: kotlin.run { 56 | onLoginFailed("invalid_host") 57 | return@launch 58 | }, 59 | code = code, 60 | onSuccess = { onLogged(it) }, 61 | onFailure = { onLoginFailed(it) }, 62 | ) 63 | } 64 | } 65 | } 66 | 67 | private suspend fun onLogged(userAccount: UserAccount) { 68 | mediaProvider.onPostLogin( 69 | host = preferences.getHost() ?: return, 70 | account = userAccount, 71 | ) 72 | 73 | val intent = 74 | Intent(this, AppActivity::class.java).apply { 75 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or 76 | Intent.FLAG_ACTIVITY_CLEAR_TASK 77 | } 78 | 79 | startActivity(intent) 80 | finish() 81 | } 82 | 83 | private fun onLoginFailed(reason: String) { 84 | runOnUiThread { 85 | authService 86 | .examineError(reason) 87 | .makeText(this) 88 | .let { Toast.makeText(this, it, LENGTH_SHORT).show() } 89 | 90 | finish() 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/src/main/kotlin/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/kotlin/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.lib.domain.connection.ServerRequestHeader 25 | import org.grakovne.lissen.lib.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 | --------------------------------------------------------------------------------