├── 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 |
4 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/common/LibraryOrderingDirection.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.common
2 |
3 | enum class LibraryOrderingDirection {
4 | ASCENDING,
5 | DESCENDING,
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/common/NetworkTypeAutoCache.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.common
2 |
3 | enum class NetworkTypeAutoCache {
4 | WIFI_ONLY,
5 | WIFI_OR_CELLULAR,
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/common/ColorScheme.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.common
2 |
3 | enum class ColorScheme {
4 | FOLLOW_SYSTEM,
5 |
6 | LIGHT,
7 | DARK,
8 | BLACK,
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/Action.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.ui.navigation
2 |
3 | const val SHOW_DOWNLOADS = "show_downloads"
4 | const val CONTINUE_PLAYBACK = "continue_playback"
5 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/common/LibraryOrderingOption.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.common
2 |
3 | enum class LibraryOrderingOption {
4 | TITLE,
5 | AUTHOR,
6 | UPDATED_AT,
7 | CREATED_AT,
8 | }
9 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/ui/navigation/AppLaunchAction.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.ui.navigation
2 |
3 | enum class AppLaunchAction {
4 | CONTINUE_PLAYBACK,
5 | MANAGE_DOWNLOADS,
6 | DEFAULT,
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/common/PlaybackVolumeBoost.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.common
2 |
3 | enum class PlaybackVolumeBoost {
4 | DISABLED,
5 | LOW,
6 | MEDIUM,
7 | HIGH,
8 | MAX,
9 | }
10 |
--------------------------------------------------------------------------------
/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/SeekTimeOption.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.lib.domain
2 |
3 | enum class SeekTimeOption {
4 | SEEK_5,
5 | SEEK_10,
6 | SEEK_15,
7 | SEEK_30,
8 | SEEK_60,
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/widget_placeholder.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
--------------------------------------------------------------------------------
/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/FixScheme.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.lib.domain
2 |
3 | fun String.fixUriScheme() = when (this.startsWith("http://") || this.startsWith("https://")) {
4 | true -> this
5 | false -> "http://$this"
6 | }
--------------------------------------------------------------------------------
/metadata/en-US/full_description.txt:
--------------------------------------------------------------------------------
1 | Listen to your favorite audiobooks through a minimalist app designed for one task
2 |
3 | Sync your listened books between devices and other Audiobookshelf players and download books to listen to them without the Internet
4 |
--------------------------------------------------------------------------------
/app/src/main/kotlin/org/grakovne/lissen/channel/audiobookshelf/common/oauth/OAuthScheme.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.channel.audiobookshelf.common.oauth
2 |
3 | const val AuthScheme = "lissen"
4 | const val AuthHost = "oauth"
5 | const val AuthClient = "lissen_app"
6 |
--------------------------------------------------------------------------------
/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/PlaybackSession.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.lib.domain
2 |
3 | import androidx.annotation.Keep
4 |
5 | @Keep
6 | data class PlaybackSession(
7 | val sessionId: String,
8 | val itemId: String,
9 | )
10 |
--------------------------------------------------------------------------------
/lib/src/main/kotlin/org/grakovne/lissen/lib/domain/Library.kt:
--------------------------------------------------------------------------------
1 | package org.grakovne.lissen.lib.domain
2 |
3 | import androidx.annotation.Keep
4 |
5 | @Keep
6 | data class Library(
7 | val id: String,
8 | val title: String,
9 | val type: LibraryType,
10 | )
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values-v31/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 |
4 |
5 |
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 |
--------------------------------------------------------------------------------