From d767700f6e48fa1b479fe42df66d56682d47536a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 00:22:37 +0000 Subject: [PATCH] feat: redesign Loudly with Material 3 Expressive and PixelPlayer style - Replaces AirNote codebase with Loudly. - Implements M3 Expressive theme with bold typography and rounded shapes. - Redesigns Navigation Bar to match PixelPlayer (floating pill + search bubble). - Redesigns PlayerUI20, removing Smart Companion chips and updating visuals. - Redesigns Home, Library, Search, and Album screens. - Updates build configuration and dependencies. --- app/build.gradle.kts | 115 +- app/src/debug/res/values/app_name.xml | 4 + app/src/debug/res/xml-v25/shortcuts.xml | 43 + .../babelsoftware/loudly/TranslationHelper.kt | 8 + .../java/com/babelsoftware/loudly/Utils.kt | 5 + .../babelsoftware/loudly/TranslationHelper.kt | 78 + .../java/com/babelsoftware/loudly/Utils.kt | 9 + app/src/main/AndroidManifest.xml | 215 +- app/src/main/ic_launcher-playstore.png | Bin 48904 -> 10677 bytes .../java/com/babelsoftware/airnote/App.kt | 7 - .../airnote/constant/Connection.kt | 24 - .../airnote/constant/Database.kt | 28 - .../babelsoftware/airnote/constant/Support.kt | 19 - .../data/local/converter/Converters.kt | 27 - .../airnote/data/local/dao/AiChatDao.kt | 47 - .../airnote/data/local/dao/NotesDao.kt | 38 - .../data/local/database/NoteDatabase.kt | 32 - .../local/database/NoteDatabaseProvider.kt | 126 - .../airnote/data/provider/StringProvider.kt | 21 - .../airnote/data/repository/AiModels.kt | 26 - .../data/repository/FolderRepositoryImpl.kt | 20 - .../data/repository/GeminiRepository.kt | 845 --- .../data/repository/ImportExportRepository.kt | 175 - .../airnote/data/repository/NoteRepository.kt | 39 - .../repository/SecureStorageRepository.kt | 56 - .../data/repository/SettingsRepository.kt | 201 - .../airnote/data/source/FolderDao.kt | 24 - .../babelsoftware/airnote/di/DataModule.kt | 138 - .../babelsoftware/airnote/di/NetworkModule.kt | 52 - .../airnote/domain/model/AiChatMessage.kt | 27 - .../airnote/domain/model/AiChatSession.kt | 14 - .../airnote/domain/model/AiResponse.kt | 22 - .../airnote/domain/model/Chat.kt | 21 - .../airnote/domain/model/Folder.kt | 17 - .../airnote/domain/model/Note.kt | 32 - .../airnote/domain/model/Settings.kt | 45 - .../domain/repository/FolderRepository.kt | 15 - .../domain/repository/NoteRepository.kt | 14 - .../domain/repository/SettingsRepository.kt | 20 - .../airnote/domain/usecase/AiChatUseCase.kt | 63 - .../airnote/domain/usecase/FolderUseCase.kt | 37 - .../domain/usecase/ImportExportUseCase.kt | 45 - .../airnote/domain/usecase/NoteUseCase.kt | 126 - .../airnote/domain/usecase/SettingsUseCase.kt | 66 - .../airnote/presentation/MainActivity.kt | 87 - .../presentation/components/Animations.kt | 60 - .../presentation/components/AutoFill.kt | 118 - .../components/CustomActionButton.kt | 43 - .../presentation/components/CustomActions.kt | 158 - .../presentation/components/CustomScaffold.kt | 36 - .../components/EncryptionHelper.kt | 71 - .../presentation/components/ImageCleaner.kt | 52 - .../presentation/components/NoteItem.kt | 100 - .../presentation/components/UpdateScreen.kt | 80 - .../components/markdown/Builder.kt | 120 - .../components/markdown/InlineElements.kt | 40 - .../components/markdown/LineProccesor.kt | 159 - .../components/markdown/MarkdownText.kt | 521 -- .../components/markdown/RowElements.kt | 74 - .../components/markdown/WidgetText.kt | 307 - .../navigation/AnimatedComposable.kt | 45 - .../presentation/navigation/NavHost.kt | 138 - .../presentation/navigation/NavRoutes.kt | 87 - .../presentation/screens/edit/EditScreen.kt | 1481 ----- .../edit/components/CustomIconButton.kt | 36 - .../edit/components/CustomTextField.kt | 144 - .../edit/components/TextFormattingToolbar.kt | 190 - .../screens/edit/model/EditModel.kt | 970 ---- .../presentation/screens/home/AiInterface.kt | 1917 ------- .../presentation/screens/home/HomeScreen.kt | 1362 ----- .../screens/home/desktop/DesktopAiScreen.kt | 1503 ----- .../screens/home/desktop/DesktopHomeScreen.kt | 823 --- .../screens/home/viewmodel/HomeModel.kt | 1193 ---- .../screens/home/widgets/AddFolderDialog.kt | 95 - .../home/widgets/FolderActionBottomSheet.kt | 48 - .../home/widgets/MoveToFolderDialog.kt | 63 - .../screens/home/widgets/NoteCard.kt | 152 - .../screens/home/widgets/NoteFilter.kt | 86 - .../screens/home/widgets/NoteGrid.kt | 157 - .../screens/home/widgets/Placeholder.kt | 45 - .../screens/settings/SettingsScreen.kt | 271 - .../screens/settings/model/IconResource.kt | 12 - .../screens/settings/model/SettingsModel.kt | 412 -- .../screens/settings/settings/About.kt | 512 -- .../screens/settings/settings/AirNoteAi.kt | 918 --- .../screens/settings/settings/Backup.kt | 276 - .../screens/settings/settings/Behaviour.kt | 173 - .../screens/settings/settings/Colors.kt | 472 -- .../screens/settings/settings/Crypto.kt | 53 - .../settings/DesktopModeSettingsScreen.kt | 70 - .../screens/settings/settings/Language.kt | 117 - .../screens/settings/settings/Privacy.kt | 300 - .../screens/settings/settings/Tools.kt | 41 - .../settings/settings/lock/LockScreen.kt | 50 - .../settings/lock/components/Fingerprint.kt | 128 - .../settings/lock/components/Passcode.kt | 277 - .../settings/lock/components/Pattern.kt | 226 - .../settings/lock/components/TitleText.kt | 37 - .../lock/viewModel/LockScreenViewModel.kt | 105 - .../screens/settings/widgets/CircleWrapper.kt | 30 - .../screens/settings/widgets/MaterialText.kt | 60 - .../settings/widgets/SettingCategory.kt | 144 - .../screens/settings/widgets/SettingsBox.kt | 288 - .../settings/widgets/SettingsDialog.kt | 77 - .../screens/settings/widgets/SettingsList.kt | 121 - .../presentation/screens/terms/TermsScreen.kt | 119 - .../airnote/presentation/theme/Color.kt | 88 - .../airnote/presentation/theme/FontUtils.kt | 47 - .../airnote/presentation/theme/Schemes.kt | 80 - .../airnote/presentation/theme/Theme.kt | 67 - .../airnote/presentation/theme/Type.kt | 81 - .../babelsoftware/airnote/util/UpdateUtils.kt | 159 - .../airnote/widget/AddNoteReceiver.kt | 8 - .../airnote/widget/AddNoteWidget.kt | 55 - .../airnote/widget/NotesWidget.kt | 67 - .../airnote/widget/NotesWidgetActivity.kt | 95 - .../airnote/widget/NotesWidgetReceiver.kt | 31 - .../babelsoftware/airnote/widget/ui/Note.kt | 75 - .../airnote/widget/ui/ZeroState.kt | 36 - .../main/java/com/babelsoftware/loudly/App.kt | 151 + .../com/babelsoftware/loudly/MainActivity.kt | 1232 ++++ .../com/babelsoftware/loudly/MusicWidget.kt | 169 + .../background/ActivityTransitionReceiver.kt | 30 + .../loudly/background/AwarenessSystem.kt | 60 + .../loudly/background/ContextualWorker.kt | 63 + .../loudly/background/HeadsetPlugReceiver.kt | 53 + .../loudly/constants/Dimensions.kt | 35 + .../loudly/constants/HistorySource.kt | 5 + .../loudly/constants/MediaSessionConstants.kt | 17 + .../loudly/constants/PreferenceKeys.kt | 504 ++ .../loudly/constants/StatPeriod.kt | 18 + .../com/babelsoftware/loudly/db/Converters.kt | 17 + .../babelsoftware/loudly/db/DatabaseDao.kt | 964 ++++ .../babelsoftware/loudly/db/MusicDatabase.kt | 444 ++ .../babelsoftware/loudly/db/entities/Album.kt | 30 + .../loudly/db/entities/AlbumArtistMap.kt | 28 + .../loudly/db/entities/AlbumEntity.kt | 37 + .../loudly/db/entities/AlbumWithSongs.kt | 34 + .../loudly/db/entities/Artist.kt | 18 + .../loudly/db/entities/ArtistEntity.kt | 45 + .../babelsoftware/loudly/db/entities/Event.kt | 27 + .../loudly/db/entities/EventWithSong.kt | 17 + .../loudly/db/entities/FormatEntity.kt | 17 + .../loudly/db/entities/LocalItem.kt | 7 + .../loudly/db/entities/LyricsEntity.kt | 14 + .../loudly/db/entities/Playlist.kt | 38 + .../loudly/db/entities/PlaylistEntity.kt | 51 + .../loudly/db/entities/PlaylistSong.kt | 14 + .../loudly/db/entities/PlaylistSongMap.kt | 30 + .../db/entities/PlaylistSongMapPreview.kt | 13 + .../db/entities/RecentActivityEntity.kt | 26 + .../loudly/db/entities/RelatedSongMap.kt | 29 + .../loudly/db/entities/SearchHistory.kt | 17 + .../loudly/db/entities/SetVideoIdEntity.kt | 10 + .../babelsoftware/loudly/db/entities/Song.kt | 52 + .../loudly/db/entities/SongAlbumMap.kt | 28 + .../loudly/db/entities/SongArtistMap.kt | 28 + .../loudly/db/entities/SongEntity.kt | 61 + .../loudly/db/entities/SortedSongAlbumMap.kt | 13 + .../loudly/db/entities/SortedSongArtistMap.kt | 13 + .../com/babelsoftware/loudly/di/AppModule.kt | 98 + .../loudly/extensions/CoroutineExt.kt | 30 + .../loudly/extensions/FileExt.kt | 12 + .../loudly/extensions/ListExt.kt | 8 + .../loudly/extensions/MediaItemExt.kt | 63 + .../loudly/extensions/NavControllerExt.kt | 21 + .../loudly/extensions/PlayerExt.kt | 126 + .../loudly/extensions/StringExt.kt | 20 + .../loudly/extensions/UtilExt.kt | 8 + .../loudly/lyrics/KuGouLyricsProvider.kt | 20 + .../loudly/lyrics/LocalLyricsProvider.kt | 37 + .../loudly/lyrics/LrcLibLyricsProvider.kt | 31 + .../loudly/lyrics/LyricsEntry.kt | 14 + .../loudly/lyrics/LyricsHelper.kt | 145 + .../loudly/lyrics/LyricsProvider.kt | 12 + .../loudly/lyrics/LyricsUtils.kt | 124 + .../loudly/lyrics/LyricsViewModel.kt | 113 + .../loudly/lyrics/MlKitTranslationService.kt | 94 + .../loudly/lyrics/YouTubeLyricsProvider.kt | 16 + .../lyrics/YouTubeSubtitleLyricsProvider.kt | 11 + .../babelsoftware/loudly/models/ImageCache.kt | 56 + .../babelsoftware/loudly/models/ItemsPage.kt | 8 + .../loudly/models/MediaMetadata.kt | 92 + .../loudly/models/PersistQueue.kt | 10 + .../loudly/models/SimilarRecommendation.kt | 9 + .../loudly/models/SongWithPlayCount.kt | 10 + .../loudly/models/SpotifyAuthResponse.kt | 16 + .../loudly/models/SpotifyUserProfile.kt | 10 + .../loudly/models/spotify/Tracks.kt | 10 + .../loudly/models/spotify/playlists/Images.kt | 8 + .../spotify/playlists/SpotifyPlaylistItem.kt | 19 + .../SpotifyPlaylistPaginatedResponse.kt | 15 + .../loudly/models/spotify/tracks/Artists.kt | 8 + .../tracks/SpotifyResultPaginatedResponse.kt | 14 + .../loudly/models/spotify/tracks/Track.kt | 10 + .../loudly/models/spotify/tracks/TrackItem.kt | 16 + .../loudly/playback/DownloadUtil.kt | 186 + .../loudly/playback/ExoDownloadService.kt | 100 + .../loudly/playback/KeepAlive.kt | 70 + .../playback/MediaLibrarySessionCallback.kt | 326 ++ .../loudly/playback/MusicService.kt | 1039 ++++ .../loudly/playback/PlayerConnection.kt | 339 ++ .../loudly/playback/SleepTimer.kt | 62 + .../loudly/playback/queues/EmptyQueue.kt | 11 + .../loudly/playback/queues/ListQueue.kt | 16 + .../loudly/playback/queues/LocalAlbumRadio.kt | 50 + .../loudly/playback/queues/Queue.kt | 33 + .../playback/queues/YouTubeAlbumRadio.kt | 41 + .../loudly/playback/queues/YouTubeQueue.kt | 44 + .../loudly/ui/component/ActionChipButton.kt | 66 + .../loudly/ui/component/AnimatedSoundWave.kt | 96 + .../loudly/ui/component/AsyncImageLocal.kt | 68 + .../loudly/ui/component/AutoResizeText.kt | 93 + .../loudly/ui/component/BigSeekBar.kt | 54 + .../loudly/ui/component/BottomSheet.kt | 344 ++ .../loudly/ui/component/BottomSheetMenu.kt | 111 + .../loudly/ui/component/ChipStyleButton.kt | 31 + .../loudly/ui/component/ChipsRow.kt | 51 + .../loudly/ui/component/Dialog.kt | 364 ++ .../loudly/ui/component/EmptyPlaceholder.kt | 46 + .../loudly/ui/component/ErrorDetailDialog.kt | 65 + .../ui/component/FeaturedContentCard.kt | 87 + .../loudly/ui/component/HideOnScrollFAB.kt | 113 + .../loudly/ui/component/IconButton.kt | 93 + .../loudly/ui/component/Items.kt | 1612 ++++++ .../loudly/ui/component/ListMenu.kt | 153 + .../loudly/ui/component/LoudlyBottomNavBar.kt | 196 + .../loudly/ui/component/Lyrics.kt | 395 ++ .../loudly/ui/component/NavigationTile.kt | 56 + .../loudly/ui/component/NavigationTitle.kt | 77 + .../loudly/ui/component/PlayerSlider.kt | 117 + .../loudly/ui/component/PlayingIndicator.kt | 112 + .../loudly/ui/component/Preference.kt | 257 + .../loudly/ui/component/SearchBar.kt | 348 ++ .../loudly/ui/component/SortHeader.kt | 99 + .../ui/component/shimmer/ButtonPlaceholder.kt | 21 + .../component/shimmer/GridItemPlaceholder.kt | 49 + .../component/shimmer/ListItemPlaceholder.kt | 45 + .../ui/component/shimmer/ShimmerHost.kt | 59 + .../ui/component/shimmer/TextPlaceholder.kt | 28 + .../loudly/ui/menu/AddToPlaylistDialog.kt | 264 + .../babelsoftware/loudly/ui/menu/AlbumMenu.kt | 451 ++ .../loudly/ui/menu/ArtistMenu.kt | 223 + .../loudly/ui/menu/FolderMenu.kt | 97 + .../loudly/ui/menu/LyricsMenu.kt | 374 ++ .../loudly/ui/menu/MediaMetadataMenu.kt | 436 ++ .../loudly/ui/menu/PlayerMenu.kt | 831 +++ .../loudly/ui/menu/PlaylistMenu.kt | 453 ++ .../loudly/ui/menu/QueueSelectionMenu.kt | 190 + .../babelsoftware/loudly/ui/menu/SongMenu.kt | 596 ++ .../loudly/ui/menu/SongSelectionMenu.kt | 263 + .../loudly/ui/menu/YouTubeAlbumMenu.kt | 381 ++ .../loudly/ui/menu/YouTubeArtistMenu.kt | 213 + .../loudly/ui/menu/YouTubePlaylistMenu.kt | 373 ++ .../loudly/ui/menu/YouTubeSongMenu.kt | 408 ++ .../ui/menu/YouTubeSongSelectionMenu.kt | 129 + .../loudly/ui/player/MiniPlayer.kt | 256 + .../loudly/ui/player/PlaybackError.kt | 106 + .../babelsoftware/loudly/ui/player/Player.kt | 882 +++ .../loudly/ui/player/PlayerUI20.kt | 1496 +++++ .../babelsoftware/loudly/ui/player/Queue.kt | 824 +++ .../loudly/ui/player/Thumbnail.kt | 410 ++ .../loudly/ui/screens/AlbumScreen.kt | 1090 ++++ .../loudly/ui/screens/ExploreScreen.kt | 221 + .../loudly/ui/screens/HistoryScreen.kt | 523 ++ .../loudly/ui/screens/HomeScreen.kt | 1129 ++++ .../loudly/ui/screens/LoginScreen.kt | 117 + .../loudly/ui/screens/MoodAndGenresScreen.kt | 142 + .../loudly/ui/screens/NavigationBuilder.kt | 297 + .../loudly/ui/screens/NewReleaseScreen.kt | 114 + .../loudly/ui/screens/Screens.kt | 68 + .../loudly/ui/screens/SetupWizard.kt | 638 +++ .../loudly/ui/screens/StatsScreen.kt | 430 ++ .../loudly/ui/screens/YouTubeBrowseScreen.kt | 302 + .../ui/screens/YouTubePlaylistsScreen.kt | 107 + .../ui/screens/artist/ArtistItemsScreen.kt | 389 ++ .../loudly/ui/screens/artist/ArtistScreen.kt | 553 ++ .../ui/screens/artist/ArtistSongsScreen.kt | 316 ++ .../loudly/ui/screens/guides/FeatureGuides.kt | 309 + .../ui/screens/library/LibraryAlbumsScreen.kt | 382 ++ .../screens/library/LibraryArtistsScreen.kt | 368 ++ .../ui/screens/library/LibraryMixScreen.kt | 830 +++ .../screens/library/LibraryPlaylistsScreen.kt | 809 +++ .../ui/screens/library/LibraryScreen.kt | 28 + .../ui/screens/library/LibrarySongsScreen.kt | 592 ++ .../playlist/AutoPlaylistLocalScreen.kt | 721 +++ .../ui/screens/playlist/AutoPlaylistScreen.kt | 777 +++ .../screens/playlist/CachePlaylistScreen.kt | 654 +++ .../screens/playlist/LocalPlaylistScreen.kt | 1077 ++++ .../screens/playlist/OnlinePlaylistScreen.kt | 846 +++ .../ui/screens/playlist/TopPlaylistScreen.kt | 718 +++ .../ui/screens/search/LocalSearchScreen.kt | 288 + .../ui/screens/search/OnlineSearchResult.kt | 291 + .../ui/screens/search/OnlineSearchScreen.kt | 404 ++ .../loudly/ui/screens/settings/AboutScreen.kt | 367 ++ .../ui/screens/settings/AccountScreen.kt | 192 + .../ui/screens/settings/AppearanceSettings.kt | 1831 ++++++ .../ui/screens/settings/BackupAndRestore.kt | 200 + .../ComradeNotificationSettingsScreen.kt | 217 + .../ui/screens/settings/ContentSettings.kt | 393 ++ .../ui/screens/settings/DiscordLoginScreen.kt | 113 + .../ui/screens/settings/DiscordSettings.kt | 459 ++ .../screens/settings/LocalPlayerSettings.kt | 208 + .../ui/screens/settings/LyricsSettings.kt | 307 + .../screens/settings/NotificationSettings.kt | 128 + .../ui/screens/settings/PlayerSettings.kt | 368 ++ .../ui/screens/settings/PrivacySettings.kt | 196 + .../ui/screens/settings/SettingsScreen.kt | 297 + .../ui/screens/settings/StorageSettings.kt | 440 ++ .../loudly/ui/screens/settings/TipsScreen.kt | 40 + .../settings/card_design/SettingsBox.kt | 151 + .../settings/card_design/SettingsGroup.kt | 37 + .../ImportFromSpotifyScreen.kt | 713 +++ .../model/ImportFromSpotifyScreenState.kt | 15 + .../model/ImportProgressEvent.kt | 14 + .../import_from_spotify/model/Playlist.kt | 6 + .../settings/viewmodel}/AppUpdateViewModel.kt | 42 +- .../settings/viewmodel/SettingsViewModel.kt | 29 + .../ui/screens/share/SocialShareScreen.kt | 420 ++ .../babelsoftware/loudly/ui/theme/Style.kt | 49 + .../babelsoftware/loudly/ui/theme/Theme.kt | 195 + .../babelsoftware/loudly/ui/utils/AppBar.kt | 67 + .../loudly/ui/utils/FadingEdge.kt | 81 + .../utils/LazyGridSnapLayoutInfoProvider.kt | 69 + .../loudly/ui/utils/LocalMediaUtils.kt | 341 ++ .../loudly/ui/utils/NavControllerUtils.kt | 11 + .../loudly/ui/utils/ScrollUtils.kt | 59 + .../loudly/ui/utils/ShapeUtils.kt | 8 + .../loudly/ui/utils/StringUtils.kt | 48 + .../loudly/ui/utils/YouTubeUtils.kt | 20 + .../loudly/utils/CoilBitmapLoader.kt | 46 + .../babelsoftware/loudly/utils/DataStore.kt | 100 + .../babelsoftware/loudly/utils/DiscordRPC.kt | 35 + .../babelsoftware/loudly/utils/ItemWrapper.kt | 13 + .../loudly/utils/LmImageCacheMgr.kt | 35 + .../loudly/utils/LogReportHelper.kt | 67 + .../loudly/utils/NetworkUtils.kt | 19 + .../loudly/utils/NotificationHelper.kt | 109 + .../babelsoftware/loudly/utils/StringUtils.kt | 26 + .../babelsoftware/loudly/utils/SyncUtils.kt | 179 + .../babelsoftware/loudly/utils/UpdateUtils.kt | 68 + .../com/babelsoftware/loudly/utils/Updater.kt | 21 + .../loudly/utils/YTPlayerUtils.kt | 167 + .../loudly/viewmodels/AlbumViewModel.kt | 49 + .../loudly/viewmodels/ArtistItemsViewModel.kt | 71 + .../loudly/viewmodels/ArtistViewModel.kt | 47 + .../viewmodels/AutoPlaylistViewModel.kt | 84 + .../viewmodels/BackupRestoreViewModel.kt | 157 + .../viewmodels/CachePlaylistViewModel.kt | 67 + .../loudly/viewmodels/ExploreViewModel.kt | 62 + .../loudly/viewmodels/HistoryViewModel.kt | 77 + .../loudly/viewmodels/HomeViewModel.kt | 229 + .../viewmodels/ImportFromSpotifyViewModel.kt | 516 ++ .../loudly/viewmodels/LibraryViewModels.kt | 456 ++ .../viewmodels/LocalPlaylistViewModel.kt | 66 + .../loudly/viewmodels/LocalSearchViewModel.kt | 73 + .../loudly/viewmodels/LyricsMenuViewModel.kt | 56 + .../viewmodels/MoodAndGenresViewModel.kt | 26 + .../loudly/viewmodels/NewReleaseViewModel.kt | 55 + .../viewmodels/OnlinePlaylistViewModel.kt | 43 + .../OnlineSearchSuggestionViewModel.kt | 70 + .../viewmodels/OnlineSearchViewModel.kt | 90 + .../loudly/viewmodels/StatsViewModel.kt | 92 + .../loudly/viewmodels/TopPlaylistViewModel.kt | 36 + .../viewmodels/YouTubeBrowseViewModel.kt | 38 + .../viewmodels/YouTubePlaylistsViewModel.kt | 26 + app/src/main/res/animator/draw_path.xml | 8 - .../main/res/animator/splash_animation.xml | 8 - .../example_appwidget_preview.png | Bin 0 -> 3522 bytes .../res/drawable-nodpi/widget_preview_1.png | Bin 44780 -> 0 bytes .../res/drawable-nodpi/widget_preview_2.png | Bin 2120 -> 0 bytes app/src/main/res/drawable/account_circle.xml | 5 + app/src/main/res/drawable/add.xml | 9 + app/src/main/res/drawable/add_link.xml | 5 + app/src/main/res/drawable/album.xml | 9 + .../main/res/drawable/album_art_gradient.xml | 8 + .../res/drawable/app_widget_background.xml | 4 + .../app_widget_inner_view_background.xml | 9 + app/src/main/res/drawable/arrow_back.xml | 9 + app/src/main/res/drawable/arrow_downward.xml | 9 + app/src/main/res/drawable/arrow_forward.xml | 10 + app/src/main/res/drawable/arrow_top_left.xml | 9 + app/src/main/res/drawable/arrow_upward.xml | 9 + app/src/main/res/drawable/artist.xml | 9 + app/src/main/res/drawable/avd_splash_draw.xml | 24 - app/src/main/res/drawable/backup.xml | 9 + app/src/main/res/drawable/badge.xml | 5 + app/src/main/res/drawable/bedtime.xml | 9 + .../main/res/drawable/bluetooth_devices.xml | 5 + app/src/main/res/drawable/bookmark.xml | 9 + app/src/main/res/drawable/bookmark_filled.xml | 9 + app/src/main/res/drawable/boosty.xml | 9 + app/src/main/res/drawable/bug_report.xml | 9 + app/src/main/res/drawable/cached.xml | 9 + app/src/main/res/drawable/casa.webp | Bin 0 -> 532 bytes app/src/main/res/drawable/casino.xml | 9 + app/src/main/res/drawable/clear_all.xml | 9 + app/src/main/res/drawable/close.xml | 9 + app/src/main/res/drawable/codigo.xml | 9 + app/src/main/res/drawable/content_copy.xml | 5 + app/src/main/res/drawable/contrast.xml | 9 + app/src/main/res/drawable/dark_mode.xml | 9 + app/src/main/res/drawable/delete.xml | 9 + app/src/main/res/drawable/delete_history.xml | 9 + app/src/main/res/drawable/directions_run.xml | 5 + app/src/main/res/drawable/discord.xml | 9 + app/src/main/res/drawable/discover_tune.xml | 9 + app/src/main/res/drawable/donate.xml | 9 + app/src/main/res/drawable/download.xml | 9 + app/src/main/res/drawable/download_done.xml | 7 + app/src/main/res/drawable/drag_handle.xml | 9 + app/src/main/res/drawable/edit.xml | 9 + app/src/main/res/drawable/equalizer.xml | 9 + app/src/main/res/drawable/error.xml | 10 + app/src/main/res/drawable/expand_less.xml | 9 + app/src/main/res/drawable/expand_more.xml | 9 + app/src/main/res/drawable/explicit.xml | 9 + app/src/main/res/drawable/explore_filled.xml | 9 + .../main/res/drawable/explore_outlined.xml | 16 + app/src/main/res/drawable/extension.xml | 9 + app/src/main/res/drawable/fast_forward.xml | 9 + app/src/main/res/drawable/favorite.xml | 10 + app/src/main/res/drawable/favorite_border.xml | 10 + .../main/res/drawable/format_align_center.xml | 9 + .../main/res/drawable/format_align_left.xml | 10 + app/src/main/res/drawable/github.xml | 19 + app/src/main/res/drawable/gradient.xml | 5 + app/src/main/res/drawable/graphic_eq.xml | 9 + app/src/main/res/drawable/grid_view.xml | 9 + app/src/main/res/drawable/headphones.xml | 5 + app/src/main/res/drawable/help.xml | 9 + app/src/main/res/drawable/high_quality.xml | 5 + app/src/main/res/drawable/history.xml | 9 + app/src/main/res/drawable/home.xml | 9 + app/src/main/res/drawable/home_filled.xml | 9 + app/src/main/res/drawable/home_outlined.xml | 13 + app/src/main/res/drawable/ic_autorenew.xml | 5 + .../res/drawable/ic_banner_foreground.xml | 71 + app/src/main/res/drawable/ic_battery.xml | 5 + .../main/res/drawable/ic_battery_charging.xml | 5 + .../res/drawable/ic_launcher_foreground.xml | 49 - app/src/main/res/drawable/ic_low_battery.xml | 5 + app/src/main/res/drawable/ic_moon.xml | 5 + app/src/main/res/drawable/ic_smart_hub.xml | 5 + app/src/main/res/drawable/incognito_fill.xml | 3 - app/src/main/res/drawable/info.xml | 9 + app/src/main/res/drawable/input.xml | 9 + app/src/main/res/drawable/language.xml | 9 + app/src/main/res/drawable/liberapay.xml | 13 + app/src/main/res/drawable/library_add.xml | 9 + .../main/res/drawable/library_add_check.xml | 9 + app/src/main/res/drawable/library_music.xml | 9 + .../res/drawable/library_music_filled.xml | 4 + .../res/drawable/library_music_outlined.xml | 16 + app/src/main/res/drawable/list.xml | 9 + app/src/main/res/drawable/location_on.xml | 9 + app/src/main/res/drawable/lock.xml | 9 + app/src/main/res/drawable/lock_open.xml | 9 + app/src/main/res/drawable/logout.xml | 5 + .../main/res/drawable/loudly_monochrome.xml | 48 + .../main/res/drawable/loudly_picutre_1.jpg | Bin 0 -> 58395 bytes .../main/res/drawable/loudly_picutre_2.jpg | Bin 0 -> 66000 bytes .../main/res/drawable/loudly_picutre_3.jpg | Bin 0 -> 34986 bytes .../main/res/drawable/loudly_picutre_4.jpg | Bin 0 -> 27369 bytes .../main/res/drawable/loudly_picutre_5.jpg | Bin 0 -> 44714 bytes .../main/res/drawable/loudly_picutre_6.jpg | Bin 0 -> 24090 bytes app/src/main/res/drawable/lyrics.xml | 37 + app/src/main/res/drawable/manage_search.xml | 9 + app/src/main/res/drawable/mood.xml | 9 + app/src/main/res/drawable/more_horiz.xml | 9 + app/src/main/res/drawable/more_vert.xml | 9 + app/src/main/res/drawable/music_note.xml | 9 + app/src/main/res/drawable/nav_bar.xml | 9 + app/src/main/res/drawable/navigate_next.xml | 10 + app/src/main/res/drawable/notification_on.xml | 9 + .../main/res/drawable/notifications_off.xml | 9 + app/src/main/res/drawable/offline.xml | 9 + app/src/main/res/drawable/palette.xml | 9 + app/src/main/res/drawable/pause.xml | 9 + app/src/main/res/drawable/person.xml | 12 +- app/src/main/res/drawable/play.xml | 9 + app/src/main/res/drawable/playlist_add.xml | 9 + app/src/main/res/drawable/playlist_play.xml | 9 + app/src/main/res/drawable/playlist_remove.xml | 9 + app/src/main/res/drawable/queue_music.xml | 9 + app/src/main/res/drawable/radio.xml | 9 + .../res/drawable/radio_button_checked.xml | 9 + .../res/drawable/radio_button_unchecked.xml | 9 + app/src/main/res/drawable/remove.xml | 9 + app/src/main/res/drawable/repeat.xml | 9 + app/src/main/res/drawable/repeat_on.xml | 9 + app/src/main/res/drawable/repeat_one.xml | 9 + app/src/main/res/drawable/repeat_one_on.xml | 9 + app/src/main/res/drawable/replay.xml | 9 + app/src/main/res/drawable/restore.xml | 9 + app/src/main/res/drawable/rounded_corner.xml | 5 + app/src/main/res/drawable/save.xml | 5 + app/src/main/res/drawable/schedule.xml | 9 + app/src/main/res/drawable/screenshot.xml | 9 + app/src/main/res/drawable/search.xml | 9 + app/src/main/res/drawable/search_off.xml | 9 + app/src/main/res/drawable/security.xml | 9 + app/src/main/res/drawable/settings.xml | 9 + app/src/main/res/drawable/share.xml | 9 + app/src/main/res/drawable/shortcut_albums.xml | 9 + .../main/res/drawable/shortcut_artists.xml | 9 + .../main/res/drawable/shortcut_playlists.xml | 9 + app/src/main/res/drawable/shortcut_search.xml | 9 + app/src/main/res/drawable/shortcut_songs.xml | 9 + app/src/main/res/drawable/shuffle.xml | 9 + app/src/main/res/drawable/shuffle_on.xml | 9 + .../main/res/drawable/signal_cellular_alt.xml | 5 + .../res/drawable/signal_cellular_nodata.xml | 5 + app/src/main/res/drawable/skip_next.xml | 9 + app/src/main/res/drawable/skip_previous.xml | 9 + app/src/main/res/drawable/sliders.xml | 9 + .../main/res/drawable/slow_motion_video.xml | 9 + app/src/main/res/drawable/small_icon.xml | 48 + app/src/main/res/drawable/speed.xml | 9 + app/src/main/res/drawable/splash_icon.xml | 4 - app/src/main/res/drawable/spotify.xml | 8 + .../main/res/drawable/standart_quality.xml | 5 + app/src/main/res/drawable/storage.xml | 9 + app/src/main/res/drawable/swipe.xml | 7 + app/src/main/res/drawable/sync.xml | 9 + app/src/main/res/drawable/tab.xml | 9 + app/src/main/res/drawable/tag.xml | 5 + app/src/main/res/drawable/telegram.xml | 9 + app/src/main/res/drawable/tinkoff.xml | 19 + app/src/main/res/drawable/tips.xml | 5 + app/src/main/res/drawable/token.xml | 9 + app/src/main/res/drawable/translate.xml | 9 + app/src/main/res/drawable/trending_up.xml | 9 + app/src/main/res/drawable/tune.xml | 9 + app/src/main/res/drawable/update.xml | 9 + app/src/main/res/drawable/upgrade.xml | 5 + app/src/main/res/drawable/whatsapp.xml | 9 + .../drawable/widget_album_art_background.xml | 9 + .../main/res/drawable/widget_background.xml | 10 + .../widget_control_chip_background.xml | 10 + .../res/drawable/widget_icon_background.xml | 7 + .../res/drawable/widget_preview_classic.xml | 62 + .../main/res/drawable/widget_progress_bar.xml | 16 + app/src/main/res/drawable/widgetpreview2.xml | 4963 ----------------- app/src/main/res/drawable/wifi.xml | 5 + app/src/main/res/drawable/wifi_off.xml | 9 + app/src/main/res/drawable/wifi_proxy.xml | 9 + app/src/main/res/layout/widget_music.xml | 98 + .../main/res/mipmap-anydpi-v26/ic_banner.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 3 +- app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 3102 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 2988 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 2080 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 1830 -> 0 bytes app/src/main/res/mipmap-xhdpi/ic_banner.png | Bin 0 -> 5177 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 4452 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 4208 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 6250 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 6908 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 8920 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 9386 -> 0 bytes app/src/main/res/resources.properties | 1 + app/src/main/res/values-DE/strings.xml | 636 +++ app/src/main/res/values-ar/strings.xml | 938 +++- app/src/main/res/values-b+hi+Latn/strings.xml | 47 - app/src/main/res/values-bg/strings.xml | 636 +++ app/src/main/res/values-bn-rIN/strings.xml | 636 +++ app/src/main/res/values-cs/strings.xml | 656 +++ app/src/main/res/values-da/strings.xml | 278 - app/src/main/res/values-de/strings.xml | 278 - app/src/main/res/values-el-rGR/strings.xml | 636 +++ app/src/main/res/values-eo/strings.xml | 278 - .../{values-bn => values-es-rUS}/strings.xml | 0 app/src/main/res/values-es/strings.xml | 463 +- app/src/main/res/values-fa-rIR/strings.xml | 270 + app/src/main/res/values-fa/strings.xml | 164 - app/src/main/res/values-fi-rFI/strings.xml | 636 +++ app/src/main/res/values-fil/strings.xml | 278 - app/src/main/res/values-fr/strings.xml | 278 - app/src/main/res/values-hi/strings.xml | 154 - app/src/main/res/values-hu/strings.xml | 636 +++ app/src/main/res/values-in/strings.xml | 898 ++- app/src/main/res/values-it/strings.xml | 766 ++- app/src/main/res/values-ja-rJP/strings.xml | 626 +++ app/src/main/res/values-ja/strings.xml | 146 - app/src/main/res/values-ko-rKR/strings.xml | 626 +++ app/src/main/res/values-ko/strings.xml | 146 - app/src/main/res/values-ml-rIN/strings.xml | 636 +++ .../{values-ota => values-nb-rNO}/strings.xml | 0 app/src/main/res/values-night-v31/colors.xml | 8 - app/src/main/res/values-night-v31/themes.xml | 10 + app/src/main/res/values-night/colors.xml | 8 - app/src/main/res/values-nl/strings.xml | 278 - app/src/main/res/values-or-rIN/strings.xml | 270 + app/src/main/res/values-pa/strings.xml | 270 + app/src/main/res/values-pl/strings.xml | 474 +- app/src/main/res/values-pt-rBR/strings.xml | 594 +- app/src/main/res/values-pt/strings.xml | 160 - app/src/main/res/values-ru-rRU/strings.xml | 659 +++ app/src/main/res/values-ru/strings.xml | 171 - app/src/main/res/values-sk/strings.xml | 167 - app/src/main/res/values-sr/strings.xml | 146 - app/src/main/res/values-sv-rSE/strings.xml | 270 + app/src/main/res/values-sv/strings.xml | 123 - app/src/main/res/values-ta/strings.xml | 2 - app/src/main/res/values-tr/strings.xml | 1123 ++-- app/src/main/res/values-uk-rUA/strings.xml | 542 ++ app/src/main/res/values-uk/strings.xml | 171 - app/src/main/res/values-v21/styles.xml | 14 + app/src/main/res/values-v31/colors.xml | 8 - app/src/main/res/values-v31/styles.xml | 16 + app/src/main/res/values-v31/themes.xml | 11 + app/src/main/res/values-v33/themes.xml | 10 - app/src/main/res/values-vi/strings.xml | 343 +- app/src/main/res/values-zh-rCN/strings.xml | 886 ++- app/src/main/res/values-zh-rTW/strings.xml | 886 ++- app/src/main/res/values/app_name.xml | 4 + app/src/main/res/values/attrs.xml | 7 + app/src/main/res/values/colors.xml | 20 +- app/src/main/res/values/dimens.xml | 16 +- .../main/res/values/ic_banner_background.xml | 4 + app/src/main/res/values/strings.xml | 1345 ++--- app/src/main/res/values/styles.xml | 47 + app/src/main/res/values/themes.xml | 18 +- app/src/main/res/values/values.xml | 8 + app/src/main/res/xml-v25/shortcuts.xml | 43 + app/src/main/res/xml/add_note.xml | 9 - app/src/main/res/xml/automotive_app_desc.xml | 4 + app/src/main/res/xml/backup_rules.xml | 21 +- .../main/res/xml/data_extraction_rules.xml | 26 +- app/src/main/res/xml/file_paths.xml | 6 + app/src/main/res/xml/locales_config.xml | 27 - app/src/main/res/xml/notes.xml | 10 - app/src/main/res/xml/provider_paths.xml | 15 +- app/src/main/res/xml/widget_info.xml | 9 + gradle/libs.versions.toml | 41 + 637 files changed, 66934 insertions(+), 32726 deletions(-) create mode 100644 app/src/debug/res/values/app_name.xml create mode 100644 app/src/debug/res/xml-v25/shortcuts.xml create mode 100644 app/src/foss/java/com/babelsoftware/loudly/TranslationHelper.kt create mode 100644 app/src/foss/java/com/babelsoftware/loudly/Utils.kt create mode 100644 app/src/full/java/com/babelsoftware/loudly/TranslationHelper.kt create mode 100644 app/src/full/java/com/babelsoftware/loudly/Utils.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/App.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/constant/Connection.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/constant/Database.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/constant/Support.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/local/converter/Converters.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/local/dao/AiChatDao.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/local/dao/NotesDao.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabase.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabaseProvider.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/provider/StringProvider.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/repository/AiModels.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/repository/FolderRepositoryImpl.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/repository/GeminiRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/repository/ImportExportRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/repository/NoteRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/repository/SecureStorageRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/repository/SettingsRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/data/source/FolderDao.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/di/DataModule.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/di/NetworkModule.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatMessage.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatSession.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/model/AiResponse.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/model/Chat.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/model/Folder.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/model/Note.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/model/Settings.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/repository/FolderRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/repository/NoteRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/repository/SettingsRepository.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/usecase/AiChatUseCase.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/usecase/FolderUseCase.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/usecase/ImportExportUseCase.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/usecase/NoteUseCase.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/domain/usecase/SettingsUseCase.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/MainActivity.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/Animations.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/AutoFill.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActionButton.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActions.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomScaffold.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/EncryptionHelper.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/ImageCleaner.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/NoteItem.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/UpdateScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/Builder.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/InlineElements.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/LineProccesor.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/MarkdownText.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/RowElements.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/WidgetText.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/navigation/AnimatedComposable.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavHost.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavRoutes.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/EditScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomIconButton.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomTextField.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/TextFormattingToolbar.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/model/EditModel.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/AiInterface.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/HomeScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopAiScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopHomeScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/viewmodel/HomeModel.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/AddFolderDialog.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/FolderActionBottomSheet.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/MoveToFolderDialog.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteCard.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteFilter.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteGrid.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/Placeholder.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/SettingsScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/IconResource.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/SettingsModel.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/About.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/AirNoteAi.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Backup.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Behaviour.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Colors.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Crypto.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/DesktopModeSettingsScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Language.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Privacy.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Tools.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/LockScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Fingerprint.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Passcode.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Pattern.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/TitleText.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/viewModel/LockScreenViewModel.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/CircleWrapper.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/MaterialText.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingCategory.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsBox.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsDialog.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsList.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/screens/terms/TermsScreen.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/theme/Color.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/theme/FontUtils.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/theme/Schemes.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/theme/Theme.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/presentation/theme/Type.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/util/UpdateUtils.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/widget/AddNoteReceiver.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/widget/AddNoteWidget.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/widget/NotesWidget.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetActivity.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetReceiver.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/widget/ui/Note.kt delete mode 100644 app/src/main/java/com/babelsoftware/airnote/widget/ui/ZeroState.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/App.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/MainActivity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/MusicWidget.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/background/ActivityTransitionReceiver.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/background/AwarenessSystem.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/background/ContextualWorker.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/background/HeadsetPlugReceiver.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/constants/Dimensions.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/constants/HistorySource.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/constants/MediaSessionConstants.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/constants/PreferenceKeys.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/constants/StatPeriod.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/Converters.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/DatabaseDao.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/MusicDatabase.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/Album.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumArtistMap.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumWithSongs.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/Artist.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/ArtistEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/Event.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/EventWithSong.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/FormatEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/LocalItem.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/LyricsEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/Playlist.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSong.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMap.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMapPreview.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/RecentActivityEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/RelatedSongMap.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/SearchHistory.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/SetVideoIdEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/Song.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/SongAlbumMap.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/SongArtistMap.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/SongEntity.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongAlbumMap.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongArtistMap.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/di/AppModule.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/CoroutineExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/FileExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/ListExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/MediaItemExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/NavControllerExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/PlayerExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/StringExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/extensions/UtilExt.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/KuGouLyricsProvider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/LocalLyricsProvider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/LrcLibLyricsProvider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsEntry.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsHelper.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsProvider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/MlKitTranslationService.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeLyricsProvider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeSubtitleLyricsProvider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/ImageCache.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/ItemsPage.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/MediaMetadata.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/PersistQueue.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/SimilarRecommendation.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/SongWithPlayCount.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/SpotifyAuthResponse.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/SpotifyUserProfile.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/Tracks.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/Images.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistItem.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistPaginatedResponse.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Artists.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/SpotifyResultPaginatedResponse.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Track.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/TrackItem.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/DownloadUtil.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/ExoDownloadService.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/KeepAlive.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/MediaLibrarySessionCallback.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/MusicService.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/PlayerConnection.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/SleepTimer.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/queues/EmptyQueue.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/queues/ListQueue.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/queues/LocalAlbumRadio.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/queues/Queue.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeAlbumRadio.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeQueue.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/ActionChipButton.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/AnimatedSoundWave.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/AsyncImageLocal.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/AutoResizeText.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/BigSeekBar.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheet.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheetMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/ChipStyleButton.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/ChipsRow.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/Dialog.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/EmptyPlaceholder.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/ErrorDetailDialog.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/FeaturedContentCard.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/HideOnScrollFAB.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/IconButton.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/Items.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/ListMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/LoudlyBottomNavBar.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/Lyrics.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTile.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTitle.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/PlayerSlider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/PlayingIndicator.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/Preference.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/SearchBar.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/SortHeader.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ButtonPlaceholder.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/GridItemPlaceholder.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ListItemPlaceholder.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ShimmerHost.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/TextPlaceholder.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/AddToPlaylistDialog.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/AlbumMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/ArtistMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/FolderMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/LyricsMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/MediaMetadataMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/PlayerMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/PlaylistMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/QueueSelectionMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/SongMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/SongSelectionMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeAlbumMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeArtistMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubePlaylistMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongSelectionMenu.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/player/MiniPlayer.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/player/PlaybackError.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/player/Player.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/player/PlayerUI20.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/player/Queue.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/player/Thumbnail.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/AlbumScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/ExploreScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/HistoryScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/HomeScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/LoginScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/MoodAndGenresScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/NavigationBuilder.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/NewReleaseScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/Screens.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/SetupWizard.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/StatsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubeBrowseScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubePlaylistsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistItemsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistSongsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/guides/FeatureGuides.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryAlbumsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryArtistsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryMixScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryPlaylistsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibrarySongsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistLocalScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/CachePlaylistScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/LocalPlaylistScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/OnlinePlaylistScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/TopPlaylistScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/search/LocalSearchScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchResult.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AboutScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AccountScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AppearanceSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/BackupAndRestore.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ComradeNotificationSettingsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ContentSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordLoginScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LocalPlayerSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LyricsSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/NotificationSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PlayerSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PrivacySettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/StorageSettings.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/TipsScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsBox.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsGroup.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/ImportFromSpotifyScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportFromSpotifyScreenState.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportProgressEvent.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/Playlist.kt rename app/src/main/java/com/babelsoftware/{airnote/presentation/screens/settings/model => loudly/ui/screens/settings/viewmodel}/AppUpdateViewModel.kt (61%) create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/viewmodel/SettingsViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/screens/share/SocialShareScreen.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/theme/Style.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/AppBar.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/FadingEdge.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/LazyGridSnapLayoutInfoProvider.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/LocalMediaUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/NavControllerUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/ScrollUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/ShapeUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/StringUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/ui/utils/YouTubeUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/CoilBitmapLoader.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/DataStore.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/DiscordRPC.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/ItemWrapper.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/LmImageCacheMgr.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/LogReportHelper.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/NetworkUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/NotificationHelper.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/StringUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/SyncUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/UpdateUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/Updater.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/utils/YTPlayerUtils.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/AlbumViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistItemsViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/AutoPlaylistViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/BackupRestoreViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/CachePlaylistViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/ExploreViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/HistoryViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/HomeViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/ImportFromSpotifyViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/LibraryViewModels.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalPlaylistViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalSearchViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/LyricsMenuViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/MoodAndGenresViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/NewReleaseViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlinePlaylistViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchSuggestionViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/StatsViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/TopPlaylistViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubeBrowseViewModel.kt create mode 100644 app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubePlaylistsViewModel.kt delete mode 100644 app/src/main/res/animator/draw_path.xml delete mode 100644 app/src/main/res/animator/splash_animation.xml create mode 100644 app/src/main/res/drawable-nodpi/example_appwidget_preview.png delete mode 100644 app/src/main/res/drawable-nodpi/widget_preview_1.png delete mode 100644 app/src/main/res/drawable-nodpi/widget_preview_2.png create mode 100644 app/src/main/res/drawable/account_circle.xml create mode 100644 app/src/main/res/drawable/add.xml create mode 100644 app/src/main/res/drawable/add_link.xml create mode 100644 app/src/main/res/drawable/album.xml create mode 100644 app/src/main/res/drawable/album_art_gradient.xml create mode 100644 app/src/main/res/drawable/app_widget_background.xml create mode 100644 app/src/main/res/drawable/app_widget_inner_view_background.xml create mode 100644 app/src/main/res/drawable/arrow_back.xml create mode 100644 app/src/main/res/drawable/arrow_downward.xml create mode 100644 app/src/main/res/drawable/arrow_forward.xml create mode 100644 app/src/main/res/drawable/arrow_top_left.xml create mode 100644 app/src/main/res/drawable/arrow_upward.xml create mode 100644 app/src/main/res/drawable/artist.xml delete mode 100644 app/src/main/res/drawable/avd_splash_draw.xml create mode 100644 app/src/main/res/drawable/backup.xml create mode 100644 app/src/main/res/drawable/badge.xml create mode 100644 app/src/main/res/drawable/bedtime.xml create mode 100644 app/src/main/res/drawable/bluetooth_devices.xml create mode 100644 app/src/main/res/drawable/bookmark.xml create mode 100644 app/src/main/res/drawable/bookmark_filled.xml create mode 100644 app/src/main/res/drawable/boosty.xml create mode 100644 app/src/main/res/drawable/bug_report.xml create mode 100644 app/src/main/res/drawable/cached.xml create mode 100644 app/src/main/res/drawable/casa.webp create mode 100644 app/src/main/res/drawable/casino.xml create mode 100644 app/src/main/res/drawable/clear_all.xml create mode 100644 app/src/main/res/drawable/close.xml create mode 100644 app/src/main/res/drawable/codigo.xml create mode 100644 app/src/main/res/drawable/content_copy.xml create mode 100644 app/src/main/res/drawable/contrast.xml create mode 100644 app/src/main/res/drawable/dark_mode.xml create mode 100644 app/src/main/res/drawable/delete.xml create mode 100644 app/src/main/res/drawable/delete_history.xml create mode 100644 app/src/main/res/drawable/directions_run.xml create mode 100644 app/src/main/res/drawable/discord.xml create mode 100644 app/src/main/res/drawable/discover_tune.xml create mode 100644 app/src/main/res/drawable/donate.xml create mode 100644 app/src/main/res/drawable/download.xml create mode 100644 app/src/main/res/drawable/download_done.xml create mode 100644 app/src/main/res/drawable/drag_handle.xml create mode 100644 app/src/main/res/drawable/edit.xml create mode 100644 app/src/main/res/drawable/equalizer.xml create mode 100644 app/src/main/res/drawable/error.xml create mode 100644 app/src/main/res/drawable/expand_less.xml create mode 100644 app/src/main/res/drawable/expand_more.xml create mode 100644 app/src/main/res/drawable/explicit.xml create mode 100644 app/src/main/res/drawable/explore_filled.xml create mode 100644 app/src/main/res/drawable/explore_outlined.xml create mode 100644 app/src/main/res/drawable/extension.xml create mode 100644 app/src/main/res/drawable/fast_forward.xml create mode 100644 app/src/main/res/drawable/favorite.xml create mode 100644 app/src/main/res/drawable/favorite_border.xml create mode 100644 app/src/main/res/drawable/format_align_center.xml create mode 100644 app/src/main/res/drawable/format_align_left.xml create mode 100644 app/src/main/res/drawable/github.xml create mode 100644 app/src/main/res/drawable/gradient.xml create mode 100644 app/src/main/res/drawable/graphic_eq.xml create mode 100644 app/src/main/res/drawable/grid_view.xml create mode 100644 app/src/main/res/drawable/headphones.xml create mode 100644 app/src/main/res/drawable/help.xml create mode 100644 app/src/main/res/drawable/high_quality.xml create mode 100644 app/src/main/res/drawable/history.xml create mode 100644 app/src/main/res/drawable/home.xml create mode 100644 app/src/main/res/drawable/home_filled.xml create mode 100644 app/src/main/res/drawable/home_outlined.xml create mode 100644 app/src/main/res/drawable/ic_autorenew.xml create mode 100644 app/src/main/res/drawable/ic_banner_foreground.xml create mode 100644 app/src/main/res/drawable/ic_battery.xml create mode 100644 app/src/main/res/drawable/ic_battery_charging.xml delete mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_low_battery.xml create mode 100644 app/src/main/res/drawable/ic_moon.xml create mode 100644 app/src/main/res/drawable/ic_smart_hub.xml delete mode 100644 app/src/main/res/drawable/incognito_fill.xml create mode 100644 app/src/main/res/drawable/info.xml create mode 100644 app/src/main/res/drawable/input.xml create mode 100644 app/src/main/res/drawable/language.xml create mode 100644 app/src/main/res/drawable/liberapay.xml create mode 100644 app/src/main/res/drawable/library_add.xml create mode 100644 app/src/main/res/drawable/library_add_check.xml create mode 100644 app/src/main/res/drawable/library_music.xml create mode 100644 app/src/main/res/drawable/library_music_filled.xml create mode 100644 app/src/main/res/drawable/library_music_outlined.xml create mode 100644 app/src/main/res/drawable/list.xml create mode 100644 app/src/main/res/drawable/location_on.xml create mode 100644 app/src/main/res/drawable/lock.xml create mode 100644 app/src/main/res/drawable/lock_open.xml create mode 100644 app/src/main/res/drawable/logout.xml create mode 100644 app/src/main/res/drawable/loudly_monochrome.xml create mode 100644 app/src/main/res/drawable/loudly_picutre_1.jpg create mode 100644 app/src/main/res/drawable/loudly_picutre_2.jpg create mode 100644 app/src/main/res/drawable/loudly_picutre_3.jpg create mode 100644 app/src/main/res/drawable/loudly_picutre_4.jpg create mode 100644 app/src/main/res/drawable/loudly_picutre_5.jpg create mode 100644 app/src/main/res/drawable/loudly_picutre_6.jpg create mode 100644 app/src/main/res/drawable/lyrics.xml create mode 100644 app/src/main/res/drawable/manage_search.xml create mode 100644 app/src/main/res/drawable/mood.xml create mode 100644 app/src/main/res/drawable/more_horiz.xml create mode 100644 app/src/main/res/drawable/more_vert.xml create mode 100644 app/src/main/res/drawable/music_note.xml create mode 100644 app/src/main/res/drawable/nav_bar.xml create mode 100644 app/src/main/res/drawable/navigate_next.xml create mode 100644 app/src/main/res/drawable/notification_on.xml create mode 100644 app/src/main/res/drawable/notifications_off.xml create mode 100644 app/src/main/res/drawable/offline.xml create mode 100644 app/src/main/res/drawable/palette.xml create mode 100644 app/src/main/res/drawable/pause.xml create mode 100644 app/src/main/res/drawable/play.xml create mode 100644 app/src/main/res/drawable/playlist_add.xml create mode 100644 app/src/main/res/drawable/playlist_play.xml create mode 100644 app/src/main/res/drawable/playlist_remove.xml create mode 100644 app/src/main/res/drawable/queue_music.xml create mode 100644 app/src/main/res/drawable/radio.xml create mode 100644 app/src/main/res/drawable/radio_button_checked.xml create mode 100644 app/src/main/res/drawable/radio_button_unchecked.xml create mode 100644 app/src/main/res/drawable/remove.xml create mode 100644 app/src/main/res/drawable/repeat.xml create mode 100644 app/src/main/res/drawable/repeat_on.xml create mode 100644 app/src/main/res/drawable/repeat_one.xml create mode 100644 app/src/main/res/drawable/repeat_one_on.xml create mode 100644 app/src/main/res/drawable/replay.xml create mode 100644 app/src/main/res/drawable/restore.xml create mode 100644 app/src/main/res/drawable/rounded_corner.xml create mode 100644 app/src/main/res/drawable/save.xml create mode 100644 app/src/main/res/drawable/schedule.xml create mode 100644 app/src/main/res/drawable/screenshot.xml create mode 100644 app/src/main/res/drawable/search.xml create mode 100644 app/src/main/res/drawable/search_off.xml create mode 100644 app/src/main/res/drawable/security.xml create mode 100644 app/src/main/res/drawable/settings.xml create mode 100644 app/src/main/res/drawable/share.xml create mode 100644 app/src/main/res/drawable/shortcut_albums.xml create mode 100644 app/src/main/res/drawable/shortcut_artists.xml create mode 100644 app/src/main/res/drawable/shortcut_playlists.xml create mode 100644 app/src/main/res/drawable/shortcut_search.xml create mode 100644 app/src/main/res/drawable/shortcut_songs.xml create mode 100644 app/src/main/res/drawable/shuffle.xml create mode 100644 app/src/main/res/drawable/shuffle_on.xml create mode 100644 app/src/main/res/drawable/signal_cellular_alt.xml create mode 100644 app/src/main/res/drawable/signal_cellular_nodata.xml create mode 100644 app/src/main/res/drawable/skip_next.xml create mode 100644 app/src/main/res/drawable/skip_previous.xml create mode 100644 app/src/main/res/drawable/sliders.xml create mode 100644 app/src/main/res/drawable/slow_motion_video.xml create mode 100644 app/src/main/res/drawable/small_icon.xml create mode 100644 app/src/main/res/drawable/speed.xml delete mode 100644 app/src/main/res/drawable/splash_icon.xml create mode 100644 app/src/main/res/drawable/spotify.xml create mode 100644 app/src/main/res/drawable/standart_quality.xml create mode 100644 app/src/main/res/drawable/storage.xml create mode 100644 app/src/main/res/drawable/swipe.xml create mode 100644 app/src/main/res/drawable/sync.xml create mode 100644 app/src/main/res/drawable/tab.xml create mode 100644 app/src/main/res/drawable/tag.xml create mode 100644 app/src/main/res/drawable/telegram.xml create mode 100644 app/src/main/res/drawable/tinkoff.xml create mode 100644 app/src/main/res/drawable/tips.xml create mode 100644 app/src/main/res/drawable/token.xml create mode 100644 app/src/main/res/drawable/translate.xml create mode 100644 app/src/main/res/drawable/trending_up.xml create mode 100644 app/src/main/res/drawable/tune.xml create mode 100644 app/src/main/res/drawable/update.xml create mode 100644 app/src/main/res/drawable/upgrade.xml create mode 100644 app/src/main/res/drawable/whatsapp.xml create mode 100644 app/src/main/res/drawable/widget_album_art_background.xml create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/drawable/widget_control_chip_background.xml create mode 100644 app/src/main/res/drawable/widget_icon_background.xml create mode 100644 app/src/main/res/drawable/widget_preview_classic.xml create mode 100644 app/src/main/res/drawable/widget_progress_bar.xml delete mode 100644 app/src/main/res/drawable/widgetpreview2.xml create mode 100644 app/src/main/res/drawable/wifi.xml create mode 100644 app/src/main/res/drawable/wifi_off.xml create mode 100644 app/src/main/res/drawable/wifi_proxy.xml create mode 100644 app/src/main/res/layout/widget_music.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_banner.xml delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_banner.png delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/resources.properties create mode 100644 app/src/main/res/values-DE/strings.xml delete mode 100644 app/src/main/res/values-b+hi+Latn/strings.xml create mode 100644 app/src/main/res/values-bg/strings.xml create mode 100644 app/src/main/res/values-bn-rIN/strings.xml create mode 100644 app/src/main/res/values-cs/strings.xml delete mode 100644 app/src/main/res/values-da/strings.xml delete mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values-el-rGR/strings.xml delete mode 100644 app/src/main/res/values-eo/strings.xml rename app/src/main/res/{values-bn => values-es-rUS}/strings.xml (100%) create mode 100644 app/src/main/res/values-fa-rIR/strings.xml delete mode 100644 app/src/main/res/values-fa/strings.xml create mode 100644 app/src/main/res/values-fi-rFI/strings.xml delete mode 100644 app/src/main/res/values-fil/strings.xml delete mode 100644 app/src/main/res/values-fr/strings.xml delete mode 100644 app/src/main/res/values-hi/strings.xml create mode 100644 app/src/main/res/values-hu/strings.xml create mode 100644 app/src/main/res/values-ja-rJP/strings.xml delete mode 100644 app/src/main/res/values-ja/strings.xml create mode 100644 app/src/main/res/values-ko-rKR/strings.xml delete mode 100644 app/src/main/res/values-ko/strings.xml create mode 100644 app/src/main/res/values-ml-rIN/strings.xml rename app/src/main/res/{values-ota => values-nb-rNO}/strings.xml (100%) delete mode 100644 app/src/main/res/values-night-v31/colors.xml create mode 100644 app/src/main/res/values-night-v31/themes.xml delete mode 100644 app/src/main/res/values-night/colors.xml delete mode 100644 app/src/main/res/values-nl/strings.xml create mode 100644 app/src/main/res/values-or-rIN/strings.xml create mode 100644 app/src/main/res/values-pa/strings.xml delete mode 100644 app/src/main/res/values-pt/strings.xml create mode 100644 app/src/main/res/values-ru-rRU/strings.xml delete mode 100644 app/src/main/res/values-ru/strings.xml delete mode 100644 app/src/main/res/values-sk/strings.xml delete mode 100644 app/src/main/res/values-sr/strings.xml create mode 100644 app/src/main/res/values-sv-rSE/strings.xml delete mode 100644 app/src/main/res/values-sv/strings.xml delete mode 100644 app/src/main/res/values-ta/strings.xml create mode 100644 app/src/main/res/values-uk-rUA/strings.xml delete mode 100644 app/src/main/res/values-uk/strings.xml create mode 100644 app/src/main/res/values-v21/styles.xml delete mode 100644 app/src/main/res/values-v31/colors.xml create mode 100644 app/src/main/res/values-v31/styles.xml create mode 100644 app/src/main/res/values-v31/themes.xml delete mode 100644 app/src/main/res/values-v33/themes.xml create mode 100644 app/src/main/res/values/app_name.xml create mode 100644 app/src/main/res/values/attrs.xml create mode 100644 app/src/main/res/values/ic_banner_background.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/values/values.xml create mode 100644 app/src/main/res/xml-v25/shortcuts.xml delete mode 100644 app/src/main/res/xml/add_note.xml create mode 100644 app/src/main/res/xml/automotive_app_desc.xml create mode 100644 app/src/main/res/xml/file_paths.xml delete mode 100644 app/src/main/res/xml/locales_config.xml delete mode 100644 app/src/main/res/xml/notes.xml create mode 100644 app/src/main/res/xml/widget_info.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2de60f..fa7bd09 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,8 +8,8 @@ plugins { } android { - namespace = "com.babelsoftware.airnote" - compileSdk = 36 + namespace = "com.babelsoftware.loudly" + compileSdk = 35 flavorDimensions += "default" buildFeatures { @@ -19,28 +19,21 @@ android { productFlavors { create("default") { dimension = "default" - applicationId = "com.babelsoftware.airnote" + applicationId = "com.babelsoftware.loudly" versionNameSuffix = "-default" } - - create("accrescent") { - dimension = "default" - applicationId = "by.babelapps.airnote" - versionNameSuffix = "-babelapps" - } } defaultConfig { - applicationId = "com.babelsoftware.airnote" + applicationId = "com.babelsoftware.loudly" minSdk = 26 - targetSdk = 36 + targetSdk = 35 versionCode = 1 versionName = "v1.6.1" vectorDrawables { useSupportLibrary = true } - // https://developer.android.com/guide/topics/resources/app-languages#gradle-config resourceConfigurations.plus( listOf("en", "ar", "de", "es", "fa", "fil", "fr", "hi", "it", "ja", "ru", "sk", "tr", "da", "nl", "pl", "tr", "uk", "vi", "ota", "pt-rBR", "sr", "zh-rCN") ) @@ -84,46 +77,70 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } - buildToolsVersion = "36.0.0" + buildToolsVersion = "35.0.0" } dependencies { - implementation(libs.androidx.biometric.ktx) - implementation(libs.androidx.glance) - implementation(libs.coil.compose) - implementation(libs.hilt.navigation.compose) - implementation(libs.androidx.glance.appwidget) - implementation(libs.androidx.material3.window.size.class1.android) - ksp(libs.androidx.room.compiler) - ksp(libs.hilt.android.compiler) - ksp(libs.hilt.compile) - - implementation("com.google.ai.client.generativeai:generativeai:0.9.0") - implementation("androidx.security:security-crypto:1.1.0") - implementation("com.udojava:EvalEx:2.7") - implementation("androidx.activity:activity-compose:1.11.0") - implementation("com.google.mlkit:translate:17.0.3") - implementation("com.google.mlkit:language-id:17.0.6") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2") - implementation("org.jsoup:jsoup:1.21.2") - implementation("com.google.code.gson:gson:2.13.2") - implementation(platform("io.ktor:ktor-bom:3.0.0-beta-2")) - implementation("io.ktor:ktor-client-core") - implementation("io.ktor:ktor-client-android") - implementation("io.ktor:ktor-client-content-negotiation") - implementation("io.ktor:ktor-client-logging") - implementation("io.ktor:ktor-serialization-kotlinx-json") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.hilt.navigation.compose) - implementation(libs.hilt.android) - implementation(libs.androidx.datastore.preferences) + // Room implementation(libs.androidx.room.runtime) - implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.room.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.androidx.material3) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.core.splashscreen) - implementation(libs.androidx.navigation.compose) -} \ No newline at end of file + ksp(libs.androidx.room.compiler) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.android.compiler) + ksp(libs.hilt.compiler) + + // Coil + implementation(libs.coil.compose) + implementation(libs.coil.gif) + + // Media3 + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.common) + + // Palette + implementation(libs.androidx.palette.ktx) + + // JSON + implementation("com.google.code.gson:gson:2.10.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + + // Coroutines + implementation(libs.kotlinx.coroutines.android) + + // Network (Retrofit/OkHttp assumed if not using Ktor, but saw Ktor in AirNote. Loudly might differ. Sticking to generics) + // Actually the imports showed org.json but not retrofit explicitly in the snippets. + // I will add standard networking just in case + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Image Cropper (from manifest) + implementation("com.vanniktech:android-image-cropper:4.5.0") + + // Material 3 Window Size Class + implementation("androidx.compose.material3:material3-window-size-class:1.2.1") + + // Squiggles + implementation("me.saket.squiggles:squiggles:0.2.0") + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/app/src/debug/res/values/app_name.xml b/app/src/debug/res/values/app_name.xml new file mode 100644 index 0000000..a1d1e2f --- /dev/null +++ b/app/src/debug/res/values/app_name.xml @@ -0,0 +1,4 @@ + + + Loudly Debug + \ No newline at end of file diff --git a/app/src/debug/res/xml-v25/shortcuts.xml b/app/src/debug/res/xml-v25/shortcuts.xml new file mode 100644 index 0000000..1e6fe60 --- /dev/null +++ b/app/src/debug/res/xml-v25/shortcuts.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/foss/java/com/babelsoftware/loudly/TranslationHelper.kt b/app/src/foss/java/com/babelsoftware/loudly/TranslationHelper.kt new file mode 100644 index 0000000..62c698c --- /dev/null +++ b/app/src/foss/java/com/babelsoftware/loudly/TranslationHelper.kt @@ -0,0 +1,8 @@ +package com.babelsoftware.loudly + +import com.babelsoftware.loudly.db.entities.LyricsEntity + +object TranslationHelper { + fun translate(lyrics: LyricsEntity): LyricsEntity = lyrics + fun clearModels() {} +} \ No newline at end of file diff --git a/app/src/foss/java/com/babelsoftware/loudly/Utils.kt b/app/src/foss/java/com/babelsoftware/loudly/Utils.kt new file mode 100644 index 0000000..28beb80 --- /dev/null +++ b/app/src/foss/java/com/babelsoftware/loudly/Utils.kt @@ -0,0 +1,5 @@ +package com.babelsoftware.loudly + +fun reportException(throwable: Throwable) { + throwable.printStackTrace() +} diff --git a/app/src/full/java/com/babelsoftware/loudly/TranslationHelper.kt b/app/src/full/java/com/babelsoftware/loudly/TranslationHelper.kt new file mode 100644 index 0000000..59bed41 --- /dev/null +++ b/app/src/full/java/com/babelsoftware/loudly/TranslationHelper.kt @@ -0,0 +1,78 @@ +package com.babelsoftware.loudly + +import android.util.LruCache +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.lyrics.LyricsUtils +import com.github.houbb.opencc4j.util.ZhConverterUtil +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.common.model.RemoteModelManager +import com.google.mlkit.nl.languageid.LanguageIdentification +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.TranslateRemoteModel +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.TranslatorOptions +import kotlinx.coroutines.tasks.await +import java.util.Locale + +object TranslationHelper { + private const val MAX_CACHE_SIZE = 20 + private val cache = LruCache(MAX_CACHE_SIZE) + + suspend fun translate(lyrics: LyricsEntity): LyricsEntity { + cache[lyrics.id]?.let { return it } + val isSynced = lyrics.lyrics.startsWith("[") + val sourceLanguage = TranslateLanguage.fromLanguageTag( + LanguageIdentification.getClient().identifyLanguage( + lyrics.lyrics.lines().joinToString(separator = "\n") { it.replace("\\[\\d{2}:\\d{2}.\\d{2,3}\\] *".toRegex(), "") } + ).await() + ) + val targetLanguage = TranslateLanguage.fromLanguageTag( + Locale.getDefault().toLanguageTag().substring(0..1) + ) + return if (sourceLanguage == null || targetLanguage == null || sourceLanguage == targetLanguage) { + lyrics + } else { + val translator = Translation.getClient( + TranslatorOptions.Builder() + .setSourceLanguage(sourceLanguage) + .setTargetLanguage(targetLanguage) + .build() + ) + translator.downloadModelIfNeeded( + DownloadConditions.Builder() + .requireWifi() + .build() + ).await() + val traditionalChinese = Locale.getDefault().toLanguageTag().replace("-Hant", "") == "zh-TW" + lyrics.copy( + lyrics = if (isSynced) { + LyricsUtils.parseLyrics(lyrics.lyrics, trim = true, multilineEnable = false).map { + val translated = translator.translate(it.text).await() + it.copy( + text = if (traditionalChinese) ZhConverterUtil.toTraditional(translated) else translated + ) + }.joinToString(separator = "\n") { + "[%02d:%02d.%03d]${it.text}".format(it.time / 60000, (it.time / 1000) % 60, it.time % 1000) + } + } else { + lyrics.lyrics.lines() + .map { + val translated = translator.translate(it).await() + if (traditionalChinese) ZhConverterUtil.toTraditional(translated) else translated + } + .joinToString(separator = "\n") + } + ) + }.also { + cache.put(it.id, it) + } + } + + suspend fun clearModels() { + val modelManager = RemoteModelManager.getInstance() + val downloadedModels = modelManager.getDownloadedModels(TranslateRemoteModel::class.java).await() + downloadedModels.forEach { + modelManager.deleteDownloadedModel(it).await() + } + } +} \ No newline at end of file diff --git a/app/src/full/java/com/babelsoftware/loudly/Utils.kt b/app/src/full/java/com/babelsoftware/loudly/Utils.kt new file mode 100644 index 0000000..dc03b67 --- /dev/null +++ b/app/src/full/java/com/babelsoftware/loudly/Utils.kt @@ -0,0 +1,9 @@ +package com.babelsoftware.loudly + +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase + +fun reportException(throwable: Throwable) { + Firebase.crashlytics.recordException(throwable) + throwable.printStackTrace() +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2ca4db1..396e576 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,91 +1,211 @@ - + - + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - + android:theme="@style/Theme.Loudly" + tools:targetApi="tiramisu"> + + android:resource="@xml/widget_info" /> - + + - + - - - - - - - + android:permission="com.google.android.gms.permission.ACTIVITY_RECOGNITION" /> + + - + android:launchMode="singleTask" + android:theme="@style/Theme.Loudly" + android:windowSoftInputMode="adjustResize"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + android:foregroundServiceType="specialUse" + tools:ignore="ExportedService"> + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index 5363c78acc17e46ee8921e6b07628bf00d812cf4..28f802b11925d017df6c1ca0a081805778f7fac5 100644 GIT binary patch literal 10677 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∼;5`kMEy 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 diff --git a/app/src/main/java/com/babelsoftware/airnote/App.kt b/app/src/main/java/com/babelsoftware/airnote/App.kt deleted file mode 100644 index b07d285..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/App.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.babelsoftware.airnote - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -@HiltAndroidApp -class App : Application() \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/constant/Connection.kt b/app/src/main/java/com/babelsoftware/airnote/constant/Connection.kt deleted file mode 100644 index 15a4075..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/constant/Connection.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.babelsoftware.airnote.constant - -object ConnectionConst { - /** - * Support mail. - */ - const val SUPPORT_MAIL = "rrechzexe.dev@gmail.com" - - /** - * Url of source code on Github. - */ - const val GITHUB_SOURCE_CODE = "https://github.com/RRechz/AirNote" - - /** - * Feature request github. - */ - const val FEATURE_REQUEST = "https://t.me/by_BabelSoftware" - - - /** - * Support ko-fi. - */ - const val SUPPORT_BUYMEACOFFEE = "https://buymeacoffee.com/dev_rrechz.kt" -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/constant/Database.kt b/app/src/main/java/com/babelsoftware/airnote/constant/Database.kt deleted file mode 100644 index 08edb9d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/constant/Database.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.constant - -object DatabaseConst { - /** - * Database version - */ - const val NOTES_DATABASE_VERSION = 6 - - /** - * Exported notes room database name and extension - */ - const val NOTES_DATABASE_FILE_NAME = "note-list.db" - - /** - * Name of backup - */ - const val NOTES_DATABASE_BACKUP_NAME = "AirNote" - - /** - * Exported settings datastore name and extension - */ - const val SETTINGS_DATASTORE_FILE_NAME = "settings" - -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/constant/Support.kt b/app/src/main/java/com/babelsoftware/airnote/constant/Support.kt deleted file mode 100644 index f717d7a..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/constant/Support.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.constant - -object SupportConst { - /** - * Address for Proton Wallet users - */ - const val PROTON_WALLET_ADDRESS = "m06.burakozcan@proton.me" - - /** - * Address of bitcoin account - */ - const val BITCOIN_ADDRESS = "bc1qfg6y048gfg7a0v5cl47atejuk06ruv3xj4sng6" - - const val TERMS_EFFECTIVE_DATE = "2025/06/30" // Created of date -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/local/converter/Converters.kt b/app/src/main/java/com/babelsoftware/airnote/data/local/converter/Converters.kt deleted file mode 100644 index e516fc2..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/local/converter/Converters.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.babelsoftware.airnote.data.local.converter - -import androidx.room.TypeConverter -import com.babelsoftware.airnote.domain.model.Participant -import java.util.Date - -class Converters { - @TypeConverter - fun fromTimestamp(value: Long?): Date? { - return value?.let { Date(it) } - } - - @TypeConverter - fun dateToTimestamp(date: Date?): Long? { - return date?.time - } - - @TypeConverter - fun fromParticipantName(name: String?): Participant? { - return name?.let { Participant.valueOf(it) } - } - - @TypeConverter - fun participantToName(participant: Participant?): String? { - return participant?.name - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/data/local/dao/AiChatDao.kt b/app/src/main/java/com/babelsoftware/airnote/data/local/dao/AiChatDao.kt deleted file mode 100644 index a85ddfd..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/local/dao/AiChatDao.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.babelsoftware.airnote.data.local.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.babelsoftware.airnote.domain.model.AiChatMessage -import com.babelsoftware.airnote.domain.model.AiChatSession -import com.babelsoftware.airnote.domain.model.Participant -import kotlinx.coroutines.flow.Flow - -@Dao -interface AiChatDao { - // --- Session Operations --- - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertSession(session: AiChatSession): Long - - @Query("SELECT * FROM ai_chat_sessions ORDER BY createdAt DESC") - fun getAllSessions(): Flow> - - @Query("SELECT * FROM ai_chat_sessions WHERE id = :sessionId") - suspend fun getSessionById(sessionId: Long): AiChatSession? - - @Query("UPDATE ai_chat_sessions SET aiMode = :aiMode WHERE id = :sessionId") - suspend fun updateSessionMode(sessionId: Long, aiMode: String) - - @Query("DELETE FROM ai_chat_sessions WHERE id = :sessionId") - suspend fun deleteSessionById(sessionId: Long) - - // --- Message Operations --- - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertMessage(message: AiChatMessage): Long - - @Query("SELECT * FROM ai_chat_messages WHERE sessionId = :sessionId ORDER BY timestamp ASC") - fun getMessagesForSession(sessionId: Long): Flow> - - @Query("UPDATE ai_chat_messages SET text = :text, isLoading = :isLoading, participant = :participant WHERE id = :id") - suspend fun updateMessageById( - id: Long, - text: String, - isLoading: Boolean, - participant: Participant - ) - - @Query("DELETE FROM ai_chat_messages WHERE id = :id") - suspend fun deleteMessageById(id: Long) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/data/local/dao/NotesDao.kt b/app/src/main/java/com/babelsoftware/airnote/data/local/dao/NotesDao.kt deleted file mode 100644 index 2e3953a..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/local/dao/NotesDao.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.babelsoftware.airnote.data.local.dao - -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import androidx.room.Update -import com.babelsoftware.airnote.domain.model.Note -import kotlinx.coroutines.flow.Flow - -@Dao -interface NoteDao { - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun addNote(note: Note) - - @Query("SELECT * FROM `notes-table`") - fun getAllNotes(): Flow> - - @Update - suspend fun updateNote(note: Note) - - @Delete - suspend fun deleteNote(note: Note) - - @Query("SELECT * FROM `notes-table` WHERE id=:id") - fun getNoteById(id: Int): Flow - - @Query("SELECT id FROM `notes-table` ORDER BY id DESC LIMIT 1") - fun getLastNoteId(): Long? - - @Query("SELECT * FROM `notes-table` WHERE `foldered` = :folderId ORDER BY `created_at` DESC") - fun getNotesByFolder(folderId: Long): Flow> - - @Query("UPDATE `notes-table` SET `foldered` = NULL WHERE `foldered` = :folderId") - suspend fun unlinkNotesFromFolder(folderId: Long) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabase.kt b/app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabase.kt deleted file mode 100644 index 90661bf..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabase.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.babelsoftware.airnote.data.local.database - -import androidx.room.Database -import androidx.room.RoomDatabase -import androidx.room.TypeConverters -import com.babelsoftware.airnote.constant.DatabaseConst -import com.babelsoftware.airnote.data.local.converter.Converters -import com.babelsoftware.airnote.data.local.dao.AiChatDao -import com.babelsoftware.airnote.data.local.dao.NoteDao -import com.babelsoftware.airnote.data.source.FolderDao -import com.babelsoftware.airnote.domain.model.AiChatMessage -import com.babelsoftware.airnote.domain.model.AiChatSession -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.model.Note - -@Database( - entities = [ - Note::class, - Folder::class, - AiChatSession::class, - AiChatMessage::class - ], - version = 9, - exportSchema = false -) -@TypeConverters(Converters::class) -abstract class NoteDatabase : RoomDatabase() { - - abstract fun noteDao(): NoteDao - abstract fun folderDao(): FolderDao - abstract fun aiChatDao(): AiChatDao -} diff --git a/app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabaseProvider.kt b/app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabaseProvider.kt deleted file mode 100644 index 4c89b5d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/local/database/NoteDatabaseProvider.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.babelsoftware.airnote.data.local.database - - -import android.app.Application -import androidx.room.Room -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase -import com.babelsoftware.airnote.constant.DatabaseConst -import com.babelsoftware.airnote.data.local.dao.AiChatDao -import com.babelsoftware.airnote.data.local.dao.NoteDao -import com.babelsoftware.airnote.data.source.FolderDao - -class NoteDatabaseProvider(private val application: Application) { - - @Volatile - private var database: NoteDatabase? = null - - fun instance(): NoteDatabase { - return database ?: synchronized(this) { - database ?: buildDatabase().also { database = it } - } - } - - private fun buildDatabase(): NoteDatabase { - return Room.databaseBuilder(application.applicationContext, - NoteDatabase::class.java, - DatabaseConst.NOTES_DATABASE_FILE_NAME) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_2_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) - .build() - } - - @Synchronized - fun close() { - database?.close() - database = null - } - - fun noteDao(): NoteDao { - return instance().noteDao() - } - - fun folderDao(): FolderDao { - return instance().folderDao() - } - fun aiChatDao(): AiChatDao { - return instance().aiChatDao() - } -} - -private val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `notes-table` ADD COLUMN `created_at` INTEGER NOT NULL DEFAULT ${System.currentTimeMillis()}") - } -} - -private val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `notes-table` ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0") - } -} - -private val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `notes-table` ADD COLUMN `encrypted` INTEGER NOT NULL DEFAULT 0") - } -} - -private val MIGRATION_2_4 = object : Migration(2, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `notes-table` ADD COLUMN `pinned` INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE `notes-table` ADD COLUMN `encrypted` INTEGER NOT NULL DEFAULT 0") - } -} - -private val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE IF NOT EXISTS `Folder` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)") - db.execSQL("ALTER TABLE `notes-table` ADD COLUMN `foldered` INTEGER DEFAULT NULL") - } -} - -private val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE `Folder_new` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `iconName` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)") - db.execSQL("INSERT INTO `Folder_new` (`id`, `name`, `iconName`, `createdAt`) SELECT `id`, `name`, 'Folder', `createdAt` FROM `Folder`") - db.execSQL("DROP TABLE `Folder`") - db.execSQL("ALTER TABLE `Folder_new` RENAME TO `Folder`") - } -} - -private val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL(""" - CREATE TABLE IF NOT EXISTS `ai_chat_sessions` ( - `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - `title` TEXT NOT NULL, - `createdAt` INTEGER NOT NULL, - `aiMode` TEXT NOT NULL - ) - """.trimIndent()) - - database.execSQL(""" - CREATE TABLE IF NOT EXISTS `ai_chat_messages` ( - `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - `sessionId` INTEGER NOT NULL, - `text` TEXT NOT NULL, - `participant` TEXT NOT NULL, - `timestamp` INTEGER NOT NULL, - `isLoading` INTEGER NOT NULL, - FOREIGN KEY(`sessionId`) REFERENCES `ai_chat_sessions`(`id`) ON DELETE CASCADE - ) - """.trimIndent()) - } -} - -private val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `ai_chat_sessions` ADD COLUMN `serviceName` TEXT NOT NULL DEFAULT 'GEMINI'") - } -} - -private val MIGRATION_8_9 = object : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE `notes-table` ADD COLUMN `is_locked` INTEGER NOT NULL DEFAULT 0") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/provider/StringProvider.kt b/app/src/main/java/com/babelsoftware/airnote/data/provider/StringProvider.kt deleted file mode 100644 index a1579f7..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/provider/StringProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.babelsoftware.airnote.data.provider - -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class StringProvider @Inject constructor( - @ApplicationContext private val context: Context -) { - // Basit metinler için - fun getString(stringResId: Int): String { - return context.getString(stringResId) - } - - // Formatlanmış metinler için - fun getString(stringResId: Int, vararg formatArgs: Any): String { - return context.getString(stringResId, *formatArgs) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/repository/AiModels.kt b/app/src/main/java/com/babelsoftware/airnote/data/repository/AiModels.kt deleted file mode 100644 index 19c75f3..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/repository/AiModels.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.babelsoftware.airnote.data.repository - -import androidx.annotation.StringRes -import com.babelsoftware.airnote.R - -data class GeminiModelInfo( - val name: String, - @StringRes val displayNameResId: Int -) - -object GeminiModels { - val supportedModels = listOf( - GeminiModelInfo( - name = "gemini-2.5-flash", - displayNameResId = R.string.gemini_models_25 - ), - GeminiModelInfo( - name = "gemini-2.0-flash-001", - displayNameResId = R.string.gemini_models_20 - ), - GeminiModelInfo( - name = "gemini-1.5-flash-latest", - displayNameResId = R.string.gemini_models_15 - ) - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/repository/FolderRepositoryImpl.kt b/app/src/main/java/com/babelsoftware/airnote/data/repository/FolderRepositoryImpl.kt deleted file mode 100644 index e3cca8b..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/repository/FolderRepositoryImpl.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.data.repository - -import com.babelsoftware.airnote.data.source.FolderDao -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.repository.FolderRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class FolderRepositoryImpl @Inject constructor( - private val folderDao: FolderDao -) : FolderRepository { - override fun getAllFolders(): Flow> = folderDao.getAllFolders() - override suspend fun insertFolder(folder: Folder) = folderDao.insertFolder(folder) - override suspend fun updateFolder(folder: Folder) = folderDao.updateFolder(folder) - override suspend fun deleteFolder(folder: Folder) = folderDao.deleteFolder(folder) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/repository/GeminiRepository.kt b/app/src/main/java/com/babelsoftware/airnote/data/repository/GeminiRepository.kt deleted file mode 100644 index 9276b32..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/repository/GeminiRepository.kt +++ /dev/null @@ -1,845 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - * - * - */ - -package com.babelsoftware.airnote.data.repository - -import android.util.Log -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.provider.StringProvider -import com.babelsoftware.airnote.domain.model.ChatMessage -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.domain.model.Participant -import com.babelsoftware.airnote.domain.repository.SettingsRepository -import com.google.ai.client.generativeai.GenerativeModel -import com.google.ai.client.generativeai.type.Content -import com.google.ai.client.generativeai.type.Part -import com.google.ai.client.generativeai.type.TextPart -import com.google.ai.client.generativeai.type.content -import com.google.ai.client.generativeai.type.generationConfig -import com.google.gson.annotations.SerializedName -import com.google.mlkit.common.model.DownloadConditions -import com.google.mlkit.common.model.RemoteModelManager -import com.google.mlkit.nl.languageid.LanguageIdentification -import com.google.mlkit.nl.translate.TranslateLanguage -import com.google.mlkit.nl.translate.TranslateRemoteModel -import com.google.mlkit.nl.translate.Translation -import com.google.mlkit.nl.translate.TranslatorOptions -import io.ktor.client.HttpClient -import io.ktor.client.plugins.ClientRequestException -import io.ktor.client.request.header -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.HttpStatusCode -import io.ktor.utils.io.readUTF8Line -import jakarta.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.withContext -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -enum class AiMode { - NOTE_ASSISTANT, // For factual, note-taking tasks. - CREATIVE_MIND, // For brainstorming and creative writing. - ACADEMIC_RESEARCHER,// For structured, evidence-based academic writing. - PROFESSIONAL_STRATEGIST // For clear, goal-oriented business communication. -} - -enum class AiAction { - IMPROVE_WRITING, - SUMMARIZE, - MAKE_SHORTER, - MAKE_LONGER, - CHANGE_TONE, - TRANSLATE -} - -enum class AiTone { - FORMAL, - BALANCED, - FRIENDLY -} - -enum class AiAssistantAction { - GIVE_IDEA, - CONTINUE_WRITING, - CHANGE_PERSPECTIVE, - PROS_AND_CONS, - CREATE_TODO_LIST, - SIMPLIFY, - SUGGEST_A_TITLE -} - -data class AiActionPlan( - @SerializedName("thought") - val thought: String?, - - @SerializedName("actions") - val actions: List, - - @SerializedName("response_message") - val response_message: String -) - -data class AiActionCommand( - @SerializedName("action_type") - val action_type: String, - @SerializedName("title") - val title: String?, - @SerializedName("content") - val content: String?, - @SerializedName("tasks") - val tasks: List?, - @SerializedName("name") - val name: String?, - @SerializedName("iconName") - val iconName: String?, - @SerializedName("note_title") - val note_title: String?, - @SerializedName("folder_name") - val folder_name: String?, - @SerializedName("response") - val response: String?, - @SerializedName("new_content") - val new_content: String?, - @SerializedName("search_term") - val search_term: String? -) - -@Serializable -private data class PplxMessage( - @SerialName("role") val role: String, - @SerialName("content") val content: String -) - -@Serializable -private data class PplxRequest( - @SerialName("model") val model: String, - @SerialName("stream") val stream: Boolean, - @SerialName("messages") val messages: List -) - -@Serializable -private data class PplxSource( - @SerialName("title") val title: String, - @SerialName("url") val url: String -) - -@Serializable -private data class PplxDelta( - @SerialName("content") val content: String? = null -) - -@Serializable -private data class PplxChoice( - @SerialName("delta") val delta: PplxDelta? = null, - @SerialName("sources") val sources: List? = null -) - -@Serializable -private data class PplxStreamResponse( - @SerialName("choices") val choices: List -) - -class GeminiRepository @Inject constructor( - private val settingsRepository: SettingsRepository, - private val stringProvider: StringProvider, - private val httpClient: HttpClient, - private val json: Json -) { - private companion object { - const val DEFAULT_TEMPERATURE = 0.7f - const val CREATIVE_TEMPERATURE = 0.9f - const val PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions" - } - class ApiKeyMissingException(message: String) : Exception(message) - - suspend fun validateApiKey(apiKey: String, modelName: String): Result = withContext(Dispatchers.IO) { - if (apiKey.isBlank()) { - return@withContext Result.failure(ApiKeyMissingException("API key cannot be empty.")) - } - return@withContext try { - GenerativeModel(modelName = modelName, apiKey = apiKey).countTokens("test") - Result.success(Unit) - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - - suspend fun validatePerplexityApiKey(apiKey: String): Result = withContext(Dispatchers.IO) { - if (apiKey.isBlank()) { - return@withContext Result.failure(ApiKeyMissingException("The Perplexity API key cannot be empty.")) - } - - val settings = settingsRepository.settings.first() - val modelToValidate = settings.selectedPerplexityModelName - - try { - val testRequest = PplxRequest( - model = modelToValidate, - stream = false, - messages = listOf(PplxMessage(role = "user", content = "Hello")) - ) - - val response = httpClient.post(PERPLEXITY_API_URL) { - header("Authorization", "Bearer $apiKey") - setBody(testRequest) - } - - return@withContext if (response.status == HttpStatusCode.OK) { - Result.success(Unit) - } else { - Result.failure(Exception("Perplexity API'sine bağlanılamadı. Durum Kodu: ${response.status.value}")) - } - } catch (e: ClientRequestException) { - if (e.response.status == HttpStatusCode.Unauthorized) { - Result.failure(Exception("Perplexity API anahtarı geçersiz veya izni yok.")) - } else { - Result.failure(e) - } - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - - - suspend fun processAiAction(text: String, action: AiAction, tone: AiTone? = null, apiKey: String, aiMode: AiMode = AiMode.NOTE_ASSISTANT): Result { - if (apiKey.isBlank()) { - return Result.failure(ApiKeyMissingException(stringProvider.getString(R.string.error_no_user_api_key))) - } - if (action == AiAction.CHANGE_TONE) { - require(tone != null) { "CHANGE_TONE eylemi için bir ton belirtilmelidir." } - } - - val modelName = settingsRepository.settings.first().selectedModelName - val generativeModel: GenerativeModel = try { - GenerativeModel( - modelName = modelName, - apiKey = apiKey, - generationConfig = generationConfig { - this.temperature = if (aiMode == AiMode.CREATIVE_MIND) CREATIVE_TEMPERATURE else DEFAULT_TEMPERATURE - } - ) - } catch (e: Exception) { - e.printStackTrace() - return Result.failure(e) - } - - val systemPrompt = when (aiMode) { - AiMode.NOTE_ASSISTANT -> stringProvider.getString(R.string.system_prompt_note_assistant) - AiMode.CREATIVE_MIND -> stringProvider.getString(R.string.system_prompt_creative_mind) - AiMode.ACADEMIC_RESEARCHER -> stringProvider.getString(R.string.system_prompt_academic_researcher) - AiMode.PROFESSIONAL_STRATEGIST -> stringProvider.getString(R.string.system_prompt_professional_strategist) - } - - val userPrompt = when (action) { - AiAction.IMPROVE_WRITING -> stringProvider.getString(R.string.prompt_improve_writing, text) - AiAction.SUMMARIZE -> stringProvider.getString(R.string.prompt_summarize, text) - AiAction.MAKE_SHORTER -> stringProvider.getString(R.string.prompt_make_shorter, text) - AiAction.MAKE_LONGER -> stringProvider.getString(R.string.prompt_make_longer, text) - AiAction.CHANGE_TONE -> { - val tonePromptResId = when (tone) { - AiTone.FORMAL -> R.string.prompt_instruction_tone_formal - AiTone.BALANCED -> R.string.prompt_instruction_tone_balanced - AiTone.FRIENDLY -> R.string.prompt_instruction_tone_friendly - null -> throw IllegalArgumentException("Tone cannot be null for CHANGE_TONE action") - } - stringProvider.getString(R.string.prompt_change_tone_template, stringProvider.getString(tonePromptResId), text) - } - AiAction.TRANSLATE -> "" // This action is handled on-device - } - - val finalPrompt = "$systemPrompt\n\n---\n\n$userPrompt" - - return try { - val response = generativeModel.generateContent(finalPrompt) - response.text?.let { Result.success(it) } ?: Result.failure(Exception("API'den boş yanıt alındı.")) - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - - fun generatePerplexityChatResponse( - history: List, - apiKey: String - ): Flow = flow { - if (apiKey.isBlank()) { - throw ApiKeyMissingException("Perplexity API anahtarı ayarlanmamış.") - } - - val settings = settingsRepository.settings.first() - val modelToUse = settings.selectedPerplexityModelName - - val messages = mutableListOf() - messages.add(PplxMessage(role = "system", content = "Be helpful and concise.")) - history.filter { !it.isLoading && it.participant != Participant.ERROR }.forEach { msg -> - messages.add(PplxMessage( - role = if (msg.participant == Participant.USER) "user" else "assistant", - content = msg.text - )) - } - - val requestBody = PplxRequest( - model = modelToUse, - stream = true, - messages = messages - ) - - val sourcesSet = mutableSetOf() - val responseTextBuilder = StringBuilder() - - try { - val response = httpClient.post(PERPLEXITY_API_URL) { - header("Authorization", "Bearer $apiKey") - setBody(requestBody) - } - - val channel = response.bodyAsChannel() - - while (!channel.isClosedForRead) { - val line = channel.readUTF8Line() - if (line.isNullOrBlank()) continue - - if (line.startsWith("data:")) { - val jsonData = line.substring(5).trim() - if (jsonData == "[DONE]") { - break - } - - try { - val streamResponse = json.decodeFromString(jsonData) - - val choice = streamResponse.choices.firstOrNull() - if (choice != null) { - val content = choice.delta?.content - if (content != null) { - responseTextBuilder.append(content) - emit(content) - } - - val sources = choice.sources - if (!sources.isNullOrEmpty()) { - sourcesSet.addAll(sources) - } - } - } catch (e: Exception) { - Log.w("PerplexityParseError", "JSON ayrıştırma hatası: $jsonData", e) - } - } - } - - if (sourcesSet.isNotEmpty()) { - val sourcesString = buildString { - append("\n\n\n**Sources:**") - sourcesSet.forEachIndexed { index, source -> - append("\n[${index + 1}] ${source.title} (${source.url})") - } - } - emit(sourcesString) - } - - } catch (e: Exception) { - Log.e("PerplexityRequestError", "Perplexity isteği başarısız", e) - throw e - } - - }.catch { - Log.e("PerplexityFlowError", "Akış hatası", it) - emit(stringProvider.getString(R.string.error_api_request_failed, it.message ?: "Bilinmeyen hata")) - }.flowOn(Dispatchers.IO) - - - fun processAssistantAction( - noteName: String, - noteDescription: String, - action: AiAssistantAction, - apiKey: String, - aiMode: AiMode = AiMode.CREATIVE_MIND - ): Flow = flow { - if (apiKey.isBlank()) { - throw ApiKeyMissingException(stringProvider.getString(R.string.error_no_user_api_key)) - } - - val modelName = settingsRepository.settings.first().selectedModelName - val generativeModel = GenerativeModel( - modelName = modelName, - apiKey = apiKey, - generationConfig = generationConfig { - this.temperature = if (aiMode == AiMode.CREATIVE_MIND) CREATIVE_TEMPERATURE else DEFAULT_TEMPERATURE - } - ) - - val systemPrompt = when (aiMode) { - AiMode.NOTE_ASSISTANT -> stringProvider.getString(R.string.system_prompt_note_assistant) - AiMode.CREATIVE_MIND -> stringProvider.getString(R.string.system_prompt_creative_mind) - AiMode.ACADEMIC_RESEARCHER -> stringProvider.getString(R.string.system_prompt_academic_researcher) - AiMode.PROFESSIONAL_STRATEGIST -> stringProvider.getString(R.string.system_prompt_professional_strategist) - } - - val userPrompt = when (action) { - AiAssistantAction.GIVE_IDEA -> stringProvider.getString(R.string.prompt_assistant_give_idea, noteName) - AiAssistantAction.CONTINUE_WRITING -> stringProvider.getString(R.string.prompt_assistant_continue_writing, noteName, noteDescription) - AiAssistantAction.CHANGE_PERSPECTIVE -> stringProvider.getString(R.string.prompt_assistant_change_perspective, noteName, noteDescription) - AiAssistantAction.PROS_AND_CONS -> stringProvider.getString(R.string.prompt_assistant_pros_and_cons, noteDescription) - AiAssistantAction.CREATE_TODO_LIST -> stringProvider.getString(R.string.prompt_assistant_create_todo, noteDescription) - AiAssistantAction.SIMPLIFY -> stringProvider.getString(R.string.prompt_assistant_simplify, noteDescription) - AiAssistantAction.SUGGEST_A_TITLE -> stringProvider.getString(R.string.prompt_assistant_suggest_title, noteDescription) - } - - val finalPrompt = "$systemPrompt\n\n---\n\n$userPrompt" - - generativeModel.generateContentStream(finalPrompt).collect { chunk -> - emit(chunk.text ?: "") - } - }.catch { - emit(stringProvider.getString(R.string.error_api_request_failed, it.message ?: "Unknown error")) - } - - fun generateChatResponse( - history: List, - apiKey: String, - aiMode: AiMode = AiMode.NOTE_ASSISTANT, - mentionedNote: com.babelsoftware.airnote.domain.model.Note? = null, - attachment: Part? = null - ): Flow = flow { - if (apiKey.isBlank()) { - throw ApiKeyMissingException(stringProvider.getString(R.string.error_no_user_api_key)) - } - - val modelName = settingsRepository.settings.first().selectedModelName - val generativeModel = GenerativeModel( - modelName = modelName, - apiKey = apiKey, - generationConfig = generationConfig { - this.temperature = if (aiMode == AiMode.CREATIVE_MIND) CREATIVE_TEMPERATURE else DEFAULT_TEMPERATURE - } - ) - - val baseSystemPrompt = when (aiMode) { - AiMode.NOTE_ASSISTANT -> stringProvider.getString(R.string.system_prompt_note_assistant) - AiMode.CREATIVE_MIND -> stringProvider.getString(R.string.system_prompt_creative_mind) - AiMode.ACADEMIC_RESEARCHER -> stringProvider.getString(R.string.system_prompt_academic_researcher) - AiMode.PROFESSIONAL_STRATEGIST -> stringProvider.getString(R.string.system_prompt_professional_strategist) - } - - val chatHistoryForModel = mutableListOf() - chatHistoryForModel.add(content("user") { text(baseSystemPrompt) }) - chatHistoryForModel.add(content("model") { text("OK.") }) - - if (mentionedNote != null) { - val noteContextPrompt = stringProvider.getString( - R.string.prompt_mention_context, - mentionedNote.name, - mentionedNote.description - ) - chatHistoryForModel.add(content("user") { text(noteContextPrompt) }) - val ackPrompt = stringProvider.getString(R.string.prompt_mention_ack, mentionedNote.name) - chatHistoryForModel.add(content("model") { text(ackPrompt) }) - } - - val userHistoryContent = history - .filter { it.participant != Participant.ERROR && !it.isLoading } - .map { msg -> - content(role = if (msg.participant == Participant.USER) "user" else "model") { - text(msg.text) - } - } - chatHistoryForModel.addAll(userHistoryContent) - - - val lastMessageContent = chatHistoryForModel.lastOrNull() ?: return@flow - val historyWithoutLast = chatHistoryForModel.dropLast(1) - val chat = generativeModel.startChat(history = historyWithoutLast) - - val finalMessageToSend: Content - if (attachment != null) { - val lastUserText = (lastMessageContent.parts.firstOrNull() as? TextPart)?.text ?: "" - - finalMessageToSend = content(lastMessageContent.role) { - part(attachment) - text(lastUserText) - } - } else { - finalMessageToSend = lastMessageContent - } - - chat.sendMessageStream(finalMessageToSend).collect { chunk -> - emit(chunk.text ?: "") - } - }.catch { - emit(stringProvider.getString(R.string.error_api_request_failed, it.message ?: "Unknown error")) - } - - suspend fun generateDraft(topic: String, apiKey: String, aiMode: AiMode = AiMode.NOTE_ASSISTANT): Result { - if (apiKey.isBlank()) { - return Result.failure(ApiKeyMissingException(stringProvider.getString(R.string.error_no_user_api_key))) - } - - val modelName = settingsRepository.settings.first().selectedModelName - val generativeModel: GenerativeModel = try { - GenerativeModel( - modelName = modelName, - apiKey = apiKey, - generationConfig = generationConfig { - this.temperature = if (aiMode == AiMode.CREATIVE_MIND) CREATIVE_TEMPERATURE else DEFAULT_TEMPERATURE - } - ) - } catch (e: Exception) { - e.printStackTrace() - return Result.failure(e) - } - - val systemPrompt = when (aiMode) { - AiMode.NOTE_ASSISTANT -> stringProvider.getString(R.string.system_prompt_note_assistant) - AiMode.CREATIVE_MIND -> stringProvider.getString(R.string.system_prompt_creative_mind) - AiMode.ACADEMIC_RESEARCHER -> stringProvider.getString(R.string.system_prompt_academic_researcher) - AiMode.PROFESSIONAL_STRATEGIST -> stringProvider.getString(R.string.system_prompt_professional_strategist) - } - val userPrompt = stringProvider.getString(R.string.prompt_assistant_draft_anything, topic) - val finalPrompt = "$systemPrompt\n\n---\n\n$userPrompt" - - return try { - val response = generativeModel.generateContent(finalPrompt) - response.text?.let { Result.success(it) } ?: Result.failure(Exception("API'den boş yanıt alındı.")) - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - - suspend fun generateDraftFromAttachment( - prompt: String, - attachment: Part, - apiKey: String, - aiMode: AiMode - ): Result { - if (apiKey.isBlank()) { - return Result.failure(ApiKeyMissingException(stringProvider.getString(R.string.error_no_user_api_key))) - } - - val generativeModel: GenerativeModel = try { - val modelName = settingsRepository.settings.first().selectedModelName - val temperature = if (aiMode == AiMode.CREATIVE_MIND) CREATIVE_TEMPERATURE else DEFAULT_TEMPERATURE - - GenerativeModel( - modelName = modelName, - apiKey = apiKey, - generationConfig = generationConfig { - this.temperature = temperature - } - ) - } catch (e: Exception) { - e.printStackTrace() - return Result.failure(e) - } - - val systemPrompt = when (aiMode) { - AiMode.NOTE_ASSISTANT -> stringProvider.getString(R.string.system_prompt_note_assistant) - AiMode.CREATIVE_MIND -> stringProvider.getString(R.string.system_prompt_creative_mind) - AiMode.ACADEMIC_RESEARCHER -> stringProvider.getString(R.string.system_prompt_academic_researcher) - AiMode.PROFESSIONAL_STRATEGIST -> stringProvider.getString(R.string.system_prompt_professional_strategist) - } - - val userPrompt = """ - Kullanıcı prompt'u: "$prompt" - - Ekli dosyayı/görseli analiz et ve bu prompt'a göre bir not taslağı oluştur. - Cevabını MUTLAKA şu formatta ver: - TITLE: [buraya başlığı yaz] - - CONTENT: [buraya not içeriğini yaz] - """.trimIndent() - - val finalPrompt = "$systemPrompt\n\n---\n\n$userPrompt" - - val inputContent = content { - part(attachment) - text(finalPrompt) - } - - return try { - val response = generativeModel.generateContent(inputContent) - response.text?.let { Result.success(it) } ?: Result.failure(Exception("API'den boş yanıt alındı.")) - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - - suspend fun generateChatOrCommandResponse( - noteContext: String, - userRequest: String, - chatHistory: List, - apiKey: String - ): Result = withContext(Dispatchers.IO) { - if (apiKey.isBlank()) { - return@withContext Result.failure(ApiKeyMissingException(stringProvider.getString(R.string.error_no_user_api_key))) - } - - try { - val modelName = settingsRepository.settings.first().selectedModelName - val generativeModel = GenerativeModel( - modelName = modelName, - apiKey = apiKey, - generationConfig = generationConfig { - temperature = 0.4f - } - ) - - val systemPrompt = buildString { - append(stringProvider.getString(R.string.system_prompt_command_chat_header)) - append("\n\n") - append(stringProvider.getString(R.string.system_prompt_intent_chat_desc)) - append("\n\n") - append(stringProvider.getString(R.string.system_prompt_intent_edit_desc)) - append("\n\n") - append(stringProvider.getString(R.string.system_prompt_json_instruction)) - } - - val historyForModel = mutableListOf() - val initialContextPrompt = buildString { - append(systemPrompt) - append("\n\n") - append(stringProvider.getString(R.string.system_prompt_context_header, noteContext)) - } - historyForModel.add(content("user") { text(initialContextPrompt) }) - historyForModel.add(content("model") { text("OK. I am ready. Waiting for user request.") }) - - val previousMessages = chatHistory - .filter { !it.isLoading && it.participant != Participant.ERROR } - .map { msg -> - content(role = if (msg.participant == Participant.USER) "user" else "model") { - text(msg.text) - } - } - historyForModel.addAll(previousMessages) - - val finalRequestPrompt = stringProvider.getString(R.string.system_prompt_request_header, userRequest) - val chat = generativeModel.startChat(history = historyForModel) - val response = chat.sendMessage(finalRequestPrompt) - val responseText = response.text - - if (responseText.isNullOrBlank()) { - Result.failure(Exception("API'den boş yanıt alındı.")) - } else { - val cleanJson = responseText.trim() - .removePrefix("```json") - .removePrefix("```") - .removeSuffix("```") - .trim() - - Result.success(cleanJson) - } - - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - - suspend fun generateActionPlan( - userRequest: String, - chatHistory: List, - apiKey: String, - aiMode: AiMode, - mentionedNote: Note? - ): Result = withContext(Dispatchers.IO) { - - if (apiKey.isBlank()) { - return@withContext Result.failure(ApiKeyMissingException(stringProvider.getString(R.string.error_no_user_api_key))) - } - - try { - val modelName = settingsRepository.settings.first().selectedModelName - val generativeModel = GenerativeModel( - modelName = modelName, - apiKey = apiKey, - generationConfig = generationConfig { - this.temperature = if (aiMode == AiMode.CREATIVE_MIND) 0.5f else 0.2f - } - ) - val systemPrompt = stringProvider.getString(R.string.system_prompt_automation_engine) - val historyForModel = mutableListOf() - historyForModel.add(content("user") { text(systemPrompt) }) - historyForModel.add(content("model") { text("OK. I am ready to generate JSON action plans.") }) - - if (mentionedNote != null) { - val noteContextPrompt = """ - --- CURRENT NOTE CONTEXT --- - Title: "${mentionedNote.name}" - Content: "${mentionedNote.description}" - """.trimIndent() - historyForModel.add(content("user") { text(noteContextPrompt) }) - historyForModel.add(content("model") { text("OK. Context for note '${mentionedNote.name}' has been loaded.") }) - } - - val previousMessages = chatHistory - .filter { !it.isLoading && it.participant != Participant.ERROR } - .map { msg -> - content(role = if (msg.participant == Participant.USER) "user" else "model") { - text(msg.text) - } - } - historyForModel.addAll(previousMessages) - - val chat = generativeModel.startChat(history = historyForModel) - val response = chat.sendMessage(userRequest) - val responseText = response.text - - if (responseText.isNullOrBlank()) { - Result.failure(Exception("AI'den boş yanıt alındı.")) - } else { - val cleanJson = responseText.trim() - .removePrefix("```json") - .removePrefix("```") - .removeSuffix("```") - .trim() - - Log.d("GeminiRepository", "Raw JSON Plan: $cleanJson") - Result.success(cleanJson) - } - - } catch (e: Exception) { - e.printStackTrace() - Result.failure(e) - } - } - val supportedLanguages = mapOf( - TranslateLanguage.ENGLISH to "English", - TranslateLanguage.TURKISH to "Türkçe", - TranslateLanguage.GERMAN to "Deutsch", - TranslateLanguage.FRENCH to "Français", - TranslateLanguage.SPANISH to "Español", - TranslateLanguage.ITALIAN to "Italiano", - TranslateLanguage.JAPANESE to "日本語", - TranslateLanguage.RUSSIAN to "Русский" - ) - - suspend fun getDownloadedModels(): Result> = withContext(Dispatchers.IO) { - suspendCoroutine { continuation -> - RemoteModelManager.getInstance().getDownloadedModels(TranslateRemoteModel::class.java) - .addOnSuccessListener { models -> - continuation.resume(Result.success(models.map { it.language }.toSet())) - } - .addOnFailureListener { - continuation.resume(Result.failure(it)) - } - } - } - - suspend fun downloadLanguageModel(languageCode: String): Result = withContext(Dispatchers.IO) { - suspendCoroutine { continuation -> - val model = TranslateRemoteModel.Builder(languageCode).build() - val conditions = DownloadConditions.Builder().requireWifi().build() - RemoteModelManager.getInstance().download(model, conditions) - .addOnSuccessListener { - continuation.resume(Result.success(Unit)) - } - .addOnFailureListener { - continuation.resume(Result.failure(it)) - } - } - } - suspend fun deleteLanguageModel(languageCode: String): Result = withContext(Dispatchers.IO) { - suspendCoroutine { continuation -> - val model = TranslateRemoteModel.Builder(languageCode).build() - RemoteModelManager.getInstance().deleteDownloadedModel(model) - .addOnSuccessListener { - continuation.resume(Result.success(Unit)) - } - .addOnFailureListener { - continuation.resume(Result.failure(it)) - } - } - } - - private suspend fun identifyLanguage(text: String): Result = suspendCoroutine { continuation -> - val languageIdentifier = LanguageIdentification.getClient() - languageIdentifier.identifyLanguage(text) - .addOnSuccessListener { languageCode -> - if (languageCode == "und") { - continuation.resume(Result.failure(Exception("The source language could not be identified."))) - } else { - continuation.resume(Result.success(languageCode)) - } - languageIdentifier.close() - } - .addOnFailureListener { - continuation.resume(Result.failure(it)) - languageIdentifier.close() - } - } - - suspend fun translateOnDevice(text: String, targetLanguage: String): Result = withContext(Dispatchers.IO) { - if (text.isBlank()) return@withContext Result.success("") - - val langIdResult = identifyLanguage(text) - if (langIdResult.isFailure) { - return@withContext Result.failure(langIdResult.exceptionOrNull()!!) - } - val sourceLanguageCode = langIdResult.getOrNull()!! - - if (sourceLanguageCode.equals(targetLanguage, ignoreCase = true)) { - return@withContext Result.failure(Exception("The note text is already in the language you want to translate.")) - } - - val options = TranslatorOptions.Builder() - .setSourceLanguage(sourceLanguageCode) - .setTargetLanguage(targetLanguage) - .build() - val translator = Translation.getClient(options) - - try { - val downloadResult = suspendCoroutine> { continuation -> - translator.downloadModelIfNeeded() - .addOnSuccessListener { continuation.resume(Result.success(Unit)) } - .addOnFailureListener { continuation.resume(Result.failure(it)) } - } - if (downloadResult.isFailure) { - throw downloadResult.exceptionOrNull()!! - } - - val originalLines = text.split('\n') - val translatedLines = mutableListOf() - - for (line in originalLines) { - if (line.isBlank()) { - translatedLines.add(line) - } else { - val translatedLineResult = suspendCoroutine> { continuation -> - translator.translate(line) - .addOnSuccessListener { translatedText -> continuation.resume(Result.success(translatedText)) } - .addOnFailureListener { exception -> continuation.resume(Result.failure(exception)) } - } - - if (translatedLineResult.isSuccess) { - translatedLines.add(translatedLineResult.getOrThrow()) - } else { - throw translatedLineResult.exceptionOrNull()!! - } - } - } - Result.success(translatedLines.joinToString("\n")) - - } catch (e: Exception) { - Log.e("TranslateError", "Satır satır çeviri sırasında hata oluştu", e) - Result.failure(e) - } finally { - translator.close() - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/data/repository/ImportExportRepository.kt b/app/src/main/java/com/babelsoftware/airnote/data/repository/ImportExportRepository.kt deleted file mode 100644 index 944c70d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/repository/ImportExportRepository.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.babelsoftware.airnote.data.repository - -import android.content.Context -import android.net.Uri -import android.provider.MediaStore -import androidx.core.database.getStringOrNull -import com.babelsoftware.airnote.constant.DatabaseConst -import com.babelsoftware.airnote.data.local.database.NoteDatabaseProvider -import com.babelsoftware.airnote.domain.model.Note -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExecutorCoroutineDispatcher -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException -import java.security.InvalidKeyException -import java.security.SecureRandom -import java.security.spec.KeySpec -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream -import javax.crypto.BadPaddingException -import javax.crypto.Cipher -import javax.crypto.IllegalBlockSizeException -import javax.crypto.SecretKey -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.SecretKeySpec - -sealed class BackupResult { - data class Success(val message: String) : BackupResult() - data class Error(val message: String) : BackupResult() - object BadPassword : BackupResult() -} - -class ImportExportRepository( - private val provider: NoteDatabaseProvider, - private val context: Context, - private val mutex: Mutex, - private val scope: CoroutineScope, - private val dispatcher: ExecutorCoroutineDispatcher, -) { - private val salt = ByteArray(16).apply { SecureRandom().nextBytes(this) } - - private fun generateSecretKey(password: String, salt: ByteArray): SecretKey { - val factory: SecretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - val spec: KeySpec = PBEKeySpec(password.toCharArray(), salt, 65536, 256) - return SecretKeySpec(factory.generateSecret(spec).encoded, "AES") - } - - private fun encrypt(data: ByteArray, secretKey: SecretKey): ByteArray { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - val iv = ByteArray(16).apply { SecureRandom().nextBytes(this) } - cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(iv)) - val encryptedData = cipher.doFinal(data) - return iv + encryptedData - } - - private fun decrypt(data: ByteArray, secretKey: SecretKey): ByteArray { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - val iv = data.copyOfRange(0, 16) - val encryptedData = data.copyOfRange(16, data.size) - cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv)) - return cipher.doFinal(encryptedData) - } - suspend fun exportBackup(uri: Uri, password: String?): BackupResult { - return try { - withContext(dispatcher + scope.coroutineContext) { - mutex.withLock { - provider.close() - - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - val databaseFile = context.getDatabasePath(DatabaseConst.NOTES_DATABASE_FILE_NAME) - val tempZipFile = File.createTempFile("backup", ".zip", context.cacheDir) - - ZipOutputStream(FileOutputStream(tempZipFile)).use { zipOutputStream -> - FileInputStream(databaseFile).use { inputStream -> - val zipEntry = ZipEntry(databaseFile.name) - zipOutputStream.putNextEntry(zipEntry) - inputStream.copyTo(zipOutputStream) - zipOutputStream.closeEntry() - } - } - - val zipData = tempZipFile.readBytes() - if (password != null) { - val secretKey = generateSecretKey(password, salt) - val encryptedData = encrypt(zipData, secretKey) - outputStream.write(salt + encryptedData) - } else { - outputStream.write(zipData) - } - tempZipFile.delete() - } - } - } - BackupResult.Success("Export successful") - } catch (e: Exception) { - BackupResult.Error("Export failed: ${e.message}") - } - } - - suspend fun importBackup(uri: Uri, password: String?): BackupResult { - return try { - withContext(dispatcher + scope.coroutineContext) { - mutex.withLock { - provider.close() - - context.contentResolver.openInputStream(uri)?.use { inputStream -> - val inputData = inputStream.readBytes() - val data: ByteArray - - if (password != null) { - val salt = inputData.copyOfRange(0, 16) - val encryptedData = inputData.copyOfRange(16, inputData.size) - val secretKey = generateSecretKey(password, salt) - data = decrypt(encryptedData, secretKey) - } else { - data = inputData - } - - val tempZipFile = File.createTempFile("backup", ".zip", context.cacheDir) - tempZipFile.writeBytes(data) - - ZipInputStream(FileInputStream(tempZipFile)).use { zipInputStream -> - var entry: ZipEntry? - while (zipInputStream.nextEntry.also { entry = it } != null) { - val dbFile = context.getDatabasePath(DatabaseConst.NOTES_DATABASE_FILE_NAME) - if (dbFile != null) { - dbFile.delete() - - dbFile.outputStream().use { outputStream -> - zipInputStream.copyTo(outputStream) - } - } else { - println("Database is not in backup") - } - zipInputStream.closeEntry() - println("Writing Backup Finished") - } - } - tempZipFile.delete() - } - } - } - BackupResult.Success("Import successful") - } catch (e: Exception) { - when (e) { - is BadPaddingException, - is IllegalBlockSizeException, - is InvalidKeyException -> BackupResult.BadPassword - else -> BackupResult.Error("Import failed: ${e.message}") - } - } - } - - fun importFile(uri: Uri): Note { - val content = context.contentResolver.openInputStream(uri)?.use { fileInputStream -> - fileInputStream.reader().buffered().readText() - } ?: throw IOException("ContentResolver couldn't open InputStream to import file.") - - val name = context.contentResolver.query(uri, arrayOf(MediaStore.Files.FileColumns.DISPLAY_NAME), null, null)?.use { cursor -> - if (cursor.moveToFirst()) cursor.getStringOrNull(0) else "" - } ?: "" - - return Note( - name = name, - description = content, - ) - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/data/repository/NoteRepository.kt b/app/src/main/java/com/babelsoftware/airnote/data/repository/NoteRepository.kt deleted file mode 100644 index 5312ba6..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/repository/NoteRepository.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.babelsoftware.airnote.data.repository - -import com.babelsoftware.airnote.data.local.database.NoteDatabaseProvider -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.domain.repository.NoteRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class NoteRepositoryImpl @Inject constructor( - private val provider: NoteDatabaseProvider -) : NoteRepository { - override fun getAllNotes(): Flow> { - return provider.noteDao().getAllNotes() - } - - override suspend fun addNote(note: Note) { - provider.noteDao().addNote(note) - } - - override suspend fun updateNote(note: Note) { - provider.noteDao().updateNote(note) - } - - override suspend fun deleteNote(note: Note) { - provider.noteDao().deleteNote(note) - } - - override fun getNoteById(id: Int): Flow { - return provider.noteDao().getNoteById(id) - } - - override fun getLastNoteId(): Long? { - return provider.noteDao().getLastNoteId() - } - - override suspend fun unlinkNotesFromFolder(folderId: Long) { - provider.noteDao().unlinkNotesFromFolder(folderId) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/repository/SecureStorageRepository.kt b/app/src/main/java/com/babelsoftware/airnote/data/repository/SecureStorageRepository.kt deleted file mode 100644 index 929bde5..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/repository/SecureStorageRepository.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.data.repository - -import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class SecureStorageRepository @Inject constructor( - @ApplicationContext private val context: Context -) { - companion object { - private const val PREFERENCE_FILE_NAME = "airnote_secure_prefs" - private const val KEY_USER_GEMINI_API = "user_gemini_api_key" - private const val KEY_PERPLEXITY_API = "perplexity_api_key" - } - - private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - - private val sharedPreferences = EncryptedSharedPreferences.create( - PREFERENCE_FILE_NAME, - masterKeyAlias, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - - fun saveUserApiKey(apiKey: String) { - with(sharedPreferences.edit()) { - putString(KEY_USER_GEMINI_API, apiKey) - commit() - } - } - - fun getUserApiKey(): String? { - return sharedPreferences.getString(KEY_USER_GEMINI_API, null) - } - - fun savePerplexityApiKey(apiKey: String) { - with(sharedPreferences.edit()) { - putString(KEY_PERPLEXITY_API, apiKey) - commit() - } - } - - fun getPerplexityApiKey(): String? { - return sharedPreferences.getString(KEY_PERPLEXITY_API, null) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/repository/SettingsRepository.kt b/app/src/main/java/com/babelsoftware/airnote/data/repository/SettingsRepository.kt deleted file mode 100644 index fd945f0..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/repository/SettingsRepository.kt +++ /dev/null @@ -1,201 +0,0 @@ -package com.babelsoftware.airnote.data.repository - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import com.babelsoftware.airnote.domain.model.Settings -import com.babelsoftware.airnote.domain.repository.SettingsRepository -import com.babelsoftware.airnote.widget.NotesWidgetReceiver -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map - - -private const val PREFERENCES_NAME = "settingsupdated" - -private val Context.dataStore: DataStore by preferencesDataStore(name = PREFERENCES_NAME) - -private object PreferencesKeys { - val DEFAULT_ROUTE_TYPE = stringPreferencesKey("default_route_type") - val PASSCODE = stringPreferencesKey("passcode") - val FINGERPRINT = booleanPreferencesKey("fingerprint") - val PATTERN = stringPreferencesKey("pattern") - val VIEW_MODE = booleanPreferencesKey("view_mode") - val AUTOMATIC_THEME = booleanPreferencesKey("automatic_theme") - val DARK_THEME = booleanPreferencesKey("dark_theme") - val DYNAMIC_THEME = booleanPreferencesKey("dynamic_theme") - val AMOLED_THEME = booleanPreferencesKey("amoled_theme") - val MINIMALISTIC_MODE = booleanPreferencesKey("minimalistic_mode") - val EXTREME_AMOLED_MODE = booleanPreferencesKey("extreme_amoled_mode") - val IS_MARKDOWN_ENABLED = booleanPreferencesKey("is_markdown_enabled") - val SCREEN_PROTECTION = booleanPreferencesKey("screen_protection") - val ENCRYPT_BACKUP = booleanPreferencesKey("encrypt_backup") - val SORT_DESCENDING = booleanPreferencesKey("sort_descending") - val VAULT_SETTING_ENABLED = booleanPreferencesKey("vault_setting_enabled") - val VAULT_ENABLED = booleanPreferencesKey("vault_enabled") - val EDIT_MODE = booleanPreferencesKey("edit_mode") - val GALLERY_SYNC = booleanPreferencesKey("gallery_sync") - val SHOW_ONLY_TITLE = booleanPreferencesKey("show_only_title") - val TERMS_OF_SERVICE = booleanPreferencesKey("terms_of_service") - val USE_MONO_SPACE_FONT = booleanPreferencesKey("use_mono_space_font") - val LOCK_IMMEDIATELY = booleanPreferencesKey("lock_immediately") - val CORNER_RADIUS = intPreferencesKey("corner_radius") - val DISABLE_SWIPE_IN_EDIT_MODE = booleanPreferencesKey("disable_swipe_in_edit_mode") - val MAKE_SEARCH_BAR_LONGER = booleanPreferencesKey("make_search_bar_longer") - val FONT_SIZE = intPreferencesKey("font_size") - val SHOW_FOLDER_INDICATOR = booleanPreferencesKey("show_folder_indicator") - val USE_AIRNOTE_API = booleanPreferencesKey("use_airnote_api") - val SELECTED_MODEL_NAME = stringPreferencesKey("selected_model_name") - val DESKTOP_MODE_ENABLED = booleanPreferencesKey("desktop_mode_enabled") - val DESKTOP_MODE_AI_ENABLED = booleanPreferencesKey("desktop_mode_ai_enabled") - val OPEN_TO_LAST_USED_FOLDER = booleanPreferencesKey("open_to_last_used_folder") - val LAST_USED_FOLDER_ID = longPreferencesKey("last_used_folder_id") -} - -class SettingsRepositoryImpl(private val context: Context) : SettingsRepository { - - override val settings: Flow = context.dataStore.data.map { preferences -> - Settings( - defaultRouteType = preferences[PreferencesKeys.DEFAULT_ROUTE_TYPE] ?: "Home", - passcode = preferences[PreferencesKeys.PASSCODE], - fingerprint = preferences[PreferencesKeys.FINGERPRINT] ?: false, - pattern = preferences[PreferencesKeys.PATTERN], - viewMode = preferences[PreferencesKeys.VIEW_MODE] ?: true, - automaticTheme = preferences[PreferencesKeys.AUTOMATIC_THEME] ?: true, - darkTheme = preferences[PreferencesKeys.DARK_THEME] ?: false, - dynamicTheme = preferences[PreferencesKeys.DYNAMIC_THEME] ?: false, - amoledTheme = preferences[PreferencesKeys.AMOLED_THEME] ?: false, - minimalisticMode = preferences[PreferencesKeys.MINIMALISTIC_MODE] ?: false, - extremeAmoledMode = preferences[PreferencesKeys.EXTREME_AMOLED_MODE] ?: false, - isMarkdownEnabled = preferences[PreferencesKeys.IS_MARKDOWN_ENABLED] ?: true, - screenProtection = preferences[PreferencesKeys.SCREEN_PROTECTION] ?: false, - encryptBackup = preferences[PreferencesKeys.ENCRYPT_BACKUP] ?: false, - sortDescending = preferences[PreferencesKeys.SORT_DESCENDING] ?: true, - vaultSettingEnabled = preferences[PreferencesKeys.VAULT_SETTING_ENABLED] ?: false, - vaultEnabled = preferences[PreferencesKeys.VAULT_ENABLED] ?: false, - editMode = preferences[PreferencesKeys.EDIT_MODE] ?: false, - gallerySync = preferences[PreferencesKeys.GALLERY_SYNC] ?: true, - showOnlyTitle = preferences[PreferencesKeys.SHOW_ONLY_TITLE] ?: false, - termsOfService = preferences[PreferencesKeys.TERMS_OF_SERVICE] ?: false, - useMonoSpaceFont = preferences[PreferencesKeys.USE_MONO_SPACE_FONT] ?: false, - lockImmediately = preferences[PreferencesKeys.LOCK_IMMEDIATELY] ?: true, - cornerRadius = preferences[PreferencesKeys.CORNER_RADIUS] ?: 28, - disableSwipeInEditMode = preferences[PreferencesKeys.DISABLE_SWIPE_IN_EDIT_MODE] ?: false, - makeSearchBarLonger = preferences[PreferencesKeys.MAKE_SEARCH_BAR_LONGER] ?: false, - fontSize = preferences[PreferencesKeys.FONT_SIZE] ?: 13, - showFolderIndicator = preferences[PreferencesKeys.SHOW_FOLDER_INDICATOR] ?: false, - useAirNoteApi = preferences[PreferencesKeys.USE_AIRNOTE_API] ?: false, - selectedModelName = preferences[PreferencesKeys.SELECTED_MODEL_NAME] ?: "gemini-2.0-flash-001", - desktopModeEnabled = preferences[PreferencesKeys.DESKTOP_MODE_ENABLED] ?: true, - desktopModeAiEnabled = preferences[PreferencesKeys.DESKTOP_MODE_AI_ENABLED] ?: true, - openToLastUsedFolder = preferences[PreferencesKeys.OPEN_TO_LAST_USED_FOLDER] ?: false, - lastUsedFolderId = preferences[PreferencesKeys.LAST_USED_FOLDER_ID] - ) - } - - override suspend fun update(settings: Settings) { - context.dataStore.edit { preferences -> - preferences[PreferencesKeys.DEFAULT_ROUTE_TYPE] = settings.defaultRouteType - settings.passcode?.let { preferences[PreferencesKeys.PASSCODE] = it } ?: preferences.remove(PreferencesKeys.PASSCODE) - preferences[PreferencesKeys.FINGERPRINT] = settings.fingerprint - settings.pattern?.let { preferences[PreferencesKeys.PATTERN] = it } ?: preferences.remove(PreferencesKeys.PATTERN) - preferences[PreferencesKeys.VIEW_MODE] = settings.viewMode - preferences[PreferencesKeys.AUTOMATIC_THEME] = settings.automaticTheme - preferences[PreferencesKeys.DARK_THEME] = settings.darkTheme - preferences[PreferencesKeys.DYNAMIC_THEME] = settings.dynamicTheme - preferences[PreferencesKeys.AMOLED_THEME] = settings.amoledTheme - preferences[PreferencesKeys.MINIMALISTIC_MODE] = settings.minimalisticMode - preferences[PreferencesKeys.EXTREME_AMOLED_MODE] = settings.extremeAmoledMode - preferences[PreferencesKeys.IS_MARKDOWN_ENABLED] = settings.isMarkdownEnabled - preferences[PreferencesKeys.SCREEN_PROTECTION] = settings.screenProtection - preferences[PreferencesKeys.ENCRYPT_BACKUP] = settings.encryptBackup - preferences[PreferencesKeys.SORT_DESCENDING] = settings.sortDescending - preferences[PreferencesKeys.VAULT_SETTING_ENABLED] = settings.vaultSettingEnabled - preferences[PreferencesKeys.VAULT_ENABLED] = settings.vaultEnabled - preferences[PreferencesKeys.EDIT_MODE] = settings.editMode - preferences[PreferencesKeys.GALLERY_SYNC] = settings.gallerySync - preferences[PreferencesKeys.SHOW_ONLY_TITLE] = settings.showOnlyTitle - preferences[PreferencesKeys.TERMS_OF_SERVICE] = settings.termsOfService - preferences[PreferencesKeys.USE_MONO_SPACE_FONT] = settings.useMonoSpaceFont - preferences[PreferencesKeys.LOCK_IMMEDIATELY] = settings.lockImmediately - preferences[PreferencesKeys.CORNER_RADIUS] = settings.cornerRadius - preferences[PreferencesKeys.DISABLE_SWIPE_IN_EDIT_MODE] = settings.disableSwipeInEditMode - preferences[PreferencesKeys.MAKE_SEARCH_BAR_LONGER] = settings.makeSearchBarLonger - preferences[PreferencesKeys.FONT_SIZE] = settings.fontSize - preferences[PreferencesKeys.SHOW_FOLDER_INDICATOR] = settings.showFolderIndicator - preferences[PreferencesKeys.USE_AIRNOTE_API] = settings.useAirNoteApi - preferences[PreferencesKeys.SELECTED_MODEL_NAME] = settings.selectedModelName - preferences[PreferencesKeys.DESKTOP_MODE_ENABLED] = settings.desktopModeEnabled - preferences[PreferencesKeys.DESKTOP_MODE_AI_ENABLED] = settings.desktopModeAiEnabled - preferences[PreferencesKeys.OPEN_TO_LAST_USED_FOLDER] = settings.openToLastUsedFolder - settings.lastUsedFolderId?.let { preferences[PreferencesKeys.LAST_USED_FOLDER_ID] = it } ?: preferences.remove(PreferencesKeys.LAST_USED_FOLDER_ID) - } - } - - override suspend fun getPreferences(): Preferences { - return context.dataStore.data.first() - } - - override suspend fun putString(key: String, value: String) { - val preferencesKey = stringPreferencesKey(key) - context.dataStore.edit { preferences -> - preferences[preferencesKey] = value - } - } - - override suspend fun putInt(key: String, value: Int) { - val preferencesKey = intPreferencesKey(key) - context.dataStore.edit { preferences -> - preferences[preferencesKey] = value - } - } - - override suspend fun getString(key: String): String? { - val preferencesKey = stringPreferencesKey(key) - val preferences = context.dataStore.data.first() - return preferences[preferencesKey] - } - - override suspend fun getInt(key: String): Int? { - val preferencesKey = intPreferencesKey(key) - val preferences = context.dataStore.data.first() - return preferences[preferencesKey] - } - - override suspend fun putBoolean(key: String, value: Boolean) { - val preferencesKey = booleanPreferencesKey(key) - context.dataStore.edit { preferences -> - preferences[preferencesKey] = value - } - } - - override suspend fun getBoolean(key: String): Boolean? { - val preferencesKey = booleanPreferencesKey(key) - val preferences = context.dataStore.data.first() - return preferences[preferencesKey] - } - - override suspend fun getEveryNotesWidget(): List> { - val preferences = context.dataStore.data.first() - val widgetPairs = mutableListOf>() - - preferences.asMap().forEach { entry -> - val key = entry.key.name - - if (entry.key.name.startsWith(NotesWidgetReceiver.Companion.WIDGET_PREFERENCE)) { - val widgetId = key.substringAfter(NotesWidgetReceiver.Companion.WIDGET_PREFERENCE).toIntOrNull() - if (widgetId != null) { - val value = entry.value as? Int ?: 0 - widgetPairs.add(widgetId to value) - } - } - } - return widgetPairs - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/data/source/FolderDao.kt b/app/src/main/java/com/babelsoftware/airnote/data/source/FolderDao.kt deleted file mode 100644 index 62e5824..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/data/source/FolderDao.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.data.source - -import androidx.room.* -import com.babelsoftware.airnote.domain.model.Folder -import kotlinx.coroutines.flow.Flow - -@Dao -interface FolderDao { - @Query("SELECT * FROM folder ORDER BY createdAt DESC") - fun getAllFolders(): Flow> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertFolder(folder: Folder) - - @Update - suspend fun updateFolder(folder: Folder) - - @Delete - suspend fun deleteFolder(folder: Folder) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/di/DataModule.kt b/app/src/main/java/com/babelsoftware/airnote/di/DataModule.kt deleted file mode 100644 index e5bf9af..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/di/DataModule.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.babelsoftware.airnote.di - -import android.app.Application -import android.content.Context -import android.os.Handler -import android.os.Looper -import com.babelsoftware.airnote.data.local.dao.AiChatDao -import com.babelsoftware.airnote.data.local.database.NoteDatabase -import com.babelsoftware.airnote.data.local.database.NoteDatabaseProvider -import com.babelsoftware.airnote.data.repository.FolderRepositoryImpl -import com.babelsoftware.airnote.data.repository.ImportExportRepository -import com.babelsoftware.airnote.data.repository.NoteRepositoryImpl -import com.babelsoftware.airnote.data.repository.SettingsRepositoryImpl -import com.babelsoftware.airnote.data.source.FolderDao -import com.babelsoftware.airnote.domain.repository.FolderRepository -import com.babelsoftware.airnote.domain.repository.NoteRepository -import com.babelsoftware.airnote.domain.repository.SettingsRepository -import com.babelsoftware.airnote.domain.usecase.FolderUseCase -import com.babelsoftware.airnote.presentation.components.EncryptionHelper -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExecutorCoroutineDispatcher -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.sync.Mutex -import java.util.concurrent.Executors -import javax.inject.Qualifier -import javax.inject.Singleton - -@Qualifier -annotation class WidgetCoroutineScope - -@Module -@InstallIn(SingletonComponent::class) -object ApplicationModule { - - @Provides - @Singleton - @WidgetCoroutineScope - fun providesWidgetCoroutineScope(): CoroutineScope = CoroutineScope( - Executors.newSingleThreadExecutor().asCoroutineDispatcher(), - ) - - @Provides - @Singleton - fun provideNoteDatabaseProvider(application: Application): NoteDatabaseProvider = - NoteDatabaseProvider(application) - - @Provides - @Singleton - fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - - @Provides - fun provideMutex(): Mutex = Mutex() - - @Provides - @Singleton - fun provideExecutorCoroutineDispatcher(): ExecutorCoroutineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - - @Provides - @Singleton - fun provideNoteRepository(noteDatabaseProvider: NoteDatabaseProvider): NoteRepository { - return NoteRepositoryImpl(noteDatabaseProvider) - } - - @Provides - @Singleton - fun provideSettingsRepository(@ApplicationContext context: Context): SettingsRepository { - return SettingsRepositoryImpl(context) - } - - @Provides - @Singleton - fun provideBackupRepository( - noteDatabaseProvider: NoteDatabaseProvider, - application: Application, - mutex: Mutex, - coroutineScope: CoroutineScope, - executorCoroutineDispatcher: ExecutorCoroutineDispatcher, - ): ImportExportRepository { - return ImportExportRepository( - provider = noteDatabaseProvider, - context = application, - mutex = mutex, - scope = coroutineScope, - dispatcher = executorCoroutineDispatcher - ) - } - - @Provides - @Singleton - fun provideMutableVaultPassword(): StringBuilder { - return StringBuilder() - } - - @Provides - @Singleton - fun provideEncryptionHelper(mutableVaultPassword: StringBuilder): EncryptionHelper { - return EncryptionHelper(mutableVaultPassword) - } - - @Provides - fun provideHandler(): Handler { - return Handler(Looper.getMainLooper()) - } - - @Provides - @Singleton - fun provideFolderDao(noteDatabaseProvider: NoteDatabaseProvider): FolderDao { - return noteDatabaseProvider.folderDao() - } - - @Provides - @Singleton - fun provideFolderRepository(folderDao: FolderDao): FolderRepository { - return FolderRepositoryImpl(folderDao) - } - - @Provides - @Singleton - fun provideFolderUseCase( - folderRepository: FolderRepository, - noteRepository: NoteRepository - ): FolderUseCase { - return FolderUseCase(folderRepository, noteRepository) - } - - @Provides - @Singleton - fun provideAiChatDao(noteDatabaseProvider: NoteDatabaseProvider): AiChatDao { - return noteDatabaseProvider.aiChatDao() - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/di/NetworkModule.kt b/app/src/main/java/com/babelsoftware/airnote/di/NetworkModule.kt deleted file mode 100644 index 75c5b06..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/di/NetworkModule.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.babelsoftware.airnote.di // Kendi paket yapınıza göre düzenleyin - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import io.ktor.client.HttpClient -import io.ktor.client.engine.android.Android -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logging -import io.ktor.client.request.accept -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object NetworkModule { - - @Provides - @Singleton - fun provideKotlinxJson(): Json { - return Json { - ignoreUnknownKeys = true - isLenient = true - } - } - - @Provides - @Singleton - fun provideHttpClient(json: Json): HttpClient { - return HttpClient(Android) { - install(ContentNegotiation) { - json(json) - } - - install(Logging) { - level = LogLevel.BODY - } - - defaultRequest { - contentType(ContentType.Application.Json) - accept(ContentType.Application.Json) - accept(ContentType.Text.EventStream) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatMessage.kt b/app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatMessage.kt deleted file mode 100644 index 8d72539..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatMessage.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.babelsoftware.airnote.domain.model - -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey -import java.util.Date - -@Entity(tableName = "ai_chat_messages", - foreignKeys = [ - ForeignKey( - entity = AiChatSession::class, - parentColumns = ["id"], - childColumns = ["sessionId"], - onDelete = ForeignKey.CASCADE - ) - ], - indices = [Index(value = ["sessionId"])] -) -data class AiChatMessage( - @PrimaryKey(autoGenerate = true) val id: Long = 0, - val sessionId: Long, - val text: String, - val participant: Participant, - val timestamp: Date = Date(), - val isLoading: Boolean = false -) diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatSession.kt b/app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatSession.kt deleted file mode 100644 index 63c99d6..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/model/AiChatSession.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.babelsoftware.airnote.domain.model - -import androidx.room.Entity -import androidx.room.PrimaryKey -import java.util.Date - -@Entity(tableName = "ai_chat_sessions") -data class AiChatSession( - @PrimaryKey(autoGenerate = true) val id: Long = 0, - val title: String, - val createdAt: Date = Date(), - val aiMode: String, - val serviceName: String -) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/model/AiResponse.kt b/app/src/main/java/com/babelsoftware/airnote/domain/model/AiResponse.kt deleted file mode 100644 index a6f7fb9..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/model/AiResponse.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.babelsoftware.airnote.domain.model - -import com.google.gson.annotations.SerializedName - -/** - * Data class representing the JSON response from Gemini. - * Depending on the model's “intent,” it carries either a chat response or new note content. - */ -data class AiResponse( - @SerializedName("intent") val intent: String, - @SerializedName("response") val response: String? = null, - @SerializedName("new_note_content") val newNoteContent: String? = null -) - -/** - * Defines the AI's possible intents - */ -enum class AiIntent { - CHAT, - EDIT_NOTE, - UNKNOWN -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/model/Chat.kt b/app/src/main/java/com/babelsoftware/airnote/domain/model/Chat.kt deleted file mode 100644 index 384cdd4..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/model/Chat.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.babelsoftware.airnote.domain.model - -import androidx.compose.ui.graphics.vector.ImageVector - -// Indicates who the message belongs to (User or Model) -enum class Participant { - USER, MODEL, ERROR -} - -// Data class representing a single chat message -data class ChatMessage( - val text: String, - val participant: Participant, - val isLoading: Boolean = false // Loading animation while waiting for the model response -) - -data class AiSuggestion( - val title: String, - val icon: ImageVector, - val action: () -> Unit // Tıklandığında ViewModel'deki hangi fonksiyonu çağıracağı -) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/model/Folder.kt b/app/src/main/java/com/babelsoftware/airnote/domain/model/Folder.kt deleted file mode 100644 index 9eaca7f..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/model/Folder.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.domain.model - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity -data class Folder( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - val name: String, - val iconName: String = "Folder", - val createdAt: Long = System.currentTimeMillis() -) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/model/Note.kt b/app/src/main/java/com/babelsoftware/airnote/domain/model/Note.kt deleted file mode 100644 index f1cb429..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/model/Note.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.babelsoftware.airnote.domain.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "notes-table") -data class Note( - @PrimaryKey(autoGenerate = true) - val id: Int = 0, - - @ColumnInfo(name = "note-name") - val name: String, - - @ColumnInfo(name = "note-description") - val description: String, - - @ColumnInfo(name = "pinned") - val pinned: Boolean = false, - - @ColumnInfo(name = "foldered") - val folderId: Long? = null, - - @ColumnInfo(name = "encrypted") - val encrypted: Boolean = false, - - @ColumnInfo(name = "is_locked") - val isLocked: Boolean = false, - - @ColumnInfo(name = "created_at") - val createdAt: Long = System.currentTimeMillis() // Default value is the current timestamp -) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/model/Settings.kt b/app/src/main/java/com/babelsoftware/airnote/domain/model/Settings.kt deleted file mode 100644 index 4557d81..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/model/Settings.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.babelsoftware.airnote.domain.model - -import com.babelsoftware.airnote.presentation.navigation.NavRoutes - -data class Settings( - var defaultRouteType: String = NavRoutes.Home.route, - var passcode: String? = null, - var fingerprint: Boolean = false, - var pattern: String? = null, - var appLockEnabled: Boolean = false, - val viewMode: Boolean = true, - val automaticTheme: Boolean = true, - val darkTheme: Boolean = false, - var dynamicTheme: Boolean = false, - var amoledTheme: Boolean = false, - var minimalisticMode: Boolean = false, - var extremeAmoledMode: Boolean = false, - var isMarkdownEnabled: Boolean = true, - var screenProtection: Boolean = false, - var encryptBackup: Boolean = false, - var sortDescending: Boolean = true, - var vaultSettingEnabled: Boolean = false, - var vaultEnabled: Boolean = false, - var editMode: Boolean = false, - var gallerySync: Boolean = true, - var showOnlyTitle: Boolean = false, - var termsOfService: Boolean = false, - var useMonoSpaceFont: Boolean = false, - var lockImmediately: Boolean = true, - var cornerRadius: Int = 28, - var disableSwipeInEditMode: Boolean = false, - var makeSearchBarLonger: Boolean = false, - var fontSize: Int = 13, - val showFolderIndicator: Boolean = false, - val useAirNoteApi: Boolean = false, - val selectedModelName: String = "gemini-2.0-flash-001", - val selectedPerplexityModelName: String = "pplx-7b-online", - val isPerplexityEnabled: Boolean = false, - val desktopModeEnabled: Boolean = true, - val desktopModeAiEnabled: Boolean = true, - val openToLastUsedFolder: Boolean = false, - val lastUsedFolderId: Long? = null, - var noteLockPassword: String? = null, - var vaultEncryptionType: String = "AES-256" -) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/repository/FolderRepository.kt b/app/src/main/java/com/babelsoftware/airnote/domain/repository/FolderRepository.kt deleted file mode 100644 index d3d2b96..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/repository/FolderRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.domain.repository - -import com.babelsoftware.airnote.domain.model.Folder -import kotlinx.coroutines.flow.Flow - -interface FolderRepository { - fun getAllFolders(): Flow> - suspend fun insertFolder(folder: Folder) - suspend fun updateFolder(folder: Folder) - suspend fun deleteFolder(folder: Folder) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/repository/NoteRepository.kt b/app/src/main/java/com/babelsoftware/airnote/domain/repository/NoteRepository.kt deleted file mode 100644 index 7dd12e1..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/repository/NoteRepository.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.babelsoftware.airnote.domain.repository - -import com.babelsoftware.airnote.domain.model.Note -import kotlinx.coroutines.flow.Flow - -interface NoteRepository { - fun getAllNotes(): Flow> - suspend fun addNote(note: Note) - suspend fun updateNote(note: Note) - suspend fun deleteNote(note: Note) - suspend fun unlinkNotesFromFolder(folderId: Long) - fun getNoteById(id: Int): Flow - fun getLastNoteId(): Long? -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/repository/SettingsRepository.kt b/app/src/main/java/com/babelsoftware/airnote/domain/repository/SettingsRepository.kt deleted file mode 100644 index 08bc471..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/repository/SettingsRepository.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.babelsoftware.airnote.domain.repository - -import androidx.datastore.preferences.core.Preferences -import com.babelsoftware.airnote.domain.model.Settings -import kotlinx.coroutines.flow.Flow - -interface SettingsRepository { - val settings: Flow - - suspend fun update(settings: Settings) - - suspend fun getPreferences(): Preferences - suspend fun putString(key: String, value: String) - suspend fun putInt(key: String, value: Int) - suspend fun getString(key: String): String? - suspend fun getInt(key: String): Int? - suspend fun putBoolean(key: String, value: Boolean) - suspend fun getBoolean(key: String): Boolean? - suspend fun getEveryNotesWidget(): List> -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/AiChatUseCase.kt b/app/src/main/java/com/babelsoftware/airnote/domain/usecase/AiChatUseCase.kt deleted file mode 100644 index ea458bc..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/AiChatUseCase.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.babelsoftware.airnote.domain.usecase - -import com.babelsoftware.airnote.data.local.dao.AiChatDao -import com.babelsoftware.airnote.data.repository.AiMode -import com.babelsoftware.airnote.domain.model.AiChatMessage -import com.babelsoftware.airnote.domain.model.AiChatSession -import com.babelsoftware.airnote.domain.model.ChatMessage -import com.babelsoftware.airnote.domain.model.Participant -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class AiChatUseCase @Inject constructor( - private val aiChatDao: AiChatDao -) { - - fun getAllSessions(): Flow> { - return aiChatDao.getAllSessions() - } - - fun getMessagesForSession(sessionId: Long): Flow> { - return aiChatDao.getMessagesForSession(sessionId) - } - - suspend fun startNewSession(title: String, aiMode: AiMode, serviceName: String): Long { - val session = AiChatSession( - title = title, - aiMode = aiMode.name, - serviceName = serviceName - ) - return aiChatDao.insertSession(session) - } - - suspend fun addMessageToSession(sessionId: Long, message: ChatMessage): Long { - val aiChatMessage = AiChatMessage( - sessionId = sessionId, - text = message.text, - participant = message.participant, - isLoading = message.isLoading - ) - return aiChatDao.insertMessage(aiChatMessage) - } - - suspend fun updateMessageById(id: Long, text: String, isLoading: Boolean, isError: Boolean = false) { - val participant = if (isError) Participant.ERROR else Participant.MODEL - aiChatDao.updateMessageById(id, text, isLoading, participant) - } - - suspend fun getSessionById(sessionId: Long): AiChatSession? { - return aiChatDao.getSessionById(sessionId) - } - - suspend fun updateSessionMode(sessionId: Long, aiMode: AiMode) { - aiChatDao.updateSessionMode(sessionId, aiMode.name) - } - - suspend fun deleteSessionById(sessionId: Long) { - aiChatDao.deleteSessionById(sessionId) - } - - suspend fun deleteMessageById(id: Long) { - aiChatDao.deleteMessageById(id) - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/FolderUseCase.kt b/app/src/main/java/com/babelsoftware/airnote/domain/usecase/FolderUseCase.kt deleted file mode 100644 index cc3073b..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/FolderUseCase.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.domain.usecase - -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.repository.FolderRepository -import com.babelsoftware.airnote.domain.repository.NoteRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class FolderUseCase @Inject constructor( - private val folderRepository: FolderRepository, - private val noteRepository: NoteRepository -) { - fun getAllFolders(): Flow> { - return folderRepository.getAllFolders() - } - - suspend fun addFolder(folder: Folder) { - folderRepository.insertFolder(folder) - } - - suspend fun updateFolder(folder: Folder) { - folderRepository.updateFolder(folder) - } - - suspend fun deleteFolder(folder: Folder) { - folderRepository.deleteFolder(folder) - } - - suspend fun deleteFolderAndUnlinkNotes(folder: Folder) { - noteRepository.unlinkNotesFromFolder(folder.id) - folderRepository.deleteFolder(folder) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/ImportExportUseCase.kt b/app/src/main/java/com/babelsoftware/airnote/domain/usecase/ImportExportUseCase.kt deleted file mode 100644 index d0c7d7c..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/ImportExportUseCase.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.babelsoftware.airnote.domain.usecase - -import android.net.Uri -import android.util.Log -import com.babelsoftware.airnote.data.repository.ImportExportRepository -import com.babelsoftware.airnote.data.repository.NoteRepositoryImpl -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.IOException -import javax.inject.Inject - -data class ImportResult( - val successful: Int, - val total: Int, -) - -class ImportExportUseCase @Inject constructor( - private val noteRepository: NoteRepositoryImpl, - private val coroutineScope: CoroutineScope, - private val fileRepository: ImportExportRepository, -) -{ - /** - * Import notes from text files. - */ - fun importNotes(uris: List, onResult: (ImportResult) -> Unit) { - coroutineScope.launch(NonCancellable + Dispatchers.IO) { - var successful = 0 - uris.forEach{uri -> - try { - noteRepository.addNote(fileRepository.importFile(uri)) - successful++ - } catch (e: IOException) { - Log.e(ImportExportUseCase::class.simpleName, e.message, e) - } - } - withContext(Dispatchers.Main) { - onResult(ImportResult(successful, uris.size)) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/NoteUseCase.kt b/app/src/main/java/com/babelsoftware/airnote/domain/usecase/NoteUseCase.kt deleted file mode 100644 index 3d17eb5..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/NoteUseCase.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.babelsoftware.airnote.domain.usecase - -import android.content.Context -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.glance.appwidget.updateAll -import com.babelsoftware.airnote.data.repository.NoteRepositoryImpl -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.presentation.components.DecryptionResult -import com.babelsoftware.airnote.presentation.components.EncryptionHelper -import com.babelsoftware.airnote.widget.NotesWidget -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import javax.inject.Inject - -class NoteUseCase @Inject constructor( - private val noteRepository: NoteRepositoryImpl, - private val coroutineScope: CoroutineScope, - private val encryptionHelper: EncryptionHelper, - @ApplicationContext private val context: Context -) { - var notes: List by mutableStateOf(emptyList()) - private set - - var decryptionResult: DecryptionResult by mutableStateOf(DecryptionResult.LOADING) - - private var observeKeysJob: Job? = null - - fun observe() { - observeNotes() - } - - private fun observeNotes() { - observeKeysJob?.cancel() - observeKeysJob = coroutineScope.launch { - getAllNotes().collectLatest { notes -> - val hasUnencryptedNotes = notes.any { !it.encrypted } - if (!hasUnencryptedNotes) this@NoteUseCase.decryptionResult = DecryptionResult.EMPTY - val processedNotes = notes.mapNotNull { note -> - if (note.encrypted) { - val (decryptedNote, status) = decryptNote(note) - this@NoteUseCase.decryptionResult = status - if (status == DecryptionResult.SUCCESS) decryptedNote else null - } else { - note - } - } - this@NoteUseCase.notes = processedNotes - NotesWidget().updateAll(context) - } - } - } - - private fun encryptNote(note: Note): Note { - return if (note.encrypted) { - note.copy( - name = encryptionHelper.encrypt(note.name), - description = encryptionHelper.encrypt(note.description), - encrypted = true - ) - } else { - note - } - } - - private fun decryptNote(note: Note): Pair { - val (decryptedName, nameResult) = encryptionHelper.decrypt(note.name) - val (decryptedDescription, descriptionResult) = encryptionHelper.decrypt(note.description) - return if (note.encrypted) { - Pair(note.copy( - name = decryptedName ?: "", - description = decryptedDescription ?: "", - ), descriptionResult) - } else { - Pair(note, DecryptionResult.SUCCESS) - } - } - - fun getAllNotes(): Flow> { - return noteRepository.getAllNotes() - } - - suspend fun addNote(note: Note) { - val noteToSave = encryptNote(note) - if (note.id == 0) { - noteRepository.addNote(noteToSave) - } else { - noteRepository.updateNote(noteToSave) - } - } - - fun pinNote(note: Note) { - coroutineScope.launch(NonCancellable + Dispatchers.IO) { - addNote(note) - } - } - - fun deleteNoteById(id: Int) { - coroutineScope.launch(NonCancellable + Dispatchers.IO) { - val noteToDelete = noteRepository.getNoteById(id).first() - noteRepository.deleteNote(noteToDelete) - } - } - - fun getNoteById(id: Int): Flow { - return noteRepository.getNoteById(id) - } - - fun getLastNoteId(onResult: (Long?) -> Unit) { - coroutineScope.launch(NonCancellable + Dispatchers.IO) { - val lastNoteId = noteRepository.getLastNoteId() - withContext(Dispatchers.Main) { - onResult(lastNoteId) - } - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/SettingsUseCase.kt b/app/src/main/java/com/babelsoftware/airnote/domain/usecase/SettingsUseCase.kt deleted file mode 100644 index 155b203..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/domain/usecase/SettingsUseCase.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.babelsoftware.airnote.domain.usecase - -import android.content.Context -import androidx.datastore.preferences.core.stringPreferencesKey -import com.babelsoftware.airnote.constant.ConnectionConst -import com.babelsoftware.airnote.domain.model.Settings -import com.babelsoftware.airnote.domain.repository.SettingsRepository -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject - -class SettingsUseCase @Inject constructor( - @ApplicationContext private val context: Context, - private val settingsRepository: SettingsRepository, -) { - - suspend fun loadSettingsFromRepository(): Settings { - return Settings().apply { - Settings::class.java.declaredFields.forEach { field -> - field.isAccessible = true - val settingName = field.name - val defaultValue = field.get(this) - val settingValue = getSettingValue(field.type, settingName, defaultValue) - field.set(this, settingValue) - } - } - } - - private suspend fun getSettingValue(fieldType: Class<*>, settingName: String, defaultValue: Any?): Any? { - return try { - when (fieldType) { - Boolean::class.java -> settingsRepository.getBoolean(settingName) ?: defaultValue - String::class.java -> settingsRepository.getString(settingName) ?: defaultValue - Int::class.java -> settingsRepository.getInt(settingName) ?: defaultValue - else -> defaultValue // Allow unsupported types to default to null or the defaultValue. - } - } catch (e: ClassCastException) { - handleCorruptedPreference(settingName, e) - defaultValue - } - } - - private fun handleCorruptedPreference(settingName: String, e: ClassCastException) { - println("Corrupted preference. Contact support: ${ConnectionConst.SUPPORT_MAIL}") - println("Invalid Key: $settingName") - println(e.stackTraceToString()) - } - - suspend fun saveSettingsToRepository(settings: Settings) { - Settings::class.java.declaredFields.forEach { field -> - field.isAccessible = true - val settingName = field.name - val settingValue = field.get(settings) - saveSettingValue(settingName, settingValue) - } - } - - private suspend fun saveSettingValue(settingName: String, settingValue: Any?) { - when (settingValue) { - is Boolean -> settingsRepository.putBoolean(settingName, settingValue) - is String -> settingsRepository.putString(settingName, settingValue) - is Int -> settingsRepository.putInt(settingName, settingValue) - null -> settingsRepository.getPreferences().toMutablePreferences().remove(stringPreferencesKey(settingName)) - else -> throw IllegalArgumentException("Unsupported setting type: ${settingValue?.javaClass}") - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/MainActivity.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/MainActivity.kt deleted file mode 100644 index 7ed3f24..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/MainActivity.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.babelsoftware.airnote.presentation - -import android.content.Intent -import android.os.Bundle -import android.provider.MediaStore -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import androidx.navigation.NavHostController -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.rememberNavController -import com.babelsoftware.airnote.presentation.navigation.AppNavHost -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.theme.LeafNotesTheme -import dagger.hilt.android.AndroidEntryPoint - -fun NavOptionsBuilder.popUpToTop(navController: NavController) { - popUpTo(navController.currentBackStackEntry?.destination?.route ?: return) { - inclusive = true - } -} - -@AndroidEntryPoint -class MainActivity : AppCompatActivity() { - private lateinit var navController: NavHostController - private var settingsViewModel: SettingsViewModel? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - installSplashScreen() - enableEdgeToEdge() - - setContent { - settingsViewModel = hiltViewModel() - settingsViewModel!!.loadDefaultRoute() - - val noteId = intent?.getIntExtra("noteId", -1) ?: -1 - val isShareIntent = intent?.action == Intent.ACTION_SEND && "text/plain" == intent.type - val startRoute = settingsViewModel!!.defaultRoute!! - - if (settingsViewModel!!.settings.value.gallerySync) { - contentResolver.registerContentObserver( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - true, - settingsViewModel!!.galleryObserver - ) - } - - LeafNotesTheme(settingsViewModel!!) { - - Surface( - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - navController = rememberNavController() - AppNavHost( - settingsModel = settingsViewModel!!, - navController = navController, - noteId = noteId, - defaultRoute = startRoute, - isShareIntent = isShareIntent - ) - } - } - } - } - - override fun onResume() { - super.onResume() - settingsViewModel?.let { - if (it.defaultRoute != NavRoutes.Home.route) { - if (it.settings.value.passcode != null || it.settings.value.fingerprint || it.settings.value.pattern != null) { - if (it.settings.value.lockImmediately) { - if (::navController.isInitialized) { - navController.navigate(it.defaultRoute!!) { popUpToTop(navController) } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/Animations.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/Animations.kt deleted file mode 100644 index 2b77363..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/Animations.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.babelsoftware.airnote.presentation.components - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally - -private const val DEFAULT_FADE_DURATION = 300 -private const val DEFAULT_SCALE_DURATION = 400 -private const val DEFAULT_SLIDE_DURATION = 400 -private const val DEFAULT_INITIAL_SCALE = 0.9f - -fun getNoteEnterAnimation(): EnterTransition { - return fadeIn(animationSpec = tween(DEFAULT_FADE_DURATION)) + scaleIn( - initialScale = 0.9f, - animationSpec = tween(DEFAULT_SCALE_DURATION) - ) -} - -fun getNoteExitAnimation(slideDirection: Int): ExitTransition { - return slideOutHorizontally( - targetOffsetX = { slideDirection * it }, - animationSpec = tween(durationMillis = DEFAULT_SLIDE_DURATION) - ) + fadeOut(animationSpec = tween(durationMillis = DEFAULT_FADE_DURATION)) -} - -fun defaultScreenEnterAnimation(): EnterTransition { - return fadeIn(animationSpec = tween(DEFAULT_FADE_DURATION)) + - scaleIn( - initialScale = DEFAULT_INITIAL_SCALE, - animationSpec = tween(DEFAULT_SCALE_DURATION) - ) -} - -fun defaultScreenExitAnimation(): ExitTransition { - return fadeOut(animationSpec = tween(DEFAULT_FADE_DURATION)) + - scaleOut( - targetScale = DEFAULT_INITIAL_SCALE, - animationSpec = tween(DEFAULT_SCALE_DURATION) - ) -} - -fun slideScreenEnterAnimation(): EnterTransition { - return slideInHorizontally( - initialOffsetX = { fullWidth -> fullWidth }, - animationSpec = tween(DEFAULT_SLIDE_DURATION) - ) -} - -fun slideScreenExitAnimation(): ExitTransition { - return slideOutHorizontally( - targetOffsetX = { fullWidth -> fullWidth }, - animationSpec = tween(DEFAULT_SLIDE_DURATION) - ) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/AutoFill.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/AutoFill.kt deleted file mode 100644 index 4ca1d7d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/AutoFill.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.babelsoftware.airnote.presentation.components - -import android.view.autofill.AutofillManager -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.Autofill -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import kotlin.math.roundToInt - -fun Modifier.connectNode(handler: AutoFillHandler): Modifier { - return with(handler) { fillBounds() } -} - -fun Modifier.defaultFocusChangeAutoFill(handler: AutoFillHandler): Modifier { - return this.then( - Modifier.onFocusChanged { - if (it.isFocused) { - handler.request() - } else { - handler.cancel() - } - } - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun AutoFillRequestHandler( - autofillTypes: List = listOf(), - onFill: (String) -> Unit, -): AutoFillHandler { - val view = LocalView.current - val context = LocalContext.current - var isFillRecently = remember { false } - val autoFillNode = remember { - AutofillNode( - autofillTypes = autofillTypes, - onFill = { - isFillRecently = true - onFill(it) - } - ) - } - val autofill = LocalAutofill.current - LocalAutofillTree.current += autoFillNode - return remember { - object : AutoFillHandler { - val autofillManager = context.getSystemService(AutofillManager::class.java) - override fun requestManual() { - autofillManager.requestAutofill( - view, - autoFillNode.id, - autoFillNode.boundingBox?.toAndroidRect() ?: error("BoundingBox is not provided yet") - ) - } - - override fun requestVerifyManual() { - if (isFillRecently) { - isFillRecently = false - requestManual() - } - } - - override val autoFill: Autofill? - get() = autofill - - override val autoFillNode: AutofillNode - get() = autoFillNode - - override fun request() { - autofill?.requestAutofillForNode(autofillNode = autoFillNode) - } - - override fun cancel() { - autofill?.cancelAutofillForNode(autofillNode = autoFillNode) - } - - override fun Modifier.fillBounds(): Modifier { - return this.then( - Modifier.onGloballyPositioned { - autoFillNode.boundingBox = it.boundsInWindow() - }) - } - } - } -} - -fun Rect.toAndroidRect(): android.graphics.Rect { - return android.graphics.Rect( - left.roundToInt(), - top.roundToInt(), - right.roundToInt(), - bottom.roundToInt() - ) -} - -interface AutoFillHandler { - @OptIn(ExperimentalComposeUiApi::class) - val autoFill: Autofill? - @OptIn(ExperimentalComposeUiApi::class) - val autoFillNode: AutofillNode - fun requestVerifyManual() - fun requestManual() - fun request() - fun cancel() - fun Modifier.fillBounds(): Modifier -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActionButton.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActionButton.kt deleted file mode 100644 index 7ab9951..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActionButton.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.babelsoftware.airnote.presentation.components - -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - - -@Composable -fun NotesButton( - text: String, - onClick: () -> Unit -) { - ExtendedFloatingActionButton( - modifier = Modifier.imePadding(), - shape = RoundedCornerShape(24.dp), - onClick = { onClick() }, - icon = { Icon(Icons.Rounded.Edit, text) }, - text = { Text(text = text) }, - ) -} - -@Composable -fun AgreeButton( - text: String, - modifier: Modifier, - onClick: () -> Unit, -) { - ExtendedFloatingActionButton( - modifier = Modifier.imePadding(), - shape = RoundedCornerShape(24.dp), - onClick = { onClick() }, - icon = { Icon(Icons.Rounded.CheckCircle, text) }, - text = { Text(text = text) }, - ) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActions.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActions.kt deleted file mode 100644 index 4606036..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomActions.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.babelsoftware.airnote.presentation.components - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.Redo -import androidx.compose.material.icons.automirrored.rounded.Undo -import androidx.compose.material.icons.outlined.PushPin -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Done -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.LockOpen -import androidx.compose.material.icons.rounded.MoreVert -import androidx.compose.material.icons.rounded.PushPin -import androidx.compose.material.icons.rounded.SelectAll -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier - -@Composable -fun CloseButton( - contentDescription: String = "Close", - onCloseClicked: () -> Unit -) { - IconButton(onClick = onCloseClicked) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onBackground - ) - } -} - -@Composable -fun MoreButton(onClick: () -> Unit) { - IconButton(onClick = { onClick() }) { - Icon(Icons.Rounded.MoreVert, contentDescription = "Info") - } -} - -@Composable -fun SaveButton(onSaveClicked: () -> Unit) { - IconButton(onClick = onSaveClicked) { - Icon( - imageVector = Icons.Rounded.Done, - contentDescription = "Done", - tint = MaterialTheme.colorScheme.onBackground - ) - } -} - -@Composable -fun UndoButton(onUndoClicked: () -> Unit) { - IconButton(onClick = onUndoClicked) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Undo, - contentDescription = "Undo", - tint = MaterialTheme.colorScheme.outlineVariant - ) - } -} - -@Composable -fun RedoButton(onRedoClicked: () -> Unit) { - IconButton(onClick = onRedoClicked) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Redo, - contentDescription = "Redo", - tint = MaterialTheme.colorScheme.outlineVariant - ) - } -} - -@Composable -fun NavigationIcon(onBackNavClicked: () -> Unit) { - IconButton(onClick = onBackNavClicked) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.onBackground - ) - } -} - -@Composable -fun SettingsButton(onSettingsClicked: () -> Unit) { - IconButton(onClick = onSettingsClicked) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = "Settings", - tint = MaterialTheme.colorScheme.onBackground - ) - } -} - -@Composable -fun VaultButton(vaultEnabled: Boolean,onVaultButtonClicked: () -> Unit) { - IconButton(onClick = onVaultButtonClicked) { - Icon( - imageVector = if (vaultEnabled) Icons.Rounded.Lock else Icons.Rounded.LockOpen, - contentDescription = "Vault", - tint = MaterialTheme.colorScheme.onBackground - ) - } -} - -@Composable -fun TitleText(titleText: String) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = titleText, - modifier = Modifier.weight(1f) - ) - } -} - -@Composable -fun PinButton(isPinned: Boolean, onClick: () -> Unit) { - IconButton(onClick = { onClick() }) { - Icon(if (isPinned) Icons.Rounded.PushPin else Icons.Outlined.PushPin, contentDescription = "Pin") - } -} - -@Composable -fun DeleteButton(onClick: () -> Unit) { - IconButton( - onClick = { onClick() } - ) { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = "Delete", - ) - } -} - -@Composable -fun SelectAllButton(enabled: Boolean, onClick: () -> Unit) { - if (enabled) { - IconButton( - onClick = { onClick() } - ) { - Icon( - imageVector = Icons.Rounded.SelectAll, - contentDescription = "Select All", - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomScaffold.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomScaffold.kt deleted file mode 100644 index 7d8d68c..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/CustomScaffold.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.presentation.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun NotesScaffold( - modifier: Modifier = Modifier, - topBar : @Composable () -> Unit = {}, - floatingActionButton: @Composable () -> Unit = {}, - snackbarHost: @Composable () -> Unit = {}, - // DEĞİŞİKLİK: content parametresini tekrar orijinal, basit haline getiriyoruz. - content: @Composable () -> Unit -) { - Scaffold( - modifier = modifier, - snackbarHost = snackbarHost, - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - topBar = { topBar() }, - floatingActionButton = { floatingActionButton() }, - ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding).consumeWindowInsets(innerPadding)) { - content() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/EncryptionHelper.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/EncryptionHelper.kt deleted file mode 100644 index 46f8172..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/EncryptionHelper.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.babelsoftware.airnote.presentation.components - -import android.util.Base64 -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import javax.crypto.Cipher -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec - -class EncryptionHelper(private val mutableVaultPassword: StringBuilder) { - - fun isPasswordEmpty(): Boolean { - return mutableVaultPassword.isEmpty() - } - - fun removePassword() { - mutableVaultPassword.setLength(0) - } - - fun setPassword(newPassword: String) { - mutableVaultPassword.setLength(0) - mutableVaultPassword.append(newPassword) - } - - fun encrypt(data: String): String { - val secretKey = generateSecretKey(mutableVaultPassword.toString()) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - val ivBytes = cipher.iv - val encryptedBytes = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8)) - return "${Base64.encodeToString(ivBytes, Base64.DEFAULT)}:${Base64.encodeToString(encryptedBytes, Base64.DEFAULT)}" - } - - fun decrypt(data: String): Pair { - if (mutableVaultPassword.isEmpty()) return Pair(null, DecryptionResult.LOADING) - if (data.isBlank()) return Pair(null, DecryptionResult.BLANK_DATA) - - return try { - val split = data.split(":") - if (split.size != 2) return Pair(null, DecryptionResult.INVALID_DATA) - val ivBytes = Base64.decode(split[0], Base64.DEFAULT) - val encryptedBytes = Base64.decode(split[1], Base64.DEFAULT) - val secretKey = generateSecretKey(mutableVaultPassword.toString()) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val spec = GCMParameterSpec(128, ivBytes) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - val decryptedBytes = cipher.doFinal(encryptedBytes) - val decryptedData = String(decryptedBytes, StandardCharsets.UTF_8) - Pair(decryptedData, DecryptionResult.SUCCESS) - } catch (e: Exception) { - Pair(null, DecryptionResult.BAD_PASSWORD) - } - } - - private fun generateSecretKey(password: String): SecretKey { - val keySpec = SecretKeySpec(password.toByteArray(StandardCharsets.UTF_8), "AES") - val sha256 = MessageDigest.getInstance("SHA-256") - val keyBytes = sha256.digest(keySpec.encoded) - return SecretKeySpec(keyBytes, "AES") - } -} - -enum class DecryptionResult { - EMPTY, - SUCCESS, - INVALID_DATA, - BLANK_DATA, - BAD_PASSWORD, - LOADING -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/ImageCleaner.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/ImageCleaner.kt deleted file mode 100644 index 4312272..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/ImageCleaner.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.babelsoftware.airnote.presentation.components - -import android.content.Context -import android.database.ContentObserver -import android.net.Uri -import android.os.Handler -import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File -import javax.inject.Inject - -// GalleryObserver class -class GalleryObserver @Inject constructor( - handler: Handler, - @ApplicationContext private val context: Context -) : ContentObserver(handler) { - override fun onChange(selfChange: Boolean, uri: Uri?) { - super.onChange(selfChange, uri) - uri?.let { - deleteFileIfExists(context, getImageName(uri)) - } - } -} - -// Function to unregister the GalleryObserver -fun unregisterGalleryObserver(context: Context, observer: GalleryObserver) { - context.contentResolver.unregisterContentObserver(observer) -} - -// Function to delete the file if it exists -fun deleteFileIfExists(context: Context, fileName: String): Boolean { - val appStorageDir = getExternalStorageDir(context) - val file = File(appStorageDir, fileName) - return if (file.exists()) { - file.delete() - } else { - false - } -} - -// Function to get the external storage directory -fun getExternalStorageDir(context: Context): File { - return context.getExternalFilesDir(null) ?: throw IllegalStateException("External storage directory not found") -} - -// Function to get the image name from the URI -fun getImageName(uri: Uri?): String { - return if (uri != null) { - uri.lastPathSegment?.filter { it.isDigit() }?.takeLast(10) ?: (1000..99999).random().toString() - } else { - (1000..99999).random().toString() - } + ".jpg" -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/NoteItem.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/NoteItem.kt deleted file mode 100644 index 587ee3e..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/NoteItem.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.presentation.components - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.model.Note - -@Composable -fun FolderIndicator( - folderName: String, - modifier: Modifier = Modifier -) { - Surface( - modifier = modifier.padding(top = 8.dp, end = 8.dp), - shape = RoundedCornerShape(percent = 50), - color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.9f), - tonalElevation = 2.dp - ) { - Text( - text = folderName, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NoteItem( - note: Note, - allFolders: List, - onClick: () -> Unit -) { - val folder = note.folderId?.let { id -> - allFolders.find { it.id == id } - } - - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Box( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - ) { - // DÜZELTME: 'note.title' yerine 'note.name' kullanıldı. - Text( - text = note.name, // - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - - // DÜZELTME: 'note.content' yerine 'note.description' kullanıldı. - Text( - text = note.description, // - style = MaterialTheme.typography.bodyMedium, - maxLines = 4, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - - if (folder != null) { - FolderIndicator( - folderName = folder.name, - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/UpdateScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/UpdateScreen.kt deleted file mode 100644 index 246b5c2..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/UpdateScreen.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - * - * This file and codes created by RRechz - Babel Software - */ - -package com.babelsoftware.airnote.presentation.components - -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.util.ChangelogResult -import com.babelsoftware.airnote.util.getChangelogFromGitHub -import kotlinx.coroutines.launch - -@Composable -fun UpdateScreen( - latestVersion: String, - onDismiss: () -> Unit, - onNavigateToAbout: () -> Unit -) { - val context = LocalContext.current - var changelog by remember { mutableStateOf("") } - val scope = rememberCoroutineScope() - - LaunchedEffect(Unit) { - scope.launch { - changelog = when (val result = getChangelogFromGitHub()) { - is ChangelogResult.Success -> { - if (result.body.isNotBlank()) { - result.body - } else { - context.getString(R.string.changelog_fetch_error) - } - } - is ChangelogResult.Error -> { - context.getString(R.string.changelog_fetch_error_with_message, result.exception.message ?: "Unknow error") - } - } - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = "${stringResource(R.string.newversion)} $latestVersion") - }, - text = { - LazyColumn(modifier = Modifier.padding(vertical = 8.dp)) { - item { - Text(text = changelog) - } - } - }, - confirmButton = { - Button( - onClick = { - onNavigateToAbout() - onDismiss() - } - ) { - Text(stringResource(id = R.string.update)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(id = R.string.cancel)) - } - } - ) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/Builder.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/Builder.kt deleted file mode 100644 index e1879e0..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/Builder.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.babelsoftware.airnote.presentation.components.markdown - -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import java.text.BreakIterator -import androidx.compose.ui.text.withStyle - -class MarkdownBuilder(internal val lines: List, private var lineProcessors: List) { - var lineIndex = -1 - - internal val content = mutableListOf() - - fun add(element: MarkdownElement) { - content.add(element) - } - - fun parse() { - while (hasNextLine()) { - val line = nextLine() - val processor = lineProcessors.find { it.canProcessLine(line) } - if (processor != null) { - processor.processLine(line, this) - } else { - add(NormalText(line)) - } - } - } - - private fun hasNextLine(): Boolean = lineIndex + 1 < lines.size - - private fun nextLine(): String { - lineIndex++ - return lines[lineIndex] - } -} - -/** - * Splits the input string by the specified delimiter and returns a list of index pairs. - * Each pair represents the start and end indices of segments between delimiters. - */ -fun splitByDelimiter(input: String, delimiter: String): List> { - val segments = mutableListOf>() - var startIndex = 0 - var delimiterIndex = input.indexOf(delimiter, startIndex) - - while (delimiterIndex != -1) { - if (startIndex != delimiterIndex) { - segments.add(Pair(startIndex, delimiterIndex)) - } else { - segments.add(Pair(startIndex, startIndex)) - } - startIndex = delimiterIndex + delimiter.length - delimiterIndex = input.indexOf(delimiter, startIndex) - } - - if (startIndex < input.length) { - segments.add(Pair(startIndex, input.length)) - } else if (startIndex == input.length) { - segments.add(Pair(startIndex, startIndex)) - } - - // Only keep segments that are odd-indexed (i.e., inside delimiters) - return segments.filterIndexed { index, _ -> index % 2 == 1 } -} - -/** - * Checks if a given index is within any of the provided segments. - */ -fun isInSegments(index: Int, segments: List>): Boolean { - return segments.any { segment -> index in segment.first until segment.second } -} - -fun buildString(input: String, defaultFontWeight: FontWeight = FontWeight.Normal): AnnotatedString { - val textStyleSegments: List = listOf( - BoldSegment(), - ItalicSegment(), - HighlightSegment(), - Strikethrough(), - Underline(), - CodeSegment() - ) - - val allSegments = textStyleSegments.associateWith { splitByDelimiter(input, it.delimiter) } - - fun getSpanStyle(index: Int): SpanStyle { - val styles = textStyleSegments.filter { segment -> isInSegments(index, allSegments[segment]!!) } - return styles.fold(SpanStyle(fontWeight = defaultFontWeight)) { acc, segment -> acc.merge(segment.getSpanStyle()) } - } - - val annotatedString = buildAnnotatedString { - val iterator = BreakIterator.getWordInstance() - iterator.setText(input) - - var start = iterator.first() - var end = iterator.next() - - while (end != BreakIterator.DONE) { - val substring = input.substring(start, end) - - // Skip delimiters and check Arabic substrings - val isDelimiter = textStyleSegments.any { segment -> - // Only consider it a delimiter if it's the exact delimiter string - // This ensures single characters like +, -, = are not filtered out - segment.delimiter == substring - } - - if (!isDelimiter) { - withStyle(style = getSpanStyle(start)) { - append(substring) // Append full words or graphemes - } - } - - start = end - end = iterator.next() - } - } - return annotatedString -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/InlineElements.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/InlineElements.kt deleted file mode 100644 index 09f4d9e..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/InlineElements.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.babelsoftware.airnote.presentation.components.markdown - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration - -interface TextStyleSegment { - val delimiter: String - fun getSpanStyle(): SpanStyle -} - -data class BoldSegment(override val delimiter: String = "**") : TextStyleSegment { - override fun getSpanStyle() = SpanStyle(fontWeight = FontWeight.Bold) -} - -data class ItalicSegment(override val delimiter: String = "*") : TextStyleSegment { - override fun getSpanStyle() = SpanStyle(fontStyle = FontStyle.Italic) -} - -data class HighlightSegment(override val delimiter: String = "==") : TextStyleSegment { - override fun getSpanStyle() = SpanStyle(background = Color.Yellow.copy(alpha = 0.2f)) -} - -data class Strikethrough(override val delimiter: String = "~~") : TextStyleSegment { - override fun getSpanStyle() = SpanStyle(textDecoration = TextDecoration.LineThrough) -} - -data class Underline(override val delimiter: String = "_") : TextStyleSegment { - override fun getSpanStyle() = SpanStyle(textDecoration = TextDecoration.Underline) -} - -data class CodeSegment(override val delimiter: String = "`") : TextStyleSegment { - override fun getSpanStyle() = SpanStyle( - fontFamily = FontFamily.Monospace, - background = Color.LightGray.copy(alpha = 0.3f) - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/LineProccesor.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/LineProccesor.kt deleted file mode 100644 index 013e08e..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/LineProccesor.kt +++ /dev/null @@ -1,159 +0,0 @@ -package com.babelsoftware.airnote.presentation.components.markdown - -interface MarkdownLineProcessor { - fun canProcessLine(line: String): Boolean - fun processLine(line: String, builder: MarkdownBuilder) -} - -class CodeBlockProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean { - return line.startsWith("```") - } - - override fun processLine(line: String, builder: MarkdownBuilder) { - val codeBlock = StringBuilder() - var index = builder.lineIndex + 1 - var isEnded = false - - while (index < builder.lines.size) { - val nextLine = builder.lines[index] - if (nextLine == "```") { - builder.lineIndex = index - isEnded = true - break - } - codeBlock.appendLine(nextLine) - index++ - } - - builder.add(CodeBlock(codeBlock.toString(), isEnded, line)) - } -} - -class CheckboxProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean = line.matches(Regex("^\\[[ xX]]( .*)?")) - - override fun processLine(line: String, builder: MarkdownBuilder) { - val checked = line.contains(Regex("^\\[[Xx]]")) - val text = line.replace(Regex("^\\[[ xX]] ?"), "").trim() - builder.add(CheckboxItem(text, checked, builder.lineIndex)) - } -} - - -class HeadingProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean = line.startsWith("#") - - override fun processLine(line: String, builder: MarkdownBuilder) { - val level = line.takeWhile { it == '#' }.length - val text = line.drop(level).trim() - builder.add(Heading(level, text)) - } -} - -class QuoteProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean = line.trim().startsWith(">") - - override fun processLine(line: String, builder: MarkdownBuilder) { - val level = line.takeWhile { it == '>' }.length - val text = line.drop(level).trim() - builder.add(Quote(level, text)) - } -} - -class ListItemProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean { - val trimmed = line.trim() - return trimmed.startsWith("- ") || - trimmed.startsWith("+ ") || - trimmed.startsWith("* ") - } - - override fun processLine(line: String, builder: MarkdownBuilder) { - val trimmed = line.trim() - val text = when { - trimmed.startsWith("- ") -> trimmed.removePrefix("- ").trim() - trimmed.startsWith("+ ") -> trimmed.removePrefix("+ ").trim() - trimmed.startsWith("* ") -> trimmed.removePrefix("* ").trim() - else -> trimmed // Shouldn't happen due to canProcessLine check - } - builder.add(ListItem(text)) - } -} - -class ImageInsertionProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean { - return line.trim().startsWith("!(") && line.trim().endsWith(")") - } - - override fun processLine(line: String, builder: MarkdownBuilder) { - val photoUri = line.substringAfter("!(", "").substringBefore(")") - builder.add(ImageInsertion(photoUri)) - } -} - -class LinkProcessor : MarkdownLineProcessor { - private val urlPattern = Regex("(https?://[\\w-]+(\\.[\\w-]+)+[\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])") - override fun canProcessLine(line: String): Boolean { - return urlPattern.containsMatchIn(line) - } - - override fun processLine(line: String, builder: MarkdownBuilder) { - val matches = urlPattern.findAll(line) - val urlRanges = mutableListOf>() - for (match in matches) { - val url = match.value - val range = match.range - urlRanges.add(Pair(url, range)) - } - if (urlRanges.isNotEmpty()) { - builder.add(Link(line, urlRanges)) - } else { - builder.add(NormalText(line)) - } - } -} - -class HorizontalRuleProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean { - return line.trim() == "---" - } - - override fun processLine(line: String, builder: MarkdownBuilder) { - builder.add(HorizontalRule(line)) - } -} - -class TableProcessor : MarkdownLineProcessor { - override fun canProcessLine(line: String): Boolean { - return line.trim().startsWith("|") - } - - override fun processLine(line: String, builder: MarkdownBuilder) { - if (builder.lineIndex + 1 < builder.lines.size) { - val nextLine = builder.lines[builder.lineIndex + 1] - if (nextLine.trim().startsWith("|") && nextLine.contains("---")) { - val headers = parseRow(line) - builder.lineIndex++ - - val rows = mutableListOf>() - while (builder.lineIndex + 1 < builder.lines.size) { - val nextRowLine = builder.lines[builder.lineIndex + 1] - if (nextRowLine.trim().startsWith("|")) { - builder.lineIndex++ - rows.add(parseRow(nextRowLine)) - } else { - break - } - } - builder.add(Table(headers, rows)) - return - } - } - builder.add(NormalText(line)) - } - - private fun parseRow(row: String): List { - return row.trim().trim('|').split('|').map { it.trim() } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/MarkdownText.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/MarkdownText.kt deleted file mode 100644 index f08ccac..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/MarkdownText.kt +++ /dev/null @@ -1,521 +0,0 @@ -package com.babelsoftware.airnote.presentation.components.markdown - - -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Checkbox -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.shapeManager -import com.babelsoftware.airnote.presentation.theme.linkColor - -@Composable -fun MarkdownTable( - headers: List, - rows: List>, - fontSize: TextUnit, - weight: FontWeight -) { - val scrollState = rememberScrollState() - val borderColor = MaterialTheme.colorScheme.outlineVariant - - Box( - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - .border(1.dp, borderColor, RoundedCornerShape(4.dp)) - .horizontalScroll(scrollState) - ) { - Column { - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - headers.forEachIndexed { index, header -> - Box( - modifier = Modifier - .width(IntrinsicSize.Max) - .padding(8.dp) - .align(Alignment.CenterVertically) - ) { - Text( - text = header, - fontWeight = FontWeight.Bold, - fontSize = fontSize, - color = MaterialTheme.colorScheme.onSurface - ) - } - if (index < headers.size - 1) { - VerticalDivider( - color = borderColor, - modifier = Modifier.fillMaxHeight() - ) - } - } - } - HorizontalDivider(color = borderColor) - rows.forEachIndexed { rowIndex, row -> - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - headers.indices.forEach { colIndex -> - val cellText = row.getOrNull(colIndex) ?: "" - Box( - modifier = Modifier - .width(IntrinsicSize.Max) - .padding(8.dp) - .align(Alignment.CenterVertically) - ) { - Text( - text = cellText, - fontSize = fontSize, - fontWeight = weight, - color = MaterialTheme.colorScheme.onSurface - ) - } - if (colIndex < headers.size - 1) { - VerticalDivider( - color = borderColor, - modifier = Modifier.fillMaxHeight() - ) - } - } - } - if (rowIndex < rows.size - 1) { - HorizontalDivider(color = borderColor) - } - } - } - } -} - -@Composable -fun MarkdownCodeBlock( - color: Color, - text: @Composable () -> Unit -) { - Box( - modifier = Modifier.padding(top = 6.dp), - content = { - Surface( - color = color, - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .fillMaxWidth(), - content = { - text() - } - ) - } - ) -} - -@Composable -fun MarkdownQuote(content: String, fontSize: TextUnit) { - Row(horizontalArrangement = Arrangement.Center) { - Box( - modifier = Modifier - .height(22.dp) - .width(6.dp) - .background( - MaterialTheme.colorScheme.surfaceContainerLow, - RoundedCornerShape(16.dp) - ) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = StyledText( - text = " $content", - baseStyle = SpanStyle(fontSize = fontSize) - ) - ) - } -} - -@Composable -fun MarkdownCheck(content: @Composable () -> Unit, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, - modifier = Modifier - .padding(end = 12.dp) - .size(20.dp) - ) - content() - } -} - -@Composable -fun MarkdownText( - radius: Int, - markdown: String, - isPreview: Boolean = false, - isEnabled: Boolean, - modifier: Modifier = Modifier.fillMaxWidth(), - weight: FontWeight = FontWeight.Normal, - fontSize: TextUnit = 16.sp, - spacing: Dp = 2.dp, - onContentChange: (String) -> Unit = {}, - settingsViewModel: SettingsViewModel? = null -) { - if (!isEnabled) { - StaticMarkdownText( - markdown = markdown, - modifier = modifier, - weight = weight, - fontSize = fontSize - ) - return - } - - val lines = markdown.lines() - val lineProcessors = listOf( - HeadingProcessor(), - ListItemProcessor(), - CodeBlockProcessor(), - QuoteProcessor(), - ImageInsertionProcessor(), - CheckboxProcessor(), - LinkProcessor(), - HorizontalRuleProcessor(), - TableProcessor() - ) - val markdownBuilder = MarkdownBuilder(lines, lineProcessors) - markdownBuilder.parse() - - MarkdownContent( - radius = radius, - isPreview = isPreview, - content = markdownBuilder.content, - modifier = modifier, - spacing = spacing, - weight = weight, - fontSize = fontSize, - lines = lines, - onContentChange = onContentChange - ) -} - -@Composable -fun StaticMarkdownText( - markdown: String, - modifier: Modifier, - weight: FontWeight, - fontSize: TextUnit -) { - Text( - text = markdown, - fontSize = fontSize, - fontWeight = weight, - modifier = modifier - ) -} - -@Composable -fun MarkdownContent( - radius: Int, - isPreview: Boolean, - content: List, - modifier: Modifier, - spacing: Dp, - weight: FontWeight, - fontSize: TextUnit, - lines: List, - onContentChange: (String) -> Unit -) { - if (isPreview) { - Column(modifier = modifier) { - content.take(4).forEachIndexed { index, _ -> - RenderMarkdownElement( - radius = radius, - index = index, - content = content, - weight = weight, - fontSize = fontSize, - lines = lines, - isPreview = true, - onContentChange = onContentChange - ) - } - } - } else { - SelectionContainer { - LazyColumn(modifier = modifier) { - items(content.size) { index -> - Spacer(modifier = Modifier.height(spacing)) - RenderMarkdownElement( - radius = radius, - content = content, - index = index, - weight = weight, - fontSize = fontSize, - lines = lines, - isPreview = isPreview, - onContentChange = onContentChange - ) - } - } - } - } -} - -@Composable -fun RenderMarkdownElement( - radius: Int, - content: List, - index: Int, - weight: FontWeight, - fontSize: TextUnit, - lines: List, - isPreview: Boolean, - onContentChange: (String) -> Unit -) { - val element = content[index] - Row { - when (element) { - is Heading -> { - val style = SpanStyle( - fontWeight = weight, - fontSize = when (element.level) { - in 1..6 -> (28 - (2 * element.level) - fontSize.value / 3).sp - else -> fontSize - } - ) - Text( - text = StyledText(text = element.text, baseStyle = style), - modifier = Modifier.padding(vertical = 10.dp) - ) - } - - is CheckboxItem -> { - MarkdownCheck( - content = { - Text( - text = StyledText( - text = element.text, - baseStyle = SpanStyle(fontSize = fontSize, fontWeight = weight) - ) - ) - }, - checked = element.checked, - onCheckedChange = if (isPreview) null else { newChecked -> - val newMarkdown = lines.toMutableList().apply { - this[element.index] = if (newChecked) { - "[X] ${element.text}" - } else { - "[ ] ${element.text}" - } - } - onContentChange(newMarkdown.joinToString("\n")) - } - ) - } - - is ListItem -> { - Text( - text = StyledText( - text = "• ${element.text}", - baseStyle = SpanStyle(fontSize = fontSize, fontWeight = weight) - ) - ) - } - - is Quote -> { - MarkdownQuote(content = element.text, fontSize = fontSize) - } - - is ImageInsertion -> { - val modifier = if (isPreview) { - Modifier.clip(shape = shapeManager(radius = radius)) - } else { - Modifier - .clip(shape = shapeManager(isBoth = true, radius = radius / 2)) - .clickable { /* Just for animation */ } - } - AsyncImage( - model = element.photoUri, - contentDescription = "Image", - modifier = modifier - ) - } - - is CodeBlock -> { - if (element.isEnded) { - MarkdownCodeBlock(color = MaterialTheme.colorScheme.surfaceContainerLow) { - Text( - text = element.code.dropLast(1), - fontSize = fontSize, - fontWeight = weight, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(6.dp), - ) - } - } else { - Text( - text = element.firstLine, - fontWeight = weight, - fontSize = fontSize, - ) - } - } - - is Link -> { - val context = LocalContext.current - val baseStyle = SpanStyle(fontWeight = weight, fontSize = fontSize) - val annotatedString = buildAnnotatedString { - val fullText = element.fullText - var lastIndex = 0 - - val sortedRanges = element.urlRanges.sortedBy { it.second.first } - - for ((url, range) in sortedRanges) { - if (range.first > lastIndex) { - val textBefore = fullText.substring(lastIndex, range.first) - append(StyledText(text = textBefore, baseStyle = baseStyle)) - } - pushStringAnnotation("URL", url) - withStyle(baseStyle.plus(SpanStyle(color = linkColor))) { - append(url) - } - pop() - - lastIndex = range.last + 1 - } - - if (lastIndex < fullText.length) { - val textAfter = fullText.substring(lastIndex) - append(StyledText(text = textAfter, baseStyle = baseStyle)) - } - } - - ClickableText( - text = annotatedString, - onClick = { offset: Int -> - annotatedString.getStringAnnotations("URL", offset, offset) - .firstOrNull()?.let { annotation -> - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(annotation.item) - context.startActivity(intent) - } - }, - style = TextStyle( - fontSize = fontSize, - color = MaterialTheme.colorScheme.onSurface - ) - ) - } - - is HorizontalRule -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .height(1.dp) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) - ) - } - - is Table -> { - MarkdownTable( - headers = element.headers, - rows = element.rows, - fontSize = fontSize, - weight = weight - ) - } - - is NormalText -> { - Text( - text = StyledText( - text = element.text, - baseStyle = SpanStyle(fontSize = fontSize, fontWeight = weight) - ) - ) - } - } - // Add new line to selectionContainer but don't render it - if (content.lastIndex != index) { - Text( - text = "\n", - maxLines = 1 - ) - } - } -} - -@Composable -private fun StyledText( - text: String, - baseStyle: SpanStyle -): AnnotatedString { - val regex = Regex("(~~)(.*?)(~~)") - - return buildAnnotatedString { - var lastIndex = 0 - val matches = regex.findAll(text) - - matches.forEach { match -> - if (match.range.first > lastIndex) { - withStyle(baseStyle) { - append(text.substring(lastIndex, match.range.first)) - } - } - - val content = match.groupValues[2] - withStyle(baseStyle.plus(SpanStyle(textDecoration = TextDecoration.LineThrough))) { - append(content) - } - - lastIndex = match.range.last + 1 - } - - if (lastIndex < text.length) { - withStyle(baseStyle) { - append(text.substring(lastIndex)) - } - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/RowElements.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/RowElements.kt deleted file mode 100644 index 6e2ae91..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/RowElements.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.babelsoftware.airnote.presentation.components.markdown - -sealed interface MarkdownElement { - fun render(builder: StringBuilder) -} - -data class Heading(val level: Int, val text: String) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("#".repeat(level)).append(" $text\n\n") - } -} - -data class CheckboxItem(val text: String, var checked: Boolean = false, var index: Int) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("[${if (checked) "X" else " "}] $text\n") - } -} - -data class Quote(val level: Int, val text: String) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("> ${text}\n") - } -} - -data class ListItem(val text: String) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("- ${text}\n") - } -} - -data class CodeBlock(val code: String, val isEnded: Boolean = false, val firstLine : String) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("```") - isEnded.let { - builder.append(it) - } - builder.append("\n$code\n```\n") - } -} - -data class NormalText(val text: String) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("$text\n\n") - } -} - -data class ImageInsertion(val photoUri: String) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("!($photoUri)\n\n") - } -} - -data class Link(val fullText: String, val urlRanges: List>) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("$fullText\n\n") - } -} - -data class HorizontalRule(val fullText: String) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("---\n\n") - } -} - -data class Table(val headers: List, val rows: List>) : MarkdownElement { - override fun render(builder: StringBuilder) { - builder.append("| ${headers.joinToString(" | ")} |\n") - builder.append("| ${headers.joinToString(" | ") { "---" }} |\n") - rows.forEach { row -> - builder.append("| ${row.joinToString(" | ")} |\n") - } - builder.append("\n") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/WidgetText.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/WidgetText.kt deleted file mode 100644 index a5d64df..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/components/markdown/WidgetText.kt +++ /dev/null @@ -1,307 +0,0 @@ -package com.babelsoftware.airnote.presentation.components.markdown - -import android.graphics.BitmapFactory -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.Image -import androidx.glance.ImageProvider -import androidx.glance.LocalContext -import androidx.glance.appwidget.CheckBox -import androidx.glance.appwidget.cornerRadius -import androidx.glance.appwidget.lazy.LazyColumn -import androidx.glance.background -import androidx.glance.layout.Alignment -import androidx.glance.layout.Box -import androidx.glance.layout.ContentScale -import androidx.glance.layout.Row -import androidx.glance.layout.Spacer -import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.height -import androidx.glance.layout.padding -import androidx.glance.layout.width -import androidx.glance.layout.wrapContentHeight -import androidx.glance.text.FontFamily -import androidx.glance.text.FontWeight -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider -import com.babelsoftware.airnote.R -import java.io.File - - -@Composable -fun WidgetText( - modifier: GlanceModifier, - markdown: String, - weight: FontWeight = FontWeight.Normal, - fontSize: TextUnit = 16.sp, - color: ColorProvider, - onContentChange: (String) -> Unit = {} -) { - val lines = markdown.lines() - val lineProcessors = listOf( - HeadingProcessor(), - ListItemProcessor(), - CodeBlockProcessor(), - QuoteProcessor(), - ImageInsertionProcessor(), - CheckboxProcessor() - ) - val markdownBuilder = MarkdownBuilder(lines, lineProcessors) - markdownBuilder.parse() - MarkdownWidgetContent( - modifier = modifier, - content = markdownBuilder.content, - weight = weight, - lines = lines, - fontSize = fontSize, - color = color, - onContentChange = onContentChange - ) -} - - - - -@Composable -fun MarkdownWidgetContent( - modifier: GlanceModifier, - content: List, - weight: FontWeight, - lines: List, - color: ColorProvider, - fontSize: TextUnit, - onContentChange: (String) -> Unit -) { - LazyColumn( - modifier = modifier - ) { - items(content.size) { index -> - WidgetMarkdownElement( - modifier = modifier, - index = index, - color = color, - content = content, - weight = weight, - fontSize = fontSize, - lines = lines, - onContentChange = onContentChange - ) - } - } -} - - -@Composable -fun WidgetMarkdownElement( - modifier: GlanceModifier, - lines: List, - content: List, - index: Int, - color: ColorProvider, - weight: FontWeight, - fontSize: TextUnit, - onContentChange: (String) -> Unit -) { - val element = content[index] - val context = LocalContext.current - - Row( - modifier = modifier - ) { - when (element) { - is Heading -> { - Text( - text = element.text, - style = TextStyle( - fontSize = when (element.level) { - in 1..6 -> (28 - (2 * element.level)).sp - else -> fontSize - }, - fontWeight = weight, - color = color - ) - ) - } - is ImageInsertion -> { - val file = File(element.photoUri) - if (file.exists()) { - var bitmap = BitmapFactory.decodeFile(element.photoUri) - val maxFileSize = 15552000 - if (bitmap.byteCount < maxFileSize) { - Image( - provider = ImageProvider(bitmap), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = GlanceModifier - .padding(6.dp) - .wrapContentHeight() - ) - } else { - Text( - text = context.getString(R.string.unsuported_image_size), - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - color = GlanceTheme.colors.error - ) - ) - } - } - } - - is NormalText -> { - Text( - text = element.text, - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - color = color - ) - ) - } - is CheckboxItem -> { - MarkdownWidgetCheck( - content = { - Text( - text = element.text, - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - color = color - ) - ) - }, - checked = element.checked, - onCheckedChange = { newChecked -> - val newMarkdown = lines.toMutableList().apply { - this[element.index] = if (newChecked) { - "[X] ${element.text}" - } else { - "[ ] ${element.text}" - } - } - onContentChange(newMarkdown.joinToString("\n")) - } - ) - } - is ListItem -> { - Text( - text = "• ${element.text}", - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - color = color - ) - ) - } - is Quote -> { - Row( - horizontalAlignment = Alignment.CenterHorizontally, - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = GlanceModifier - .height(22.dp) - .width(6.dp) - .background(GlanceTheme.colors.tertiary) - .cornerRadius(12.dp) - ) {} - Spacer(modifier = GlanceModifier.width(6.dp)) - Text( - text = element.text, - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - color = color - ) - ) - } - } - is CodeBlock -> { - if (element.isEnded) { - MarkdownWidgetCodeBlock(color = GlanceTheme.colors.widgetBackground) { - Text( - text = element.code.dropLast(1), - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - fontFamily = FontFamily.Monospace, - color = color - ) - ) - } - } else { - Text( - text = element.firstLine, - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - color = color - ) - ) - } - } - - is Link -> { - Text( - text = element.fullText, - style = TextStyle( - fontSize = fontSize, - fontWeight = weight, - color = color - ) - ) - } - - is HorizontalRule -> TODO() - is Table -> TODO() - } - } -} - - - - -@Composable -fun MarkdownWidgetCheck( - content: @Composable () -> Unit, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalAlignment = Alignment.CenterHorizontally - ) { - CheckBox( - checked = checked, - onCheckedChange = { onCheckedChange(!checked) }) - - content() - } -} - -@Composable -fun MarkdownWidgetCodeBlock( - color: ColorProvider, - text: @Composable () -> Unit -) { - Box( - modifier = GlanceModifier.padding(top = 6.dp), - content = { - Box( - modifier = GlanceModifier - .padding(6.dp) - .cornerRadius(6.dp) - .background(color) - .fillMaxWidth(), - ) { - text() - } - } - ) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/AnimatedComposable.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/AnimatedComposable.kt deleted file mode 100644 index 9403202..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/AnimatedComposable.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.babelsoftware.airnote.presentation.navigation - -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.runtime.Composable -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDeepLink -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.babelsoftware.airnote.presentation.components.defaultScreenEnterAnimation -import com.babelsoftware.airnote.presentation.components.defaultScreenExitAnimation -import com.babelsoftware.airnote.presentation.components.slideScreenEnterAnimation -import com.babelsoftware.airnote.presentation.components.slideScreenExitAnimation - -fun NavGraphBuilder.animatedComposable( - route: String, - arguments: List = emptyList(), - deepLinks: List = emptyList(), - content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit -) = composable( - route = route, - arguments = arguments, - deepLinks = deepLinks, - enterTransition = { defaultScreenEnterAnimation() }, - exitTransition = { defaultScreenExitAnimation() }, - popEnterTransition = { defaultScreenEnterAnimation() }, - popExitTransition = { defaultScreenExitAnimation() }, - content = content -) - -fun NavGraphBuilder.slideInComposable( - route: String, - arguments: List = emptyList(), - deepLinks: List = emptyList(), - content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit -) = composable( - route = route, - arguments = arguments, - deepLinks = deepLinks, - enterTransition = { slideScreenEnterAnimation() }, - exitTransition = { defaultScreenExitAnimation() }, - popEnterTransition = { defaultScreenEnterAnimation() }, - popExitTransition = { slideScreenExitAnimation() }, - content = content -) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavHost.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavHost.kt deleted file mode 100644 index 4464734..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavHost.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.babelsoftware.airnote.presentation.navigation - -import androidx.activity.ComponentActivity -import androidx.activity.compose.LocalActivity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.NavType -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.babelsoftware.airnote.domain.model.Settings -import com.babelsoftware.airnote.presentation.screens.edit.EditNoteView -import com.babelsoftware.airnote.presentation.screens.home.HomeView -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.terms.TermsScreen - -@Composable -fun AppNavHost( - settingsModel: SettingsViewModel, - navController: NavHostController = rememberNavController(), - noteId: Int, - defaultRoute: String, - isShareIntent: Boolean -) { - val activity = LocalActivity.current as? ComponentActivity - val settings by settingsModel.settings - - LaunchedEffect(key1 = isShareIntent, key2 = navController.currentDestination) { - if (isShareIntent && navController.currentDestination?.route == defaultRoute) { - val editRoute = NavRoutes.Edit.createRoute(id = 0, encrypted = false, folderId = null) - navController.navigate(editRoute) { - popUpTo(defaultRoute) { inclusive = true } - } - } - } - - NavHost( - navController = navController, - startDestination = defaultRoute - ) { - addHomeRoute(navController, settingsModel, settings) - addTermsRoute(settingsModel) - addEditNoteRoute(settingsModel, noteId, activity) - addSettingsScreens(settingsModel, navController) - } -} - -private fun NavGraphBuilder.addHomeRoute( - navController: NavHostController, - settingsModel: SettingsViewModel, - settings: Settings -) { - animatedComposable(NavRoutes.Home.route) { - HomeView( - onSettingsClicked = { navController.navigate(NavRoutes.Settings.route) }, - onNoteClicked = { id, encrypted, folderId -> - navController.navigate( - NavRoutes.Edit.createRoute( - id, - encrypted, - folderId - ) - ) - }, - settingsModel = settingsModel, - settings = settings, - onNavigateToAbout = { navController.navigate(NavRoutes.About.route) }, - ) - } -} - -private fun NavGraphBuilder.addTermsRoute(settingsModel: SettingsViewModel) { - animatedComposable(NavRoutes.Terms.route) { - TermsScreen( - settingsModel - ) - } -} - -private fun NavGraphBuilder.addEditNoteRoute( - settingsModel: SettingsViewModel, - noteId: Int, - activity: ComponentActivity? -) { - animatedComposable( - route = NavRoutes.Edit.route, - arguments = listOf( - navArgument("id") { type = NavType.IntType }, - navArgument("encrypted") { type = NavType.BoolType }, - navArgument("folderId") { - type = NavType.StringType - nullable = true - defaultValue = null - } - ) - ) { backStackEntry -> - val id = backStackEntry.arguments?.getInt("id") ?: 0 - val encrypted = backStackEntry.arguments?.getBoolean("encrypted") ?: false - val folderIdString = backStackEntry.arguments?.getString("folderId") - val folderId = if (folderIdString == "null" || folderIdString == null) null else folderIdString.toLongOrNull() - - EditNoteView( - settingsViewModel = settingsModel, - id = if (noteId == -1) id else noteId, - folderId = folderId, - encrypted = encrypted, - isWidget = noteId != -1 - ) { - if (noteId == -1) { - (backStackEntry.destination.parent?.findNode(NavRoutes.Home.route) != null).let { - activity?.onBackPressedDispatcher?.onBackPressed() - } - } else { - activity?.finish() - } - } - } -} - -private fun NavGraphBuilder.addSettingsScreens( - settingsModel: SettingsViewModel, - navController: NavHostController -) { - settingScreens.forEach { (route, screen) -> - if (route == NavRoutes.Settings.route) { - slideInComposable(route) { - screen(settingsModel, navController) - } - } else { - animatedComposable(route) { - screen(settingsModel, navController) - } - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavRoutes.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavRoutes.kt deleted file mode 100644 index e4312fb..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/navigation/NavRoutes.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.babelsoftware.airnote.presentation.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NavController -import com.babelsoftware.airnote.presentation.screens.settings.MainSettings -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.AboutScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.CloudScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.ColorStylesScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.LanguageScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.MarkdownScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.PrivacyScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.SupportScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.AirNoteAiSettingsScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.DesktopModeSettingsScreen -import com.babelsoftware.airnote.presentation.screens.settings.settings.ToolsScreen - -enum class ActionType { - PASSCODE, - FINGERPRINT, - PATTERN -} - -sealed class NavRoutes(val route: String) { - data object Home : NavRoutes("home") - data object Edit : NavRoutes("edit_note_screen/{id}/{encrypted}?folderId={folderId}") { - fun createRoute(id: Int, encrypted: Boolean, folderId: Long? = null): String { - val baseRoute = "edit_note_screen/$id/$encrypted" - return if (folderId != null) { - "$baseRoute?folderId=$folderId" - } else { - baseRoute - } - } - } - data object Terms : NavRoutes("terms") - data object Settings : NavRoutes("settings") - data object ColorStyles : NavRoutes("settings/color_styles") - data object Language : NavRoutes("settings/language") - data object Cloud : NavRoutes("settings/cloud") - data object Privacy : NavRoutes("settings/privacy") - data object Markdown : NavRoutes("settings/markdown") - data object Tools : NavRoutes("settings/tools") - data object History : NavRoutes("settings/history") - data object Widgets : NavRoutes("settings/widgets") - data object About : NavRoutes("settings/about") - data object Support : NavRoutes("settings/support") - data object AirNoteAiSettings : NavRoutes("settings/ai_settings") - data object DesktopMode : NavRoutes("settings/desktop_mode") - data object DesktopModeSettings : NavRoutes("settings/desktop_mode_settings") - data object LockScreen : NavRoutes("settings/lock/{type}") { - fun createRoute(action: ActionType?) = "settings/lock/$action" - } -} - -val settingScreens = mapOf Unit>( - NavRoutes.Settings.route to { settings, navController -> MainSettings(settings, navController) }, - NavRoutes.ColorStyles.route to { settings, navController -> - ColorStylesScreen( - navController, - settings - ) - }, - NavRoutes.Language.route to { settings, navController -> - LanguageScreen( - navController, - settings - ) - }, - NavRoutes.Cloud.route to { settings, navController -> CloudScreen(navController, settings) }, - NavRoutes.Privacy.route to { settings, navController -> PrivacyScreen(navController, settings) }, - NavRoutes.Markdown.route to { settings, navController -> - MarkdownScreen( - navController, - settings - ) - }, - NavRoutes.Tools.route to { settings, navController -> ToolsScreen(navController, settings) }, - NavRoutes.About.route to { settings, navController -> AboutScreen(navController, settings) }, - NavRoutes.Support.route to { settings, navController -> SupportScreen(navController, settings) }, - NavRoutes.AirNoteAiSettings.route to { settings, navController -> - AirNoteAiSettingsScreen(navController, settings) - }, - NavRoutes.DesktopModeSettings.route to { settings, navController -> - DesktopModeSettingsScreen(navController, settings) - } -) diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/EditScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/EditScreen.kt deleted file mode 100644 index 6495eca..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/EditScreen.kt +++ /dev/null @@ -1,1481 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.edit - -import android.content.Intent -import android.icu.text.SimpleDateFormat -import androidx.activity.compose.LocalActivity -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.outlined.PushPin -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.ArrowUpward -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Balance -import androidx.compose.material.icons.rounded.BusinessCenter -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.EmojiEmotions -import androidx.compose.material.icons.rounded.EmojiSymbols -import androidx.compose.material.icons.rounded.Face -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Interests -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Numbers -import androidx.compose.material.icons.rounded.PushPin -import androidx.compose.material.icons.rounded.RemoveRedEye -import androidx.compose.material.icons.rounded.Spellcheck -import androidx.compose.material.icons.rounded.Summarize -import androidx.compose.material.icons.rounded.Translate -import androidx.compose.material.icons.rounded.Tune -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.repository.AiAction -import com.babelsoftware.airnote.data.repository.AiAssistantAction -import com.babelsoftware.airnote.data.repository.AiTone -import com.babelsoftware.airnote.domain.model.ChatMessage -import com.babelsoftware.airnote.domain.model.Participant -import com.babelsoftware.airnote.presentation.components.MoreButton -import com.babelsoftware.airnote.presentation.components.NavigationIcon -import com.babelsoftware.airnote.presentation.components.NotesScaffold -import com.babelsoftware.airnote.presentation.components.RedoButton -import com.babelsoftware.airnote.presentation.components.SaveButton -import com.babelsoftware.airnote.presentation.components.UndoButton -import com.babelsoftware.airnote.presentation.components.markdown.MarkdownText -import com.babelsoftware.airnote.presentation.screens.edit.components.CustomTextField -import com.babelsoftware.airnote.presentation.screens.edit.components.TextFormattingToolbar -import com.babelsoftware.airnote.presentation.screens.edit.model.EditViewModel -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.shapeManager -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox -import com.babelsoftware.airnote.presentation.screens.settings.widgets.copyToClipboard -import com.babelsoftware.airnote.presentation.theme.FontUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.Locale - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun EditNoteView( - id: Int, - folderId: Long?, - settingsViewModel: SettingsViewModel, - encrypted: Boolean = false, - isWidget: Boolean = false, - onClickBack: () -> Unit -) { - val viewModel: EditViewModel = hiltViewModel() - viewModel.updateIsEncrypted(encrypted) - val activity = LocalActivity.current - val intent = activity?.intent - val isLoading by viewModel.isLoading - - LaunchedEffect(key1 = id, key2 = intent) { - if (id == 0 && intent?.action == Intent.ACTION_SEND && "text/plain" == intent.type) { - viewModel.handleSharedIntent(intent) - } else { - viewModel.setupNoteData(id, folderId) - } - } - - ObserveLifecycleEvents(viewModel) - - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(key1 = true) { - viewModel.uiEvent.collect { message -> - snackbarHostState.showSnackbar(message) - } - } - - if (viewModel.isAiActionSheetVisible.value) { - AiActionSheet(viewModel = viewModel) - } - - // "Tüm Notu Çevir" için alt pencereyi geri getirdik. - if (viewModel.isTranslateSheetVisible.value) { - TranslateLanguageSheet(viewModel = viewModel) - } - - if (viewModel.titleSuggestions.value.isNotEmpty()) { - TitleSuggestionDialog( - suggestions = viewModel.titleSuggestions.value, - onDismiss = { viewModel.clearTitleSuggestions() }, - onSelect = { selectedTitle -> - viewModel.updateNoteName(TextFieldValue(selectedTitle)) - viewModel.clearTitleSuggestions() - } - ) - } - - val pagerState = rememberPagerState(initialPage = if (id == 0 || isWidget || settingsViewModel.settings.value.editMode) 0 else 1, pageCount = { 2 }) - - - val coroutineScope = rememberCoroutineScope() - - NotesScaffold( - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - topBar = { - if (!isLoading && !settingsViewModel.settings.value.minimalisticMode) TopBar( - pagerState, - coroutineScope, - onClickBack, - viewModel - ) - }, - content = { - if (isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - PagerContent(pagerState, viewModel, settingsViewModel, onClickBack) - } - } - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun TopBarActions(pagerState: PagerState, onClickBack: () -> Unit, viewModel: EditViewModel) { - val context = LocalContext.current - val isAssistantStreaming = viewModel.isAiAssistantStreaming.value - val isMenuExpanded = viewModel.isAiAssistantSheetVisible.value - - val actionButtonPlaceholder = @Composable { - Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { - if (isAssistantStreaming) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.5.dp - ) - } else { - IconButton(onClick = { viewModel.toggleAiAssistantSheet(true) }) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = "AI Asistanı" - ) - } - } - AiCommandMenu( - viewModel = viewModel, - expanded = isMenuExpanded, - onDismiss = { viewModel.toggleAiAssistantSheet(false) } - ) - } - } - - when (pagerState.currentPage) { - - 0 -> { // Edit Mode - Row(verticalAlignment = Alignment.CenterVertically) { - actionButtonPlaceholder() - SaveButton { onClickBack() } - } - } - 1 -> { // Preview Mode - Row(verticalAlignment = Alignment.CenterVertically) { - actionButtonPlaceholder() - MoreButton { - viewModel.toggleEditMenuVisibility(true) - } - DropdownMenu( - expanded = viewModel.isEditMenuVisible.value, - onDismissRequest = { viewModel.toggleEditMenuVisibility(false) } - ) { - if (viewModel.noteId.value != 0) { - DropdownMenuItem( - text = { Text(stringResource(R.string.delete)) }, - leadingIcon = { Icon(Icons.Rounded.Delete, contentDescription = "Delete")}, - onClick = { - viewModel.toggleEditMenuVisibility(false) - viewModel.deleteNote(viewModel.noteId.value) - onClickBack() - } - ) - } - DropdownMenuItem( - text = { Text(stringResource(id = R.string.pinned)) }, - leadingIcon = { Icon(if (viewModel.isPinned.value) Icons.Rounded.PushPin else Icons.Outlined.PushPin, contentDescription = "Pin")}, - onClick = { viewModel.toggleNotePin(!viewModel.isPinned.value) } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.copy)) }, - leadingIcon = { Icon(Icons.Rounded.ContentCopy, contentDescription = "Copy")}, - onClick = { - copyToClipboard(context, viewModel.noteDescription.value.text) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.information)) }, - leadingIcon = { Icon(Icons.Rounded.Info, contentDescription = "Information")}, - onClick = { - viewModel.toggleEditMenuVisibility(false) - viewModel.toggleNoteInfoVisibility(true) - } - ) - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun PagerContent(pagerState: PagerState, viewModel: EditViewModel, settingsViewModel: SettingsViewModel, onClickBack: () -> Unit) { - HorizontalPager( - state = pagerState, - modifier = Modifier, - userScrollEnabled = !settingsViewModel.settings.value.disableSwipeInEditMode - ) { page -> - when (page) { - 0 -> EditScreen(viewModel, settingsViewModel, pagerState, onClickBack) - 1 -> PreviewScreen(viewModel, settingsViewModel, pagerState, onClickBack) - } - } -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) -@Composable -fun TopBar( - pagerState: PagerState, - coroutineScope: CoroutineScope, - onClickBack: () -> Unit, - viewModel: EditViewModel -) { - CenterAlignedTopAppBar( - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - title = { - ModeButton( - pagerState = pagerState, - coroutineScope = coroutineScope, - isUndoRedoVisible = viewModel.isDescriptionInFocus.value - ) - }, - navigationIcon = { - Row { - NavigationIcon(onClickBack) - if (pagerState.currentPage == 0 && viewModel.isDescriptionInFocus.value) { - UndoButton { viewModel.undo() } - } - } - }, - actions = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (pagerState.currentPage == 0 && viewModel.isDescriptionInFocus.value) { - RedoButton { viewModel.redo() } - } - TopBarActions(pagerState, onClickBack, viewModel) - } - } - ) -} - -@Composable -fun ObserveLifecycleEvents(viewModel: EditViewModel) { - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_STOP) { - viewModel.saveNote(viewModel.noteId.value) - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BottomModal(viewModel: EditViewModel, settingsViewModel: SettingsViewModel) { - ModalBottomSheet( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - onDismissRequest = { viewModel.toggleNoteInfoVisibility(false) } - ) { - val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - - Column( - modifier = Modifier.padding(20.dp, 0.dp, 20.dp, 20.dp) - ) { - SettingsBox( - size = 8.dp, - title = stringResource(R.string.created_time), - icon = IconResource.Vector(Icons.Rounded.Numbers), - actionType = ActionType.TEXT, - radius = shapeManager( - isFirst = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - customText = sdf.format(viewModel.noteCreatedTime.value).toString() - ) - SettingsBox( - size = 8.dp, - title = stringResource(R.string.words), - icon = IconResource.Vector(Icons.Rounded.Numbers), - radius = shapeManager(radius = settingsViewModel.settings.value.cornerRadius), - actionType = ActionType.TEXT, - customText = if (viewModel.noteDescription.value.text != "") viewModel.noteDescription.value.text.split( - "\\s+".toRegex() - ).size.toString() else "0" - ) - SettingsBox( - size = 8.dp, - title = stringResource(R.string.characters), - icon = IconResource.Vector(Icons.Rounded.Numbers), - actionType = ActionType.TEXT, - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - customText = viewModel.noteDescription.value.text.length.toString() - ) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun MinimalisticMode( - alignment : Alignment.Vertical = Alignment.CenterVertically, - viewModel: EditViewModel, - modifier: Modifier = Modifier, - isEnabled: Boolean, pagerState: PagerState, - isExtremeAmoled: Boolean, - showOnlyDescription: Boolean = false, - onClickBack: () -> Unit, content: @Composable () -> Unit, -) { - val coroutineScope = rememberCoroutineScope() - Row( - verticalAlignment = alignment, - modifier = modifier - .fillMaxWidth() - .then(if (showOnlyDescription) Modifier.padding(top = 8.dp) else Modifier) - ) { - if (!showOnlyDescription) { - if (isEnabled) NavigationIcon(onClickBack) - if (isEnabled && viewModel.isDescriptionInFocus.value) UndoButton { viewModel.undo() } - content() - if (isEnabled) TopBarActions(pagerState, onClickBack, viewModel) - if (isEnabled) ModeButton(pagerState, coroutineScope, isUndoRedoVisible = viewModel.isDescriptionInFocus.value) - } else { - Column { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - if (isEnabled) NavigationIcon(onClickBack) - Spacer(modifier = Modifier.weight(1f)) - if (isEnabled) ModeButton(pagerState, coroutineScope, isUndoRedoVisible = viewModel.isDescriptionInFocus.value) - if (isEnabled) TopBarActions(pagerState, onClickBack, viewModel) - } - content() - } - } - } -} - - - -@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) -@Composable -fun EditScreen(viewModel: EditViewModel, settingsViewModel: SettingsViewModel, pagerState: PagerState, onClickBack: () -> Unit) { - - if (viewModel.isAiLoading.value) { - LoadingOverlay() - } - - if (viewModel.aiResultText.value != null) { - AiResultDialog(viewModel = viewModel) - } - - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(16.dp, 16.dp, 16.dp, 0.dp) - ) { - MarkdownBox( - isExtremeAmoled = settingsViewModel.settings.value.extremeAmoledMode, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - content = { - MinimalisticMode( - viewModel = viewModel, - modifier = Modifier.padding(top = 2.dp), - isEnabled = settingsViewModel.settings.value.minimalisticMode, - pagerState = pagerState, - isExtremeAmoled = settingsViewModel.settings.value.extremeAmoledMode, - onClickBack = { onClickBack() } - ) { - println(settingsViewModel.settings.value.useMonoSpaceFont) - CustomTextField( - value = viewModel.noteName.value, - modifier = Modifier.weight(1f), - onValueChange = { viewModel.updateNoteName(it) }, - placeholder = stringResource(R.string.name), - useMonoSpaceFont = settingsViewModel.settings.value.useMonoSpaceFont - ) - } - } - ) - MarkdownBox( - isExtremeAmoled = settingsViewModel.settings.value.extremeAmoledMode, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - modifier = Modifier - .weight(1f) - .onFocusChanged { focusState -> - if (!viewModel.isMinimalAiUiVisible.value) { - viewModel.toggleIsDescriptionInFocus(focusState.isFocused) - } - }, - content = { - CustomTextField( - value = viewModel.noteDescription.value, - onValueChange = { viewModel.updateNoteDescription(it) }, - modifier = Modifier.fillMaxSize(), - placeholder = stringResource(R.string.description), - useMonoSpaceFont = settingsViewModel.settings.value.useMonoSpaceFont - ) - } - ) - } - BottomBarContainer(viewModel, settingsViewModel) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun BottomBarContainer(viewModel: EditViewModel, settingsViewModel: SettingsViewModel) { - val isAiVisible by viewModel.isMinimalAiUiVisible - val showBar = (viewModel.isDescriptionInFocus.value || isAiVisible) && settingsViewModel.settings.value.isMarkdownEnabled - - AnimatedVisibility( - visible = showBar, - enter = slideInVertically { it }, - exit = slideOutVertically { it } - ) { - AnimatedContent( - targetState = isAiVisible, - transitionSpec = { - if (targetState) { - (fadeIn(animationSpec = tween(220, delayMillis = 90)) + slideInVertically(initialOffsetY = { it }, animationSpec = tween(220, delayMillis = 90))) - .togetherWith(fadeOut(animationSpec = tween(90))) - } else { - (fadeIn(animationSpec = tween(220, delayMillis = 90)) + slideInVertically(initialOffsetY = { -it }, animationSpec = tween(220, delayMillis = 90))) - .togetherWith(fadeOut(animationSpec = tween(90))) - } - }, - label = "BottomBarAnimation" - ) { targetState -> - if (targetState) { - MinimalAiChatInterface(viewModel = viewModel, settingsViewModel = settingsViewModel, isDreamJournalMode = viewModel.isDreamJournalMode.value) - } else { - TextFormattingToolbar(viewModel = viewModel) - } - } - } -} - - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun PreviewScreen(viewModel: EditViewModel, settingsViewModel: SettingsViewModel, pagerState: PagerState, onClickBack: () -> Unit) { - if (viewModel.isNoteInfoVisible.value) BottomModal(viewModel, settingsViewModel) - - val focusManager = LocalFocusManager.current - focusManager.clearFocus() - val showOnlyDescription = viewModel.noteName.value.text.isNotBlank() - - Column( - modifier = Modifier.padding(16.dp), - ) { - if (showOnlyDescription) { - MarkdownBox( - isExtremeAmoled = settingsViewModel.settings.value.extremeAmoledMode, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - content = { - MinimalisticMode( - viewModel = viewModel, - isEnabled = settingsViewModel.settings.value.minimalisticMode, - pagerState = pagerState, - isExtremeAmoled = settingsViewModel.settings.value.extremeAmoledMode, - onClickBack = { onClickBack() } - ) { - MarkdownText( - markdown = viewModel.noteName.value.text, - isEnabled = settingsViewModel.settings.value.isMarkdownEnabled, - weight = FontWeight.Bold, - fontSize = FontUtils.getTitleFontSize(settingsViewModel), - modifier = Modifier - .padding(16.dp) - .align(Alignment.CenterHorizontally), - onContentChange = { viewModel.updateNoteName(TextFieldValue(text = it)) }, - radius = settingsViewModel.settings.value.cornerRadius, - settingsViewModel = settingsViewModel - ) - Spacer(modifier = Modifier.weight(1f)) - } - } - ) - } - MarkdownBox( - isExtremeAmoled = settingsViewModel.settings.value.extremeAmoledMode, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = (showOnlyDescription), - isBoth = (!showOnlyDescription) - ), - modifier = Modifier.fillMaxSize(), - content = { - MinimalisticMode( - alignment = Alignment.Top, - viewModel = viewModel, - isExtremeAmoled = settingsViewModel.settings.value.extremeAmoledMode, - isEnabled = settingsViewModel.settings.value.minimalisticMode && !showOnlyDescription, - pagerState = pagerState, - showOnlyDescription = !showOnlyDescription, - onClickBack = { onClickBack() }, - ) { - MarkdownText( - radius = settingsViewModel.settings.value.cornerRadius, - markdown = viewModel.noteDescription.value.text, - isEnabled = settingsViewModel.settings.value.isMarkdownEnabled, - fontSize = FontUtils.getBodyFontSize(settingsViewModel), - modifier = Modifier - .padding( - 16.dp, - top = if (showOnlyDescription) 16.dp else 6.dp, - 16.dp, - 16.dp - ) - .weight(1f), - onContentChange = { newText -> - val synchronizedText = synchronizeStrikethroughs(newText) - viewModel.updateNoteDescription(TextFieldValue(text = synchronizedText)) - }, - settingsViewModel = settingsViewModel - ) - } - } - ) - } -} - -@Composable -fun MarkdownBox( - isExtremeAmoled: Boolean, - modifier: Modifier = Modifier, - shape: RoundedCornerShape = RoundedCornerShape(0.dp), - content: @Composable () -> Unit, -) { - ElevatedCard( - shape = shape, - modifier = modifier - .clip(shape) - .then( - if (isExtremeAmoled) { - Modifier.border( - 1.5.dp, - shape = shape, - color = MaterialTheme.colorScheme.surfaceContainerHighest - ) - } else Modifier - ), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - elevation = CardDefaults.cardElevation(defaultElevation = if (!isExtremeAmoled) 1.dp else 0.dp), // Shadow - - ) { - content() - } - Spacer(modifier = Modifier.height(3.dp)) -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) -@Composable -fun ModeButton( - pagerState: PagerState, - coroutineScope: CoroutineScope, - isUndoRedoVisible: Boolean -) { - val options = listOf( - stringResource(R.string.edit) to Icons.Rounded.Edit, - stringResource(R.string.preview) to Icons.Rounded.RemoveRedEye - ) - - var widthPx by remember { mutableFloatStateOf(0f) } - val density = LocalDensity.current - - Box( - modifier = Modifier - .height(44.dp) - .clip(RoundedCornerShape(100)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(4.dp) - .onSizeChanged { widthPx = it.width.toFloat() } - ) { - val indicatorWidth = widthPx / 2 - - val indicatorOffset by remember(pagerState, widthPx) { - derivedStateOf { - val pageOffset = pagerState.currentPage + pagerState.currentPageOffsetFraction - pageOffset.coerceIn(0f, 1f) * (widthPx / 2) - } - } - - if (widthPx > 0) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(with(density) { indicatorWidth.toDp() }) - .offset { IntOffset(x = indicatorOffset.toInt(), y = 0) } - .shadow(2.dp, RoundedCornerShape(100)) - .clip(RoundedCornerShape(100)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - ) - } - - Row(modifier = Modifier.fillMaxSize()) { - options.forEachIndexed { index, (text, icon) -> - val isSelected = pagerState.currentPage == index - val contentColor = if (isSelected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .clip(RoundedCornerShape(100)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - coroutineScope.launch { - pagerState.animateScrollToPage(index) - } - }, - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Icon( - imageVector = icon, - contentDescription = text, - modifier = Modifier.size(18.dp), - tint = contentColor - ) - AnimatedVisibility(visible = !isUndoRedoVisible) { - Row { - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - color = contentColor, - maxLines = 1 - ) - } - } - } - } - } - } - } -} - -@Composable -private fun TitleSuggestionDialog( - suggestions: List, - onDismiss: () -> Unit, - onSelect: (String) -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.ai_suggestion)) }, - text = { - LazyColumn { - items(suggestions) { suggestion -> - Text( - text = suggestion, - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(suggestion) } - .padding(vertical = 8.dp) - ) - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.close)) - } - } - ) -} - -@Composable -private fun CommandMenuItem( - title: String, - subtitle: String? = null, - onClick: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { - Text(text = title, style = MaterialTheme.typography.bodyLarge) - if (subtitle != null) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -fun AiCommandMenu( - viewModel: EditViewModel, - expanded: Boolean, - onDismiss: () -> Unit -) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismiss, - modifier = Modifier.widthIn(min = 280.dp) - ) { - CommandMenuItem( - title = stringResource(R.string.ai_command_pros_cons), - onClick = { viewModel.executeAiAssistantAction(AiAssistantAction.PROS_AND_CONS) } - ) - CommandMenuItem( - title = stringResource(R.string.ai_command_todo), - onClick = { viewModel.executeAiAssistantAction(AiAssistantAction.CREATE_TODO_LIST) } - ) - CommandMenuItem( - title = stringResource(R.string.ai_command_simpler), - subtitle = stringResource(R.string.ai_command_simpler_subtitle), - onClick = { viewModel.executeAiAssistantAction(AiAssistantAction.SIMPLIFY) } - ) - CommandMenuItem( - title = stringResource(R.string.ai_command_title), - onClick = { viewModel.executeAiAssistantAction(AiAssistantAction.SUGGEST_A_TITLE) } - ) - CommandMenuItem( - title = stringResource(R.string.translate_all_note), - onClick = { - onDismiss() - viewModel.onTranslateClicked(forSelection = false) - } - ) - } -} - -@Composable -private fun AiResultDialog(viewModel: EditViewModel) { - val aiResult = viewModel.aiResultText.value ?: return - - AlertDialog( - onDismissRequest = { viewModel.clearAiResult() }, - title = { Text(stringResource(R.string.ai_suggestion)) }, - text = { Text(aiResult) }, - confirmButton = { - TextButton(onClick = { viewModel.replaceWithAiResult() }) { - Text(stringResource(R.string.change)) - } - }, - dismissButton = { - TextButton(onClick = { viewModel.clearAiResult() }) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - -@Composable -private fun LoadingOverlay() { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)) - .clickable(enabled = false, onClick = {}) // Prevents back clicks - .zIndex(10f), // To appear above everything - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AiActionSheet(viewModel: EditViewModel) { - if (!viewModel.isAiActionSheetVisible.value) return - - LaunchedEffect(viewModel.isAiActionSheetVisible.value) { - if (viewModel.isAiActionSheetVisible.value) { - delay(300L) - viewModel.markSheetAsReadyForInteraction() - } - } - - val actions = AiAction.values() - - ModalBottomSheet( - onDismissRequest = { viewModel.toggleAiActionSheet(false) } - ) { - LazyColumn { - items(actions) { action -> - ListItem( - headlineContent = { Text(text = action.getDisplayName()) }, - leadingContent = { - if (action == AiAction.CHANGE_TONE) { - Icon( - imageVector = Icons.Rounded.Tune, - contentDescription = null - ) - } - }, - modifier = Modifier.clickable( - enabled = viewModel.isSheetReadyForInteraction.value - ) { - if (viewModel.isSheetReadyForInteraction.value) { - viewModel.executeAiAction(action = action, tone = null) - } - } - ) - } - } - } -} - -private enum class MinimalAiUiMode { - MAIN, - TONE, - TRANSLATE -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MinimalAiChatInterface(viewModel: EditViewModel, settingsViewModel: SettingsViewModel, isDreamJournalMode: Boolean = false) { - val isTextSelected = viewModel.noteDescription.value.selection.collapsed.not() - val isChatActive = viewModel.isMinimalChatActive.value - val chatText = viewModel.minimalAiChatText.value - val chatHistory = viewModel.minimalAiChatHistory.value - val isLoading = viewModel.isMinimalAiLoading.value - val chatScrollState = rememberScrollState() - val cornerRadius = settingsViewModel.settings.value.cornerRadius - var currentUiState by remember { mutableStateOf(MinimalAiUiMode.MAIN) } - - LaunchedEffect(isTextSelected) { - if (!isTextSelected) { - currentUiState = MinimalAiUiMode.MAIN - } - } - - LaunchedEffect(chatHistory.size) { - chatScrollState.animateScrollTo(chatScrollState.maxValue) - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - shape = RoundedCornerShape(cornerRadius.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .animateContentSize() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (isDreamJournalMode) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = "AirNote Kai - Dream Edition", - tint = Color(0xFFB39DDB), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.dream_journal_ai_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = Color(0xFFB39DDB) - ) - } - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = "AirNote Kai", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "AirNote Kai", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - } - Spacer(modifier = Modifier.weight(1f)) - IconButton( - onClick = { - viewModel.toggleMinimalAiUi(false) - currentUiState = MinimalAiUiMode.MAIN - }, - ) { - Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) - } - } - - if (isDreamJournalMode) { - AnimatedVisibility(visible = !isChatActive) { - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - InputActionButton( - text = stringResource(R.string.interpret_my_dream), - icon = Icons.Rounded.Interests, - enabled = true, - onClick = { - viewModel.executeDreamAnalysis() - } - ) - } - item { - InputActionButton( - text = stringResource(R.string.explain_symbols), - icon = Icons.Rounded.EmojiSymbols, - enabled = true, - onClick = { - viewModel.executeDreamSymbolAnalysis() - } - ) - } - item { - InputActionButton( - text = stringResource(R.string.emotional_analysis), - icon = Icons.Rounded.EmojiEmotions, - enabled = true, - onClick = { - viewModel.executeDreamEmotionAnalysis() - } - ) - } - } - } - } else { - AnimatedVisibility(visible = !isChatActive) { - AnimatedContent( - targetState = currentUiState, - label = "AiActionsAnimation", - transitionSpec = { - if (targetState != MinimalAiUiMode.MAIN && initialState == MinimalAiUiMode.MAIN) { - (slideInHorizontally { width -> width } + fadeIn()).togetherWith(slideOutHorizontally { width -> -width } + fadeOut()) - } else { - (slideInHorizontally { width -> -width } + fadeIn()).togetherWith(slideOutHorizontally { width -> width } + fadeOut()) - } - } - ) { state -> - when (state) { - MinimalAiUiMode.MAIN -> { - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(AiAction.values()) { action -> - InputActionButton( - text = action.getDisplayName(), - icon = action.getIcon(), - enabled = isTextSelected, - onClick = { - when (action) { - AiAction.CHANGE_TONE -> currentUiState = MinimalAiUiMode.TONE - AiAction.TRANSLATE -> { - viewModel.onTranslateClicked(forSelection = true) - currentUiState = MinimalAiUiMode.TRANSLATE - } - else -> viewModel.executeAiAction(action = action, tone = null) - } - } - ) - } - } - } - MinimalAiUiMode.TONE -> { - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - item { - FilledIconButton( - onClick = { currentUiState = MinimalAiUiMode.MAIN }, - modifier = Modifier.size(34.dp), - colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(18.dp) - ) - } - } - items(AiTone.values()) { tone -> - InputActionButton( - text = tone.getDisplayName(), - icon = tone.getIcon(), - enabled = true, - onClick = { - viewModel.executeAiAction(action = AiAction.CHANGE_TONE, tone = tone) - currentUiState = MinimalAiUiMode.MAIN - } - ) - } - } - } - MinimalAiUiMode.TRANSLATE -> { - val languages = viewModel.downloadedLanguages.value - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - item { - FilledIconButton( - onClick = { currentUiState = MinimalAiUiMode.MAIN }, - modifier = Modifier.size(34.dp), - colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(18.dp) - ) - } - } - if (languages.isEmpty()) { - item { - Text( - text = stringResource(R.string.no_downloaded_languages), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp) - ) - } - } else { - items(languages) { (code, name) -> - InputActionButton( - text = name, - icon = Icons.Rounded.Translate, - enabled = true, - onClick = { - viewModel.executeTranslation(code) - currentUiState = MinimalAiUiMode.MAIN - } - ) - } - } - } - } - } - } - } - } - - AnimatedVisibility(visible = isChatActive) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - .verticalScroll(chatScrollState) - .padding(horizontal = 16.dp) - ) { - chatHistory.forEach { message -> - ChatMessageItem(message = message) - } - } - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.5f) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = chatText, - onValueChange = { viewModel.updateMinimalAiChatText(it) }, - modifier = Modifier.weight(1f), - placeholder = { Text(stringResource(R.string.ask_airnote_ai)) }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary, - ), - maxLines = 5 - ) - - Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) { - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } else { - IconButton( - onClick = { viewModel.sendMinimalAiMessage() }, - enabled = chatText.isNotBlank() - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.send), - tint = if (chatText.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } - } -} - -@Composable -private fun InputActionButton( - text: String, - icon: ImageVector, - onClick: () -> Unit, - enabled: Boolean = true -) { - TextButton( - onClick = onClick, - enabled = enabled, - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.textButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurface, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.5f), - disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - modifier = Modifier.alpha(if (enabled) 1f else 0.5f) - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) - } -} - - -@Composable -fun ChatMessageItem(message: ChatMessage) { - val isUser = message.participant == Participant.USER - val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart - val bubbleColor = when (message.participant) { - Participant.USER -> MaterialTheme.colorScheme.primaryContainer - Participant.MODEL -> MaterialTheme.colorScheme.surfaceContainerHigh - Participant.ERROR -> MaterialTheme.colorScheme.errorContainer - } - val textColor = when (message.participant) { - Participant.USER -> MaterialTheme.colorScheme.onPrimaryContainer - Participant.MODEL -> MaterialTheme.colorScheme.onSurface - Participant.ERROR -> MaterialTheme.colorScheme.onErrorContainer - } - val bubbleShape = RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = if (isUser) 16.dp else 0.dp, - bottomEnd = if (isUser) 0.dp else 16.dp - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - contentAlignment = alignment - ) { - if (message.isLoading) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } else { - Surface( - color = bubbleColor, - shape = bubbleShape, - modifier = Modifier.widthIn(max = 300.dp) - ) { - Text( - text = message.text.replace(Regex("[*#]"), "").trim(), - style = MaterialTheme.typography.bodyLarge, - color = textColor, - modifier = Modifier.padding(12.dp) - ) - } - } - } -} - -@Composable -fun AiAction.getIcon(): ImageVector { - return when (this) { - AiAction.IMPROVE_WRITING -> Icons.Rounded.Spellcheck - AiAction.SUMMARIZE -> Icons.Rounded.Summarize - AiAction.MAKE_SHORTER -> Icons.Rounded.ArrowDownward - AiAction.MAKE_LONGER -> Icons.Rounded.ArrowUpward - AiAction.CHANGE_TONE -> Icons.Rounded.Tune - AiAction.TRANSLATE -> Icons.Rounded.Translate - } -} - - -@Composable -fun AiAction.getDisplayName(): String { - return when (this) { - AiAction.IMPROVE_WRITING -> stringResource(id = R.string.ai_action_improve_writing) - AiAction.SUMMARIZE -> stringResource(id = R.string.ai_action_summarize) - AiAction.MAKE_SHORTER -> stringResource(id = R.string.ai_action_make_shorter) - AiAction.MAKE_LONGER -> stringResource(id = R.string.ai_action_make_longer) - AiAction.CHANGE_TONE -> stringResource(id = R.string.ai_action_change_tone) - AiAction.TRANSLATE -> stringResource(id = R.string.translate) - } -} - -@Composable -fun AiTone.getIcon(): ImageVector { - return when (this) { - AiTone.FORMAL -> Icons.Rounded.BusinessCenter - AiTone.BALANCED -> Icons.Rounded.Balance - AiTone.FRIENDLY -> Icons.Rounded.Face - } -} - -@Composable -fun AiTone.getDisplayName(): String { - return when (this) { - AiTone.FORMAL -> stringResource(id = R.string.ai_tone_formal) - AiTone.BALANCED -> stringResource(id = R.string.ai_tone_balanced) - AiTone.FRIENDLY -> stringResource(id = R.string.ai_tone_friendly) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TranslateLanguageSheet(viewModel: EditViewModel) { - val languages = viewModel.downloadedLanguages.value - ModalBottomSheet(onDismissRequest = { viewModel.toggleTranslateSheet(false) }) { - if (languages.isEmpty()) { - Text( - text = stringResource(R.string.no_downloaded_languages), - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(16.dp)) - } else { - LazyColumn { - items(languages) { (code, name) -> - ListItem( - headlineContent = { Text(name) }, - modifier = Modifier.clickable { - viewModel.executeTranslation(code) - } - ) - } - } - Spacer(modifier = Modifier.height(16.dp)) - } - } -} - -private fun synchronizeStrikethroughs(newMarkdown: String): String { - return newMarkdown.lines().joinToString("\n") { line -> - val checkedRegex = Regex("""^(\s*\[[xX]\]\s+)(.*)""") - val uncheckedRegex = Regex("""^(\s*\[ \]\s+)(.*)""") - val checkedMatch = checkedRegex.find(line) - val uncheckedMatch = uncheckedRegex.find(line) - - when { - checkedMatch != null -> { - val prefix = checkedMatch.groupValues[1] - val content = checkedMatch.groupValues[2] - - if (content.startsWith("~~") && content.endsWith("~~")) { - line - } else { - "$prefix~~$content~~" - } - } - uncheckedMatch != null -> { - val prefix = uncheckedMatch.groupValues[1] - val content = uncheckedMatch.groupValues[2] - - if (content.startsWith("~~") && content.endsWith("~~")) { - val strippedContent = content.substring(2, content.length - 2) - "$prefix$strippedContent" - } else { - line - } - } - - else -> line - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomIconButton.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomIconButton.kt deleted file mode 100644 index 0e3b52f..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomIconButton.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.edit.components - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -@Composable -fun CustomIconButton( - shape: RoundedCornerShape, - elevation: Dp, - icon: ImageVector, - onClick: () -> Unit -) { - ElevatedCard( - shape = shape, - elevation = CardDefaults.cardElevation(defaultElevation = elevation), - modifier = Modifier - .clip(shape) - .clickable { onClick() } - ) { - Icon( - imageVector = icon, - contentDescription = "", - modifier = Modifier.padding(16.dp), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomTextField.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomTextField.kt deleted file mode 100644 index 32b1ff2..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/CustomTextField.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.edit.components - -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.autofill.AutofillType -import com.babelsoftware.airnote.presentation.components.AutoFillRequestHandler -import com.babelsoftware.airnote.presentation.components.connectNode -import com.babelsoftware.airnote.presentation.components.defaultFocusChangeAutoFill - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun CustomTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - placeholder: String, - shape: RoundedCornerShape = RoundedCornerShape(0.dp), - interactionSource: MutableInteractionSource = MutableInteractionSource(), - singleLine: Boolean = false, - modifier: Modifier = Modifier, - hideContent: Boolean = false, - useMonoSpaceFont: Boolean = false, - autofillTypes: List? = null -) { - val autoFillHandler = if (autofillTypes != null) AutoFillRequestHandler( - autofillTypes = autofillTypes, - onFill = { - onValueChange(TextFieldValue(it)) - } - ) else null - - val visualTransformation = if (hideContent) { - PasswordVisualTransformation() - } else { - VisualTransformation.None - } - - // Determine if this is a password field based on autofill types or hideContent flag - val isPasswordField = hideContent || (autofillTypes != null && - (autofillTypes.contains(AutofillType.Password) || - autofillTypes.contains(AutofillType.NewPassword))) - - - - TextField( - value = value, - textStyle = if (useMonoSpaceFont) LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace) else LocalTextStyle.current, - visualTransformation = visualTransformation, - onValueChange = { - onValueChange(it) - if (it.text.isEmpty()) autoFillHandler?.requestVerifyManual() - }, - interactionSource = interactionSource, - keyboardOptions = KeyboardOptions( - autoCorrect = !isPasswordField, - keyboardType = if (isPasswordField) KeyboardType.Password else KeyboardType.Text, - capitalization = if (isPasswordField) KeyboardCapitalization.None else KeyboardCapitalization.Sentences, - ), - modifier = modifier - .fillMaxWidth() - .clip(shape) - .then( - if (autoFillHandler != null) { - Modifier.Companion - .connectNode(handler = autoFillHandler) - .defaultFocusChangeAutoFill(handler = autoFillHandler) - } else Modifier - ), - - singleLine = singleLine, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - ), - placeholder = { - Text(placeholder) - } - ) -} - -// CustomTextField.kt - -class UndoRedoState { - var input by mutableStateOf(TextFieldValue("")) - private val undoHistory = ArrayDeque() - private val redoHistory = ArrayDeque() - - init { - undoHistory.add(input) - } - - fun onInput(value: TextFieldValue) { - if (undoHistory.lastOrNull()?.text != value.text) { - undoHistory.add(value) - redoHistory.clear() // A new write action clears the fast-forward history. - } - input = value - } - - fun undo() { - if (undoHistory.size > 1) { - val lastState = undoHistory.removeLastOrNull() - lastState?.let { - redoHistory.add(it) - } - - val previousState = undoHistory.lastOrNull() - previousState?.let { - input = it - } - } - } - - fun redo() { - val redoState = redoHistory.removeLastOrNull() - redoState?.let { - undoHistory.add(it) - input = it - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/TextFormattingToolbar.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/TextFormattingToolbar.kt deleted file mode 100644 index fd17cd6..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/components/TextFormattingToolbar.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.edit.components - -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.FormatListBulleted -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.CheckBox -import androidx.compose.material.icons.rounded.Code -import androidx.compose.material.icons.rounded.DataObject -import androidx.compose.material.icons.rounded.FormatBold -import androidx.compose.material.icons.rounded.FormatItalic -import androidx.compose.material.icons.rounded.FormatListNumbered -import androidx.compose.material.icons.rounded.FormatQuote -import androidx.compose.material.icons.rounded.FormatUnderlined -import androidx.compose.material.icons.rounded.Highlight -import androidx.compose.material.icons.rounded.HorizontalRule -import androidx.compose.material.icons.rounded.Image -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.StrikethroughS -import androidx.compose.material.icons.rounded.TableChart -import androidx.compose.material.icons.rounded.Title -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.presentation.components.getExternalStorageDir -import com.babelsoftware.airnote.presentation.components.getImageName -import com.babelsoftware.airnote.presentation.screens.edit.model.EditViewModel -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream - -data class ToolbarItem( - val icon: ImageVector, - val contentDescription: String, - val color: Color, - val onClickAction: () -> Unit, -) - -@Composable -fun TextFormattingToolbar(viewModel: EditViewModel) { - val colorIcon = MaterialTheme.colorScheme.onSurfaceVariant - val context = LocalContext.current - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - uri?.let { - val savedUri = saveImageToAppStorage(context, it) - viewModel.insertText("!($savedUri)") - } - } - - val toolbarItems = remember { - listOf( - ToolbarItem(Icons.Rounded.FormatBold, "Bold", color = colorIcon) { - viewModel.insertText("****", offset = -2, newLine = false) - }, - ToolbarItem(Icons.Rounded.FormatItalic, "Italic", color = colorIcon) { - viewModel.insertText("**", offset = -1, newLine = false) - }, - ToolbarItem(Icons.Rounded.FormatUnderlined, "Underline", color = colorIcon) { - viewModel.insertText("__", -1 , newLine = false) - }, - ToolbarItem(Icons.Rounded.StrikethroughS, "Strikethrough", color = colorIcon) { - viewModel.insertText("~~~~", -2 , newLine = false) - }, - ToolbarItem(Icons.Rounded.Highlight, "Highlight", color = colorIcon) { - viewModel.insertText("====", offset = -2, newLine = false) - }, - ToolbarItem(Icons.Rounded.Title, "Header", color = colorIcon) { - viewModel.insertText("# ") - }, - ToolbarItem(Icons.Rounded.FormatQuote, "Quote", color = colorIcon) { - viewModel.insertText("> ", newLine = true) - }, - ToolbarItem(Icons.Rounded.HorizontalRule, "Horizontal Rule", color = colorIcon) { - viewModel.insertText("\n---\n", newLine = true) - }, - ToolbarItem(Icons.AutoMirrored.Rounded.FormatListBulleted, "Bullet List", color = colorIcon) { - viewModel.insertText("- ") - }, - ToolbarItem(Icons.Rounded.FormatListNumbered, "Numbered List", color = colorIcon) { - viewModel.insertText("1. ") - }, - ToolbarItem(Icons.Rounded.CheckBox, "Checkbox", color = colorIcon) { - viewModel.insertText("[ ] ") - }, - ToolbarItem(Icons.Rounded.Link, "Insert Link", color = colorIcon) { - viewModel.insertText("[](url)", offset = -6, newLine = false) - }, - ToolbarItem(Icons.Rounded.Image, "Insert Image", color = colorIcon) { - launcher.launch("image/*") - }, - ToolbarItem(Icons.Rounded.TableChart, "Insert Table", color = colorIcon) { - viewModel.insertText("| Header | Header |\n| --- | --- |\n| Cell | Cell |", newLine = true) - }, - ToolbarItem(Icons.Rounded.Code, "Code Block", color = colorIcon) { - viewModel.insertText("```\n\n```", -4) - }, - ToolbarItem(Icons.Rounded.DataObject, "Inline Code", color = colorIcon) { - viewModel.insertText("``", -1, newLine = false) - }, - ) - } - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { viewModel.toggleMinimalAiUi(true) }, - enabled = true - ) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = "Edit with AI", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - - Spacer(modifier = Modifier.width(8.dp)) - - LazyRow( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - items(toolbarItems) { item -> - IconButton( - onClick = { item.onClickAction.invoke() }, - ) { - Icon( - item.icon, - contentDescription = item.contentDescription, - modifier = Modifier.size(24.dp), - tint = item.color, - ) - } - } - } - } - } -} - -private fun saveImageToAppStorage(context: Context, uri: Uri): String { - val appStorageDir = getExternalStorageDir(context) - if (!appStorageDir.exists()) { - appStorageDir.mkdirs() - } - val imageFile = File(appStorageDir, getImageName(uri)) - - val inputStream: InputStream? = context.contentResolver.openInputStream(uri) - inputStream?.use { input -> - FileOutputStream(imageFile).use { output -> - input.copyTo(output) - } - } - - inputStream?.close() - return imageFile.path.toString() -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/model/EditModel.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/model/EditModel.kt deleted file mode 100644 index dceb683..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/edit/model/EditModel.kt +++ /dev/null @@ -1,970 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.edit.model - -import android.content.Intent -import android.util.Patterns -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.domain.usecase.NoteUseCase -import com.babelsoftware.airnote.presentation.components.DecryptionResult -import com.babelsoftware.airnote.presentation.components.EncryptionHelper -import com.babelsoftware.airnote.presentation.screens.edit.components.UndoRedoState -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import javax.inject.Inject -import com.babelsoftware.airnote.data.repository.GeminiRepository -import com.babelsoftware.airnote.data.repository.AiAction -import com.babelsoftware.airnote.data.repository.AiAssistantAction -import com.babelsoftware.airnote.data.repository.AiTone -import com.babelsoftware.airnote.data.repository.SecureStorageRepository -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.provider.StringProvider -import com.babelsoftware.airnote.domain.model.ChatMessage -import com.babelsoftware.airnote.domain.model.Participant -import com.babelsoftware.airnote.domain.model.AiResponse -import com.babelsoftware.airnote.domain.usecase.FolderUseCase -import com.google.gson.Gson -import com.google.gson.JsonSyntaxException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.withContext -import org.jsoup.Jsoup -import kotlin.onSuccess - -@HiltViewModel -class EditViewModel @Inject constructor( - private val noteUseCase: NoteUseCase, - private val folderUseCase: FolderUseCase, - private val encryption: EncryptionHelper, - private val geminiRepository: GeminiRepository, - private val secureStorageRepository: SecureStorageRepository, - private val stringProvider: StringProvider -) : ViewModel() { - private val _noteName = mutableStateOf(TextFieldValue()) - val noteName: State get() = _noteName - - private val _isDescriptionInFocus = mutableStateOf(false) - val isDescriptionInFocus: State get() = _isDescriptionInFocus - - private val _isEncrypted = mutableStateOf(false) - val isEncrypted: State get() = _isEncrypted - - private val _noteDescription = mutableStateOf(TextFieldValue()) - val noteDescription: State get() = _noteDescription - - private val _noteId = mutableIntStateOf(0) - val noteId: State get() = _noteId - - private val _noteCreatedTime = mutableLongStateOf(System.currentTimeMillis()) - val noteCreatedTime: State get() = _noteCreatedTime - - private val _isNoteInfoVisible = mutableStateOf(false) - val isNoteInfoVisible: State get() = _isNoteInfoVisible - - private val _isEditMenuVisible = mutableStateOf(false) - val isEditMenuVisible: State get() = _isEditMenuVisible - - private val _isPinned = mutableStateOf(false) - val isPinned: State get() = _isPinned - - private val undoRedoState = UndoRedoState() - - private val _folderId = mutableStateOf(null) - val folderId: State get() = _folderId - - private var isShareIntentHandled = false - - private val _isLoading = mutableStateOf(true) // Başlangıçta true - val isLoading: State get() = _isLoading - - private val _isDreamJournalMode = mutableStateOf(false) - val isDreamJournalMode: State get() = _isDreamJournalMode - - // --- AI'S STATES --- - private suspend fun getApiKeyToUse(): String { - return secureStorageRepository.getUserApiKey() ?: "" - } - private val _isAiActionSheetVisible = mutableStateOf(false) - val isAiActionSheetVisible: State get() = _isAiActionSheetVisible - - private val _isSheetReadyForInteraction = mutableStateOf(false) - val isSheetReadyForInteraction: State get() = _isSheetReadyForInteraction - - private val _isToneActionSheetVisible = mutableStateOf(false) - val isToneActionSheetVisible: State get() = _isToneActionSheetVisible - - private val _aiResultText = mutableStateOf(null) - val aiResultText: State = _aiResultText - - private val _isAiLoading = mutableStateOf(false) - val isAiLoading: State get() = _isAiLoading - - // --- NEW MINIMAL AI CHAT STATES --- - private val _isMinimalAiUiVisible = mutableStateOf(false) - val isMinimalAiUiVisible: State get() = _isMinimalAiUiVisible - - private val _minimalAiChatText = mutableStateOf("") - val minimalAiChatText: State get() = _minimalAiChatText - - private val _minimalAiChatHistory = mutableStateOf>(emptyList()) - val minimalAiChatHistory: State> = _minimalAiChatHistory - - private val _isMinimalChatActive = mutableStateOf(false) - val isMinimalChatActive: State get() = _isMinimalChatActive - - private val _isMinimalAiLoading = mutableStateOf(false) - val isMinimalAiLoading: State get() = _isMinimalAiLoading - // --- END NEW STATES --- - - private val _isAiAssistantStreaming = mutableStateOf(false) - val isAiAssistantStreaming: State get() = _isAiAssistantStreaming - private var _lastSelection: TextRange? = null // Tentative texts to be sent to AI - private val _uiEvent = Channel() - val uiEvent = _uiEvent.receiveAsFlow() - - private val _isAiAssistantSheetVisible = mutableStateOf(false) - val isAiAssistantSheetVisible: State get() = _isAiAssistantSheetVisible - private val _titleSuggestions = mutableStateOf>(emptyList()) - val titleSuggestions: State> = _titleSuggestions - - private val _isTranslateSheetVisible = mutableStateOf(false) - val isTranslateSheetVisible: State get() = _isTranslateSheetVisible - - private val _downloadedLanguages = mutableStateOf>>(emptyList()) - val downloadedLanguages: State>> = _downloadedLanguages - - private var _translationSelection: TextRange? = null - private var _isTranslatingSelection = false // Çevirinin seçim için mi yoksa tüm not için mi olduğunu takip eder - // --- AI'S STATES | END--- - - - // --- YENİ EKLENEN FONKSİYONLAR (PAYLAŞ VE LİNK) --- - - /** - * "Link" adında bir klasör arar. Bulamazsa oluşturur ve ID'sini döndürür. - * (Daha kararlı hale getirildi) - */ - private suspend fun getOrCreateLinkFolderId(): Long? { - val linkFolderName = "Link" - val foldersFlow = folderUseCase.getAllFolders() - var folders = foldersFlow.first() // Mevcut listeyi al - var linkFolder = folders.find { it.name.equals(linkFolderName, ignoreCase = true) } - - if (linkFolder != null) { - return linkFolder.id // Klasör zaten var, ID'sini döndür - } - - // Klasör yok, oluşturalım. - // iconName için HomeModel.kt'de kullanılan "Link" ikonunu varsayıyorum. - val newFolder = com.babelsoftware.airnote.domain.model.Folder(name = linkFolderName, iconName = "Link") - - try { - folderUseCase.addFolder(newFolder) // suspend fonksiyon, eklemenin bitmesini bekle - - // Ekleme bittiğine göre, veritabanı güncellenmiş olmalı. - // Flow'dan en güncel listeyi tekrar çek. - folders = foldersFlow.first() - linkFolder = folders.find { it.name.equals(linkFolderName, ignoreCase = true) } - - return linkFolder?.id // Yeni oluşturulan klasörün ID'sini döndür - - } catch (e: Exception) { - // Ekleme veya bulma sırasında hata oluştu - e.printStackTrace() - // Hata durumunda bile son bir kez daha dene - folders = foldersFlow.first() - linkFolder = folders.find { it.name.equals(linkFolderName, ignoreCase = true) } - return linkFolder?.id - } - } - - /** - * Gelen paylaşım verisini (link veya metin) işler. - */ - fun handleSharedIntent(intent: Intent) { - // Intent'in zaten işlenip işlenmediğini kontrol et - if (isShareIntentHandled) return - - if (intent.action == Intent.ACTION_SEND && "text/plain" == intent.type) { - // Intent'i işlendi olarak işaretle - isShareIntentHandled = true - - viewModelScope.launch { - val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return@launch - val sharedTitle = intent.getStringExtra(Intent.EXTRA_SUBJECT) - - // 1. Paylaşılan metin bir URL mi kontrol et - if (Patterns.WEB_URL.matcher(sharedText).matches()) { - // EVET, BU BİR LİNK - - // 2. "Link" klasörünün ID'sini al veya oluştur - val targetFolderId = getOrCreateLinkFolderId() // <-- Artık Long? döndürür - - if(targetFolderId != null) { - updateFolderId(targetFolderId) // Notun folderId'sini ayarla - } else { - // Opsiyonel: Klasör oluşturma başarısız olursa kullanıcıyı bilgilendir - _uiEvent.send("The link folder could not be created.") - } - - // 3. Site başlığını çek - _isAiLoading.value = true // Başlık çekilirken yükleniyor göster - val title = fetchTitleFromUrl(sharedText) ?: sharedTitle ?: "Saved links" - _isAiLoading.value = false - - // 4. ViewModel'i doldur - updateNoteName(TextFieldValue(title)) - updateNoteDescription(TextFieldValue(sharedText)) - - } else { - // HAYIR, BU DÜZ METİN - // Normal davran, klasör ataması yapma (varsayılan klasörde kalsın) - val title = sharedTitle ?: sharedText.take(30) - updateNoteName(TextFieldValue(title)) - updateNoteDescription(TextFieldValue(sharedText)) - } - } - } - } - - /** - * Asenkron olarak URL'den başlık çeker. - * Bu bir ağ işlemidir, bu yüzden 'suspend' olmalıdır. - */ - private suspend fun fetchTitleFromUrl(url: String): String? { - return withContext(Dispatchers.IO) { - try { - val doc = Jsoup.connect(url) - .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") - .timeout(5000) // 5 saniye zaman aşımı - .get() - // Başlığın boş olmadığını kontrol et - doc.title().takeIf { it.isNotBlank() } - } catch (e: Exception) { - e.printStackTrace() - null // Hata durumunda null döndür - } - } - } - - // --- BİTİŞ: YENİ EKLENEN FONKSİYONLAR --- - - - // --- AI FUNCTIONS --- - - // --- NEW MINIMAL AI CHAT FUNCTIONS --- - fun toggleMinimalAiUi(isVisible: Boolean) { - _isMinimalAiUiVisible.value = isVisible - if (isVisible) { - viewModelScope.launch { - geminiRepository.getDownloadedModels() - .onSuccess { downloadedCodes -> - val availableLanguages = geminiRepository.supportedLanguages - .filter { downloadedCodes.contains(it.key) } - .map { it.key to it.value } - _downloadedLanguages.value = availableLanguages - } - .onFailure { - println("Dil modelleri yüklenemedi (Minimal UI): ${it.message}") - } - } - } else { - // Reset chat state when closing - _minimalAiChatText.value = "" - _minimalAiChatHistory.value = emptyList() - _isMinimalChatActive.value = false - _isMinimalAiLoading.value = false - } - } - - fun updateMinimalAiChatText(newText: String) { - _minimalAiChatText.value = newText - } - - fun sendMinimalAiMessage() { - if (isMinimalAiLoading.value || minimalAiChatText.value.isBlank()) return - - val userMessageText = _minimalAiChatText.value - _minimalAiChatText.value = "" - val userMessage = ChatMessage(userMessageText, Participant.USER) - val loadingMessage = ChatMessage("", Participant.MODEL, isLoading = true) - - _minimalAiChatHistory.value = _minimalAiChatHistory.value + userMessage + loadingMessage - _isMinimalChatActive.value = true - _isMinimalAiLoading.value = true - - viewModelScope.launch { - try { - val apiKey = getApiKeyToUse() - val noteContext = noteDescription.value.text - val currentHistoryForApi = _minimalAiChatHistory.value.dropLast(2) - val result = geminiRepository.generateChatOrCommandResponse( - noteContext = noteContext, - userRequest = userMessageText, - chatHistory = currentHistoryForApi, - apiKey = apiKey - ) - - _isMinimalAiLoading.value = false - _minimalAiChatHistory.value = _minimalAiChatHistory.value.filter { !it.isLoading } - - result.onSuccess { jsonResponse -> - try { - val aiResponse = Gson().fromJson(jsonResponse, AiResponse::class.java) - - when (aiResponse.intent) { - "CHAT" -> { - val responseText = aiResponse.response ?: "..." - val modelMessage = ChatMessage(responseText, Participant.MODEL) - _minimalAiChatHistory.value = _minimalAiChatHistory.value + modelMessage - } - "EDIT_NOTE" -> { - aiResponse.newNoteContent?.let { newContent -> - undoRedoState.onInput(noteDescription.value) - updateNoteDescription(TextFieldValue(newContent)) - - val successMessage = ChatMessage( - stringProvider.getString(R.string.chat_message_note_updated), - Participant.MODEL - ) - _minimalAiChatHistory.value = _minimalAiChatHistory.value + successMessage - } - } - else -> { - val errorMessage = ChatMessage("AI garip bir yanıt verdi: ${aiResponse.intent}", Participant.ERROR) - _minimalAiChatHistory.value = _minimalAiChatHistory.value + errorMessage - } - } - } catch (e: JsonSyntaxException) { - e.printStackTrace() - val errorMessage = ChatMessage(stringProvider.getString(R.string.ai_error_invalid_json), Participant.ERROR) - _minimalAiChatHistory.value = _minimalAiChatHistory.value + errorMessage - } - } - - result.onFailure { e -> - val errorMessage = ChatMessage(e.message ?: stringProvider.getString(R.string.error_unknown), Participant.ERROR) - _minimalAiChatHistory.value = _minimalAiChatHistory.value + errorMessage - } - - } catch (e: Exception) { - _isMinimalAiLoading.value = false - _minimalAiChatHistory.value = _minimalAiChatHistory.value.filter { !it.isLoading } - val errorMessage = ChatMessage(e.message ?: stringProvider.getString(R.string.error_unknown), Participant.ERROR) - _minimalAiChatHistory.value = _minimalAiChatHistory.value + errorMessage - } - } - } - - fun toggleAiActionSheet(isVisible: Boolean) { - if (isVisible) { - _isSheetReadyForInteraction.value = false // Set the interaction status to “not ready” (false) each time the menu is opened. - } - _isAiActionSheetVisible.value = isVisible - } - - fun markSheetAsReadyForInteraction() { - _isSheetReadyForInteraction.value = true - } - - fun toggleToneActionSheet(isVisible: Boolean) { - _isToneActionSheetVisible.value = isVisible - } - - fun clearAiResult() { - _aiResultText.value = null - _lastSelection = null // Reset stored selection - } - - /** - * Central function that runs all AI actions. - * Operates only on the selected text. - */ - fun executeAiAction(action: AiAction, tone: AiTone? = null) { - val selection = _lastSelection ?: noteDescription.value.selection - if (action != AiAction.CHANGE_TONE && action != AiAction.TRANSLATE) { - toggleAiActionSheet(false) - } else { - } - toggleToneActionSheet(false) - - if (action == AiAction.CHANGE_TONE && tone == null) { - return - } - - if (action == AiAction.TRANSLATE) { - onTranslateClicked(forSelection = true) - return - } - - if (selection.collapsed) { - viewModelScope.launch { - _uiEvent.send(stringProvider.getString(R.string.ai_error_no_text_selected)) - } - return - } - - val selectedText = noteDescription.value.text.substring(selection.min, selection.max) - - if (selectedText.isNotBlank()) { - viewModelScope.launch { - _isAiLoading.value = true - try { - val apiKey = getApiKeyToUse() - geminiRepository.processAiAction(selectedText, action, tone, apiKey) - .onSuccess { responseText -> - _lastSelection = selection - _aiResultText.value = responseText - } - .onFailure { exception -> - _uiEvent.send(exception.message ?: stringProvider.getString(R.string.error_unknown)) - } - } catch (e: Exception) { - _uiEvent.send(stringProvider.getString(R.string.error_unexpected_with_message, e.message ?: "")) - } finally { - _isAiLoading.value = false - } - } - } - } - - fun executeAiAssistantAction(action: AiAssistantAction) { - toggleAiAssistantSheet(false) - - viewModelScope.launch { - var fullResponse = StringBuilder() - val apiKey = getApiKeyToUse() - - geminiRepository.processAssistantAction( - noteName = noteName.value.text, - noteDescription = noteDescription.value.text, - action = action, - apiKey = apiKey - ) - .onStart { - if (action != AiAssistantAction.SUGGEST_A_TITLE) _isAiAssistantStreaming.value = true - } - .onCompletion { - _isAiAssistantStreaming.value = false - } - .collect { chunk -> - if (chunk.startsWith("API isteği başarısız oldu") || chunk.startsWith("Kullanıcı API anahtarı bulunamadı")) { - _uiEvent.send(chunk) - this.coroutineContext.cancel() - return@collect - } - fullResponse.append(chunk) - if (action == AiAssistantAction.PROS_AND_CONS || action == AiAssistantAction.CREATE_TODO_LIST || action == AiAssistantAction.SIMPLIFY) { - val currentText = _noteDescription.value.text - val newText = currentText + chunk - _noteDescription.value = TextFieldValue(text = newText, selection = TextRange(newText.length)) - } - } - val finalResponse = fullResponse.toString() - when (action) { - AiAssistantAction.CREATE_TODO_LIST -> { - if (finalResponse.trim() == "NO_TASKS") { - _uiEvent.send(stringProvider.getString(R.string.ai_error_no_tasks_for_todo)) - } - } - AiAssistantAction.SUGGEST_A_TITLE -> { - val suggestions = finalResponse.lines().mapNotNull { - it.replaceFirst(Regex("^\\d+\\.?\\s*"), "").trim().takeIf { s -> s.isNotEmpty() } - } - _titleSuggestions.value = suggestions - } - else -> { - } - } - } - } - - fun executeDreamAnalysis() { - toggleMinimalAiUi(true) - _isMinimalChatActive.value = true - - val dreamText = noteDescription.value.text - if (dreamText.isBlank()) { - viewModelScope.launch { - _uiEvent.send(stringProvider.getString(R.string.dream_not_found)) - } - return - } - - val userMessageText = stringProvider.getString(R.string.interpret_my_dream) - val userMessage = ChatMessage(userMessageText, Participant.USER) - val loadingMessage = ChatMessage("", Participant.MODEL, isLoading = true) - - _minimalAiChatHistory.value = listOf(userMessage, loadingMessage) - _isMinimalAiLoading.value = true - - val historyToSend = mutableListOf() - val dreamPrompt = stringProvider.getString(R.string.prompt_dream_journal_header) - historyToSend.add(ChatMessage("$dreamPrompt\"\"\"$dreamText\"\"\"\n\n$userMessageText", Participant.USER)) - - viewModelScope.launch { - try { - val apiKey = getApiKeyToUse() - val responseBuilder = StringBuilder() - - geminiRepository.generateChatResponse(historyToSend, apiKey) - .onCompletion { - _isMinimalAiLoading.value = false - val finalResponse = ChatMessage(responseBuilder.toString(), Participant.MODEL, isLoading = false) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + finalResponse - } - .collect { chunk -> - responseBuilder.append(chunk) - // Akıcı yanıtı arayüze yansıt - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + ChatMessage(responseBuilder.toString(), Participant.MODEL, isLoading = true) - } - - } catch (e: Exception) { - _isMinimalAiLoading.value = false - val errorMessage = ChatMessage(e.message ?: stringProvider.getString(R.string.error_unknown), Participant.ERROR, isLoading = false) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + errorMessage - } - } - } - - fun executeDreamSymbolAnalysis() { - toggleMinimalAiUi(true) - _isMinimalChatActive.value = true - - val dreamText = noteDescription.value.text - if (dreamText.isBlank()) { - viewModelScope.launch { - _uiEvent.send(stringProvider.getString(R.string.dream_not_found)) - } - return - } - - val userMessageText = stringProvider.getString(R.string.explain_symbols) - val userMessage = ChatMessage(userMessageText, Participant.USER) - val loadingMessage = ChatMessage("", Participant.MODEL, isLoading = true) - - _minimalAiChatHistory.value = listOf(userMessage, loadingMessage) - _isMinimalAiLoading.value = true - - val historyToSend = mutableListOf() - val dreamPrompt = stringProvider.getString(R.string.prompt_dream_journal_symbol_analysis) - - historyToSend.add(ChatMessage("$dreamPrompt\"\"\"$dreamText\"\"\"\n\n$userMessageText", Participant.USER)) - - viewModelScope.launch { - try { - val apiKey = getApiKeyToUse() - val responseBuilder = StringBuilder() - - geminiRepository.generateChatResponse(historyToSend, apiKey) - .onCompletion { - _isMinimalAiLoading.value = false - val finalResponse = ChatMessage(responseBuilder.toString(), Participant.MODEL, isLoading = false) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + finalResponse - } - .collect { chunk -> - responseBuilder.append(chunk) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + ChatMessage(responseBuilder.toString(), Participant.MODEL, isLoading = true) - } - - } catch (e: Exception) { - _isMinimalAiLoading.value = false - val errorMessage = ChatMessage(e.message ?: stringProvider.getString(R.string.error_unknown), Participant.ERROR, isLoading = false) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + errorMessage - } - } - } - - fun executeDreamEmotionAnalysis() { - toggleMinimalAiUi(true) - _isMinimalChatActive.value = true - - val dreamText = noteDescription.value.text - if (dreamText.isBlank()) { - viewModelScope.launch { - _uiEvent.send(stringProvider.getString(R.string.dream_not_found)) - } - return - } - - val userMessageText = stringProvider.getString(R.string.emotional_analysis) - val userMessage = ChatMessage(userMessageText, Participant.USER) - val loadingMessage = ChatMessage("", Participant.MODEL, isLoading = true) - - _minimalAiChatHistory.value = listOf(userMessage, loadingMessage) - _isMinimalAiLoading.value = true - - val historyToSend = mutableListOf() - val dreamPrompt = stringProvider.getString(R.string.prompt_dream_journal_emotion_analysis) - - historyToSend.add(ChatMessage("$dreamPrompt\"\"\"$dreamText\"\"\"\n\n$userMessageText", Participant.USER)) - - viewModelScope.launch { - try { - val apiKey = getApiKeyToUse() - val responseBuilder = StringBuilder() - - geminiRepository.generateChatResponse(historyToSend, apiKey) - .onCompletion { - _isMinimalAiLoading.value = false - val finalResponse = ChatMessage(responseBuilder.toString(), Participant.MODEL, isLoading = false) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + finalResponse - } - .collect { chunk -> - responseBuilder.append(chunk) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + ChatMessage(responseBuilder.toString(), Participant.MODEL, isLoading = true) - } - - } catch (e: Exception) { - _isMinimalAiLoading.value = false - val errorMessage = ChatMessage(e.message ?: stringProvider.getString(R.string.error_unknown), Participant.ERROR, isLoading = false) - _minimalAiChatHistory.value = _minimalAiChatHistory.value.dropLast(1) + errorMessage - } - } - } - - /** - * Replaces AI generated text with the selected text. - */ - fun replaceWithAiResult() { - val originalText = noteDescription.value.text - val selection = _lastSelection ?: return // Use stored selection - val newText = _aiResultText.value ?: return - - val updatedText = originalText.replaceRange(selection.start, selection.end, newText) - val newSelectionStart = selection.start + newText.length - updateNoteDescription( - TextFieldValue( - text = updatedText, - selection = TextRange(newSelectionStart) - ) - ) - clearAiResult() - } - - fun toggleAiAssistantSheet(isVisible: Boolean) { - _isAiAssistantSheetVisible.value = isVisible - } - - fun clearTitleSuggestions() { - _titleSuggestions.value = emptyList() - } - - fun toggleTranslateSheet(isVisible: Boolean) { - _isTranslateSheetVisible.value = isVisible - } - - fun onTranslateClicked(forSelection: Boolean) { - _isTranslatingSelection = forSelection - _translationSelection = if (forSelection) { - noteDescription.value.selection.takeIf { !it.collapsed } - } else { - TextRange(0, noteDescription.value.text.length) - } - - if (forSelection && _translationSelection == null) { - viewModelScope.launch { - _uiEvent.send(stringProvider.getString(R.string.ai_error_select_text_to_translate)) - } - return - } - - if (forSelection) { - toggleMinimalAiUi(true) - } else { - viewModelScope.launch { - _isAiLoading.value = true - geminiRepository.getDownloadedModels() - .onSuccess { downloadedCodes -> - val availableLanguages = geminiRepository.supportedLanguages - .filter { downloadedCodes.contains(it.key) } - .map { it.key to it.value } - _downloadedLanguages.value = availableLanguages - toggleTranslateSheet(true) - } - .onFailure { - _uiEvent.send(stringProvider.getString(R.string.error_fetching_downloaded_models, it.message ?: "")) - } - _isAiLoading.value = false - } - } - } - - fun executeTranslation(targetLanguageCode: String) { - if (_isTranslatingSelection) { - } else { - toggleTranslateSheet(false) - } - - val selection = _translationSelection - if (selection == null || selection.end > noteDescription.value.text.length) { - return - } - - val textToTranslate = noteDescription.value.text.substring(selection.start, selection.end) - if (textToTranslate.isBlank()) return - - viewModelScope.launch { - _isAiLoading.value = true - geminiRepository.translateOnDevice(textToTranslate, targetLanguageCode) - .onSuccess { translatedText -> - val originalText = noteDescription.value.text - val updatedText = originalText.replaceRange(selection.start, selection.end, translatedText) - val newSelectionStart = selection.start + translatedText.length - updateNoteDescription( - TextFieldValue( - text = updatedText, - selection = TextRange(newSelectionStart) - ) - ) - - if (_isTranslatingSelection) { - toggleMinimalAiUi(false) - } - } - .onFailure { - _uiEvent.send(stringProvider.getString(R.string.error_translation_failed, it.message ?: "")) - } - _isAiLoading.value = false - } - } - // --- AI FUNCTIONS | END --- - - fun updateFolderId(newFolderId: Long?) { - _folderId.value = newFolderId - } - - fun saveNote(id: Int) { - if (noteName.value.text.isNotEmpty() || noteDescription.value.text.isNotBlank()) { - viewModelScope.launch { - noteUseCase.addNote( - Note( - id = id, - name = noteName.value.text, - description = noteDescription.value.text, - pinned = isPinned.value, - folderId = folderId.value, - encrypted = isEncrypted.value, - createdAt = if (noteCreatedTime.value != 0L) noteCreatedTime.value else System.currentTimeMillis(), - ) - ) - fetchLastNoteAndUpdate() - } - } - } - - fun deleteNote(id: Int) { - noteUseCase.deleteNoteById(id = id) - } - - private fun syncNote(note: Note) { - if (note.encrypted) { - val (name, nameStatus) = encryption.decrypt(note.name) - val (description, descriptionStatus) = encryption.decrypt(note.description) - if (nameStatus == DecryptionResult.SUCCESS && descriptionStatus == DecryptionResult.SUCCESS) { - updateNoteName(TextFieldValue(name!!, selection = TextRange(note.name.length))) - updateNoteDescription(TextFieldValue(description!!, selection = TextRange(note.description.length))) - } - } else { - updateNoteName(TextFieldValue(note.name, selection = TextRange(note.name.length))) - updateNoteDescription(TextFieldValue(note.description, selection = TextRange(note.description.length))) - } - updateNoteCreatedTime(note.createdAt) - updateNoteId(note.id) - updateNotePin(note.pinned) - updateIsEncrypted(note.encrypted) - updateFolderId(note.folderId) - viewModelScope.launch { - // --- DÜZELTME BAŞLANGIÇ --- - val dreamFolderName = stringProvider.getString(R.string.dream_journal) - // --- DÜZELTME BİTİŞ --- - try { - val folders = folderUseCase.getAllFolders().first() - val folder = folders.find { it.id == note.folderId } - // --- DÜZELTME BAŞLANGIÇ --- - if (folder?.name.equals(dreamFolderName, ignoreCase = true)) { - // --- DÜZELTME BİTİŞ --- - _isDreamJournalMode.value = true - } else { - _isDreamJournalMode.value = false - } - } catch (e: Exception) { - _isDreamJournalMode.value = false - } finally { - _isLoading.value = false - } - } - } - - fun setupNoteData(id : Int, folderIdFromNav: Long? = null) { - _isLoading.value = true - if (id != 0) { - viewModelScope.launch { - noteUseCase.getNoteById(id).collectLatest { note -> - if (note != null) { - syncNote(note) - } else { - _isLoading.value = false - } - } - } - } else { - updateFolderId(folderIdFromNav) - if (folderIdFromNav != null) { - viewModelScope.launch { - val dreamFolderName = stringProvider.getString(R.string.dream_journal) - try { - val folders = folderUseCase.getAllFolders().first() - val folder = folders.find { it.id == folderIdFromNav } - if (folder?.name.equals(dreamFolderName, ignoreCase = true)) { - _isDreamJournalMode.value = true - } - } catch (e: Exception) { - _isDreamJournalMode.value = false - } finally { - _isLoading.value = false - } - } - } else { - _isLoading.value = false - } - } - } - - private fun fetchLastNoteAndUpdate() { - if (noteName.value.text.isNotEmpty() || noteDescription.value.text.isNotBlank()) { - if (noteId.value == 0) { - viewModelScope.launch { - noteUseCase.getLastNoteId { lastId -> - viewModelScope.launch { - setupNoteData(lastId?.toInt() ?: 1) - } - } - } - } - } - } - - fun toggleEditMenuVisibility(value: Boolean) { - _isEditMenuVisible.value = value - } - - fun toggleNoteInfoVisibility(value: Boolean) { - _isNoteInfoVisible.value = value - } - - fun toggleIsDescriptionInFocus(value: Boolean) { - _isDescriptionInFocus.value = value - } - - fun toggleNotePin(value: Boolean) { - _isPinned.value = value - } - - fun updateNoteName(newName: TextFieldValue) { - _noteName.value = newName - undoRedoState.onInput(newName) - } - - fun updateIsEncrypted(value: Boolean) { - _isEncrypted.value = value - } - - fun updateNoteDescription(newDescription: TextFieldValue) { - _noteDescription.value = newDescription - if (!newDescription.selection.collapsed) { - _lastSelection = newDescription.selection - } - undoRedoState.onInput(newDescription) - } - - private fun updateNoteCreatedTime(newTime: Long) { - _noteCreatedTime.longValue = newTime - } - - private fun updateNotePin(pinned: Boolean) { - _isPinned.value = pinned - } - - fun updateNoteId(newId: Int) { - _noteId.intValue = newId - } - - fun undo() { - undoRedoState.undo() - _noteDescription.value = undoRedoState.input - } - - fun redo() { - undoRedoState.redo() - _noteDescription.value = undoRedoState.input - } - - private fun isSelectorAtStartOfNonEmptyLine(): Boolean { - val text = _noteDescription.value.text - val selectionStart = _noteDescription.value.selection.start - - if (selectionStart == 0) { - return true - } - return text[selectionStart - 1] == '\n' - } - - - private fun getIntRangeForCurrentLine(): IntRange { - val text = _noteDescription.value.text - val selectionStart = _noteDescription.value.selection.start - val selectionEnd = _noteDescription.value.selection.end - var lineStart = selectionStart - var lineEnd = selectionEnd - - while (lineStart > 0 && text[lineStart - 1] != '\n') { - lineStart-- - } - - while (lineEnd < text.length && text[lineEnd] != '\n') { - lineEnd++ - } - return IntRange(lineStart, lineEnd - 1); - } - - fun insertText(insertText: String, offset: Int = 1, newLine: Boolean = true) { - val currentText = _noteDescription.value.text - val resultSelectionIndex: Int - val rangeOfCurrentLine = getIntRangeForCurrentLine() - val updatedText = if (!rangeOfCurrentLine.isEmpty()) { - val currentLineContents = currentText.substring(rangeOfCurrentLine) - val newLine = if (isSelectorAtStartOfNonEmptyLine()) { - insertText + currentLineContents - } else { - if (newLine) { - currentLineContents + "\n" + insertText - } else { - currentLineContents + insertText - } - } - resultSelectionIndex = rangeOfCurrentLine.first + newLine.length - 1 - currentText.replaceRange(rangeOfCurrentLine, newLine) - } else { - resultSelectionIndex = (currentText + insertText).length - currentText + insertText - } - - _noteDescription.value = TextFieldValue( - text = updatedText, - selection = TextRange(resultSelectionIndex + offset) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/AiInterface.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/AiInterface.kt deleted file mode 100644 index 7b82d82..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/AiInterface.kt +++ /dev/null @@ -1,1917 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home - -import android.content.ActivityNotFoundException -import android.net.Uri -import android.provider.MediaStore -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.automirrored.rounded.Notes -import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.rounded.AddComment -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.FilePresent -import androidx.compose.material.icons.rounded.GolfCourse -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Notes -import androidx.compose.material.icons.rounded.Psychology -import androidx.compose.material.icons.rounded.School -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.hilt.navigation.compose.hiltViewModel -import coil.compose.AsyncImage -import coil.compose.rememberAsyncImagePainter -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.repository.AiMode -import com.babelsoftware.airnote.domain.model.AiChatSession -import com.babelsoftware.airnote.domain.model.Participant -import com.babelsoftware.airnote.domain.model.Settings -import com.babelsoftware.airnote.presentation.screens.home.viewmodel.AiService -import com.babelsoftware.airnote.presentation.screens.home.viewmodel.DraftedNote -import com.babelsoftware.airnote.presentation.screens.home.viewmodel.HomeViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.random.Random - -// ================================================================= -// === Enhanced AirNote Kai AI Interface (with THEME) ====================== -// ================================================================= - -@OptIn(ExperimentalAnimationApi::class) -@Composable -fun AiChatContainer(viewModel: HomeViewModel, settings: Settings) { - val isDark = isSystemInDarkTheme() - val backgroundBrush = if (isDark) { - Brush.verticalGradient( - colors = listOf(Color(0xFF10141C), Color(0xFF0A0D12)) - ) - } else { - Brush.verticalGradient( - colors = listOf(MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.surfaceContainerLowest) - ) - } - - val showHistoryScreen by viewModel.showAiHistoryScreen - val allSessions by viewModel.allChatSessions.collectAsState() - - Column( - modifier = Modifier - .fillMaxSize() - .background(backgroundBrush) - ) { - AiTopBar( - viewModel = viewModel, - showHistory = showHistoryScreen, - onToggleHistory = { viewModel.toggleAiHistoryScreen(!showHistoryScreen) }, - isPerplexityEnabled = settings.isPerplexityEnabled - ) - - AnimatedContent(targetState = showHistoryScreen, label = "AiScreenAnimation") { showHistory -> - if (showHistory) { - AiHistoryScreen( - sessions = allSessions, - onNewChatClicked = { viewModel.startNewChat() }, - onSessionClicked = { viewModel.loadChatSession(it) }, - onDeleteSession = { viewModel.deleteChatSession(it.id) } - ) - } else { - AiMainContent(viewModel = viewModel) - } - } - } -} - -@Composable -fun AiMainContent(viewModel: HomeViewModel) { - val chatState by viewModel.chatState.collectAsState() - var text by remember { mutableStateOf("") } - val isChatActive = chatState.hasStartedConversation || chatState.messages.isNotEmpty() - val allNotes by viewModel.allNotesForAi.collectAsState() - val isDark = isSystemInDarkTheme() - val selectedService by viewModel.selectedAiService.collectAsState() - val showAttachmentButton = selectedService == AiService.GEMINI - val placeholderText = if (selectedService == AiService.GEMINI) { - if (chatState.isAwaitingDraftTopic) stringResource(R.string.draft_topic_placeholder) else stringResource(R.string.ask_airnote_ai) - } else { - stringResource(R.string.ask_perplexity_placeholder) - } - - val (mentionQuery, showMentionSuggestions) = remember(text) { - val cursorPosition = text.length - val atIndex = text.lastIndexOf('@', cursorPosition - 1) - - if (atIndex != -1) { - val query = text.substring(atIndex + 1, cursorPosition) - if (" " !in query && query.length < 30) { - Pair(query, true) - } else { - Pair("", false) - } - } else { - Pair("", false) - } - } - - val mentionSuggestions = remember(mentionQuery, allNotes, showMentionSuggestions) { - if (showMentionSuggestions) { - allNotes.filter { - it.name.contains(mentionQuery, ignoreCase = true) - }.take(5) - } else { - emptyList() - } - } - - if (viewModel.showAskQuestionDialog.value) { - AskAiQuestionDialog( - onDismiss = { viewModel.onDismissQuestionDialog() }, - onConfirm = { question -> - viewModel.sendMessage(question) - viewModel.onDismissQuestionDialog() - } - ) - } - - if (viewModel.showCreateDraftDialog.value) { - CreateDraftDialog( - onDismiss = { viewModel.onDismissCreateDraftDialog() }, - onConfirm = { topic -> - viewModel.generateDraft(topic) - viewModel.onDismissCreateDraftDialog() - } - ) - } - - val onSendMessage = { message: String -> - if (message.isNotBlank() || (chatState.pendingAttachmentUri != null && showAttachmentButton)) { - if (chatState.isAwaitingDraftTopic) { - viewModel.generateDraft(message) - } else { - viewModel.sendMessage(message) - } - text = "" - } - } - - Column(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.weight(1f)) { - val (isLoading, isAnalyzingImage) = remember(chatState) { - val loadingMessage = chatState.messages.any { it.isLoading } - val imageUri = chatState.analyzingImageUri - (loadingMessage to (imageUri != null)) - } - - when { - chatState.latestDraft != null && chatState.latestDraft?.sourceImageUri != null -> { - DraftDisplayWithImage( - draft = chatState.latestDraft!!, - onSave = { viewModel.saveDraftedNote() }, - onRegenerate = { viewModel.regenerateDraft() } - ) - } - - isLoading && isAnalyzingImage -> { - ImageAnalysisScreen(imageUri = chatState.analyzingImageUri!!) - } - - chatState.latestDraft != null -> { - DraftDisplay( - draft = chatState.latestDraft, - onSave = { viewModel.saveDraftedNote() }, - onRegenerate = { viewModel.regenerateDraft() } - ) - } - isChatActive -> { - ChatScreenContent( - messages = chatState.messages, - topicForLoading = if ( - chatState.messages.lastOrNull()?.isLoading == true && - chatState.messages.getOrNull(chatState.messages.size - 2)?.participant == Participant.USER - ) { - chatState.messages.getOrNull(chatState.messages.size - 2)?.text - } else { - null - } - ) - } - else -> { - NewAiHomeScreen(viewModel = viewModel) - } - } - } - - AnimatedVisibility(visible = mentionSuggestions.isNotEmpty() && showAttachmentButton) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .offset(y = (-8).dp), - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - color = if (isDark) Color.White.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceContainerHigh, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outlineVariant) - ) { - LazyColumn( - modifier = Modifier - .heightIn(max = 150.dp) - .padding(vertical = 8.dp) - ) { - items(items = mentionSuggestions, key = { it.id }) { note -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val atIndex = text.lastIndexOf('@') - text = text.substring(0, atIndex + 1) + note.name + " " - } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Rounded.FilePresent, - contentDescription = null, - tint = if (isDark) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - Spacer(Modifier.width(12.dp)) - Text( - text = note.name, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } - } - - AnimatedVisibility(visible = chatState.pendingAttachmentUri != null && showAttachmentButton) { - AttachedFileChip( - uri = chatState.pendingAttachmentUri, - mimeType = chatState.pendingAttachmentMimeType, - onRemove = { viewModel.onAttachmentRemoved() } - ) - } - - val showInputBar = chatState.latestDraft == null && chatState.analyzingImageUri == null - val isLoading = chatState.messages.any { it.isLoading } - - if (showInputBar) { - if (isChatActive) { - RedesignedChatInputBar( - text = text, - onValueChange = { text = it }, - onSendMessage = { onSendMessage(text) }, - onImagePickerClicked = { viewModel.onAttachmentIconClicked() }, - enabled = !isLoading, - showAttachmentButton = showAttachmentButton, - placeholderText = placeholderText - ) - } else { - PreChatInputBar( - text = text, - onValueChange = { text = it }, - onSendMessage = onSendMessage, - onImagePickerClicked = { viewModel.onAttachmentIconClicked() }, - enabled = !isLoading, - showAttachmentButton = showAttachmentButton - ) - } - AiDisclaimerText() - } - } -} - -@Composable -fun AttachedFileChip( - uri: Uri?, - mimeType: String?, - onRemove: () -> Unit -) { - if (uri == null || mimeType == null) return - - val isDark = isSystemInDarkTheme() - val context = LocalContext.current - - val displayName = remember(uri, mimeType) { - if (mimeType.startsWith("image/")) { - "Image" - } else { - try { - context.contentResolver.query(uri, arrayOf(MediaStore.Images.Media.DISPLAY_NAME), null, null, null) - ?.use { cursor -> - if (cursor.moveToFirst()) { - cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) - } else { - "File" - } - } ?: "File" - } catch (e: Exception) { - "File" - } - } - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .offset(y = (-8).dp), - shape = RoundedCornerShape(12.dp), - color = if (isDark) Color.White.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceContainerHigh, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outlineVariant) - ) { - Row( - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { - Icon( - imageVector = if (mimeType.startsWith("image/")) Icons.Default.Image else Icons.Default.Description, - contentDescription = "File type", - tint = if (isDark) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.width(8.dp)) - Text( - text = displayName, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { - Icon( - Icons.Default.Close, - contentDescription = "Remove file", - tint = if (isDark) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - - -@Composable -fun ImageAnalysisScreen(imageUri: Uri) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - AsyncImage( - model = imageUri, - contentDescription = stringResource(R.string.image_being_analyzed), - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Fit - ) - - MagicAnalysisAnimation() - } -} - -@Composable -fun MagicAnalysisAnimation() { - val infiniteTransition = rememberInfiniteTransition(label = "analysis_animation") - val scanLinePosition by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(2000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "scan_line" - ) - - val particles = remember { - List(50) { - Pair( - Random.nextFloat(), // x - Random.nextFloat() // y - ) - } - } - val particleAlpha by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "particles" - ) - - val primaryColor = if (isSystemInDarkTheme()) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - - Canvas(modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp))) { - val canvasWidth = size.width - val canvasHeight = size.height - val y = scanLinePosition * canvasHeight - - drawLine( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, primaryColor.copy(alpha = 0.5f), Color.Transparent), - startY = y - 20f, - endY = y + 20f - ), - start = Offset(x = 0f, y = y), - end = Offset(x = canvasWidth, y = y), - strokeWidth = 4f, - cap = StrokeCap.Round - ) - - particles.forEach { (x, y) -> - drawCircle( - color = primaryColor, - radius = (Random.nextFloat() * 4f + 2f), - center = Offset(x * canvasWidth, y * canvasHeight), - alpha = particleAlpha * Random.nextFloat() - ) - } - - val bracketSize = 30f - val bracketStroke = 6f - - // Top left - drawLine(primaryColor, Offset(0f, bracketSize), Offset(0f, 0f), bracketStroke) - drawLine(primaryColor, Offset(0f, 0f), Offset(bracketSize, 0f), bracketStroke) - // Top right - drawLine(primaryColor, Offset(canvasWidth - bracketSize, 0f), Offset(canvasWidth, 0f), bracketStroke) - drawLine(primaryColor, Offset(canvasWidth, 0f), Offset(canvasWidth, bracketSize), bracketStroke) - // Lower Left - drawLine(primaryColor, Offset(0f, canvasHeight - bracketSize), Offset(0f, canvasHeight), bracketStroke) - drawLine(primaryColor, Offset(0f, canvasHeight), Offset(bracketSize, canvasHeight), bracketStroke) - // Lower Right - drawLine(primaryColor, Offset(canvasWidth - bracketSize, canvasHeight), Offset(canvasWidth, canvasHeight), bracketStroke) - drawLine(primaryColor, Offset(canvasWidth, canvasHeight), Offset(canvasWidth, canvasHeight - bracketSize), bracketStroke) - } -} - -@Composable -fun DraftDisplayWithImage(draft: DraftedNote, onSave: () -> Unit, onRegenerate: () -> Unit) { - val isDark = isSystemInDarkTheme() - val primaryAccentColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - AsyncImage( - model = draft.sourceImageUri, - contentDescription = draft.title, - modifier = Modifier - .fillMaxWidth() - .height(150.dp) - .clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.height(16.dp)) - Surface( - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - color = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.outlineVariant) - ) { - LazyColumn( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp) - ) { - item { - Text( - text = draft.title, - style = MaterialTheme.typography.titleLarge, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 12.dp) - ) - } - item { - Text( - text = draft.content, - style = MaterialTheme.typography.bodyLarge, - color = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 24.sp - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onRegenerate, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, primaryAccentColor) - ) { - Icon(Icons.Rounded.AutoAwesome, contentDescription = null, tint = primaryAccentColor) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.regenerate_note), color = primaryAccentColor) - } - Button( - onClick = onSave, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = primaryAccentColor, - contentColor = if (isDark) Color(0xFF10141C) else MaterialTheme.colorScheme.onPrimary - ) - ) { - Text(stringResource(R.string.save_note)) - } - } - } -} - - -@Composable -fun NewAiHomeScreen(viewModel: HomeViewModel) { - val chatState by viewModel.chatState.collectAsState() - val suggestions by viewModel.suggestions.collectAsState() - - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(bottom = 16.dp) - ) { - item { - Spacer(modifier = Modifier.height(64.dp)) - AiCentralGraphic(isThinking = chatState.messages.any { it.isLoading }) - Spacer(modifier = Modifier.height(24.dp)) - } - - val rows = (suggestions.size + 1) / 2 - items(rows) { rowIndex -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - val itemIndex1 = rowIndex * 2 - Box(modifier = Modifier.weight(1f)) { - ActionCard( - text = suggestions[itemIndex1].title, - icon = suggestions[itemIndex1].icon, - onClick = suggestions[itemIndex1].action - ) - } - - val itemIndex2 = itemIndex1 + 1 - Box(modifier = Modifier.weight(1f)) { - if (itemIndex2 < suggestions.size) { - ActionCard( - text = suggestions[itemIndex2].title, - icon = suggestions[itemIndex2].icon, - onClick = suggestions[itemIndex2].action - ) - } - } - } - } - } -} - - -@Composable -fun ChatScreenContent( - messages: List, - topicForLoading: String? -) { - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - - LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { - scope.launch { - listState.animateScrollToItem(messages.size - 1) - } - } - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp), - state = listState, - contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp) - ) { - items(items = messages, key = { it.hashCode() }) { message -> - ChatMessageItem(message = message, topicForLoading = topicForLoading) - } - } -} - -@Composable -fun AiHistoryScreen( - sessions: List, - onNewChatClicked: () -> Unit, - onSessionClicked: (AiChatSession) -> Unit, - onDeleteSession: (AiChatSession) -> Unit -) { - val isDark = isSystemInDarkTheme() - - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - OutlinedButton( - onClick = onNewChatClicked, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outline), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface - ) - ) { - Icon(Icons.Rounded.AddComment, contentDescription = stringResource(R.string.new_chat)) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.new_chat)) - } - Spacer(modifier = Modifier.height(24.dp)) - Text( - stringResource(R.string.history), - style = MaterialTheme.typography.titleMedium, - color = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (sessions.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = 80.dp), - contentAlignment = Alignment.Center - ) { - Text( - stringResource(R.string.no_chat_history), - color = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 32.dp) - ) - } - } else { - LazyColumn( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(items = sessions, key = { it.id }) { session -> - HistoryItem( - session = session, - onClick = { onSessionClicked(session) }, - onDelete = { onDeleteSession(session) } - ) - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun HistoryItem(session: AiChatSession, onClick: () -> Unit, onDelete: () -> Unit) { - var showDeleteConfirm by remember { mutableStateOf(false) } - val isDark = isSystemInDarkTheme() - - if (showDeleteConfirm) { - AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, - title = { Text(stringResource(R.string.delete_chat)) }, - text = { Text(stringResource(R.string.delete_chat_description)) }, - confirmButton = { - TextButton( - onClick = { - onDelete() - showDeleteConfirm = false - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.delete)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .combinedClickable( - onClick = onClick, - onLongClick = { showDeleteConfirm = true } - ), - color = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val serviceIcon = if (session.serviceName == AiService.PERPLEXITY.name) { - Icons.Rounded.Search - } else { - Icons.Rounded.AutoAwesome - } - - Icon( - imageVector = serviceIcon, - contentDescription = session.serviceName, - tint = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = session.title, - color = if (isDark) Color.White.copy(alpha = 0.9f) else MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - } -} - -@Composable -fun AiTopBar( - viewModel: HomeViewModel, - showHistory: Boolean, - onToggleHistory: () -> Unit, - isPerplexityEnabled: Boolean -) { - val currentAiMode by viewModel.aiMode.collectAsState() - var showModelMenu by remember { mutableStateOf(false) } - val selectedService by viewModel.selectedAiService.collectAsState() - var showServiceMenu by remember { mutableStateOf(false) } - val isDark = isSystemInDarkTheme() - - val geminiIconUrl = "https://cdn-1.webcatalog.io/catalog/google-bard/google-bard-icon-filled-256.webp?v=1760920816570" - val perplexityIconUrl = "https://framerusercontent.com/images/gcMkPKyj2RX8EOEja8A1GWvCb7E.jpg?width=2000&height=2000" - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { - AnimatedVisibility(visible = selectedService == AiService.GEMINI) { - Box { - Row( - modifier = Modifier - .clip(CircleShape) - .background(if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable { showModelMenu = true } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = when (currentAiMode) { - AiMode.NOTE_ASSISTANT -> Icons.AutoMirrored.Rounded.Notes - AiMode.CREATIVE_MIND -> Icons.Rounded.Psychology - AiMode.ACADEMIC_RESEARCHER -> Icons.Rounded.School - AiMode.PROFESSIONAL_STRATEGIST -> Icons.Rounded.GolfCourse - }, - contentDescription = "AI Model", - tint = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = when (currentAiMode) { - AiMode.NOTE_ASSISTANT -> stringResource(R.string.ai_mode_note_assistant) - AiMode.CREATIVE_MIND -> stringResource(R.string.ai_mode_creative_mind) - AiMode.ACADEMIC_RESEARCHER -> stringResource(R.string.ai_mode_academic_research) - AiMode.PROFESSIONAL_STRATEGIST -> stringResource(R.string.ai_mode_professional_strategy) - }, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp - ) - Icon( - Icons.Rounded.ExpandMore, - contentDescription = "Change Model", - tint = if (isDark) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - DropdownMenu( - expanded = showModelMenu, - onDismissRequest = { showModelMenu = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.ai_mode_note_assistant)) }, - onClick = { - viewModel.setAiMode(AiMode.NOTE_ASSISTANT) - showModelMenu = false - }, - leadingIcon = { - Icon( - Icons.Rounded.Notes, - contentDescription = stringResource(R.string.ai_mode_note_assistant) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.ai_mode_creative_mind)) }, - onClick = { - viewModel.setAiMode(AiMode.CREATIVE_MIND) - showModelMenu = false - }, - leadingIcon = { - Icon( - Icons.Rounded.Psychology, - contentDescription = stringResource(R.string.ai_mode_creative_mind) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.ai_mode_academic_research)) }, - onClick = { - viewModel.setAiMode(AiMode.ACADEMIC_RESEARCHER) - showModelMenu = false - }, - leadingIcon = { - Icon( - Icons.Rounded.School, - contentDescription = stringResource(R.string.ai_mode_academic_research) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.ai_mode_professional_strategy)) }, - onClick = { - viewModel.setAiMode(AiMode.PROFESSIONAL_STRATEGIST) - showModelMenu = false - }, - leadingIcon = { - Icon( - Icons.Rounded.GolfCourse, - contentDescription = stringResource(R.string.ai_mode_professional_strategy) - ) - } - ) - } - } - } - - Box(modifier = Modifier.padding(start = if (selectedService == AiService.GEMINI) 8.dp else 0.dp)) { - Row( - modifier = Modifier - .clip(CircleShape) - .background(if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceContainerHigh) - .clickable { showServiceMenu = true } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = rememberAsyncImagePainter(model = if (selectedService == AiService.GEMINI) geminiIconUrl else perplexityIconUrl), - contentDescription = "AI Service", - modifier = Modifier - .size(20.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (selectedService == AiService.GEMINI) "Gemini" else "Perplexity", - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp - ) - Icon( - Icons.Rounded.ExpandMore, - contentDescription = "Change Service", - tint = if (isDark) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - DropdownMenu( - expanded = showServiceMenu, - onDismissRequest = { showServiceMenu = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - DropdownMenuItem( - text = { Text("Gemini") }, - onClick = { - viewModel.selectAiService(AiService.GEMINI) - showServiceMenu = false - }, - leadingIcon = { - Image( - painter = rememberAsyncImagePainter(model = geminiIconUrl), - contentDescription = "Gemini", - modifier = Modifier.size(24.dp).clip(CircleShape), - contentScale = ContentScale.Crop - ) - } - ) - if (isPerplexityEnabled) { - DropdownMenuItem( - text = { Text("Perplexity") }, - onClick = { - viewModel.selectAiService(AiService.PERPLEXITY) - showServiceMenu = false - }, - leadingIcon = { - Image( - painter = rememberAsyncImagePainter(model = perplexityIconUrl), - contentDescription = "Perplexity", - modifier = Modifier.size(24.dp).clip(CircleShape), - contentScale = ContentScale.Crop - ) - } - ) - } - } - } - } - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = onToggleHistory) { - Icon( - if (showHistory) Icons.AutoMirrored.Filled.Chat else Icons.Rounded.History, - contentDescription = "Toggle History", - tint = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@Composable -fun AiCentralGraphic(isThinking: Boolean) { - val infiniteTransition = rememberInfiniteTransition(label = "orb_animation") - val rotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable(tween(if (isThinking) 2000 else 10000, easing = LinearEasing), RepeatMode.Restart), - label = "orb_rotation" - ) - val scale by animateFloatAsState( - targetValue = if (isThinking) 1.1f else 1.0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "orb_scale" - ) - - val isDark = isSystemInDarkTheme() - val primaryColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - - Box( - modifier = Modifier.size(120.dp), - contentAlignment = Alignment.Center - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - drawCircle( - brush = Brush.radialGradient( - colors = listOf(primaryColor.copy(alpha = 0.1f), Color.Transparent), - ), - radius = (size.minDimension / 2.0f) * scale - ) - } - Box( - modifier = Modifier - .size(120.dp) - .graphicsLayer { - rotationZ = rotation - } - .border( - BorderStroke( - 2.dp, - Brush.sweepGradient( - 0.0f to Color.Transparent, - 0.7f to primaryColor, - 1.0f to Color.Transparent - ) - ), - CircleShape - ), - ) - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = "AI Core", - tint = primaryColor, - modifier = Modifier.size(50.dp) - ) - } -} - -@Composable -fun ActionCard(text: String, icon: ImageVector, onClick: () -> Unit) { - val isDark = isSystemInDarkTheme() - Surface( - onClick = onClick, - shape = RoundedCornerShape(16.dp), - color = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier - .fillMaxWidth() - .height(100.dp) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.SpaceBetween - ) { - Icon( - imageVector = icon, - contentDescription = text, - tint = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = text, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } -} - - -@Composable -fun ChatMessageItem( - message: com.babelsoftware.airnote.domain.model.ChatMessage, - topicForLoading: String? -) { - val isUser = message.participant == Participant.USER - val isDark = isSystemInDarkTheme() - - Row( - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 8.dp) - .fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start - ) { - if (message.isLoading) { - if (message.text.startsWith("// Generating Note...")) { - TerminalLoadingIndicator(topic = topicForLoading ?: "") - } else { - TypingIndicator() - } - } else { - val bubbleShape = RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = if (isUser) 16.dp else 0.dp, - bottomEnd = if (isUser) 0.dp else 16.dp - ) - - val textColor = if (isDark) { - if (message.participant == Participant.ERROR) Color.White else Color.White.copy(alpha = 0.9f) - } else { - when (message.participant) { - Participant.USER -> MaterialTheme.colorScheme.onPrimaryContainer - Participant.MODEL -> MaterialTheme.colorScheme.onSurfaceVariant - Participant.ERROR -> MaterialTheme.colorScheme.onErrorContainer - } - } - - Box( - modifier = Modifier - .clip(bubbleShape) - .then( - if (isDark) { - Modifier - .background( - brush = when (message.participant) { - Participant.USER -> Brush.verticalGradient( - colors = listOf( - Color(0xFF33A2FF).copy(alpha = 0.4f), - Color(0xFF33A2FF).copy(alpha = 0.1f) - ) - ) - - Participant.MODEL -> Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.15f), - Color.White.copy(alpha = 0.05f) - ) - ) - - Participant.ERROR -> Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.error.copy(alpha = 0.4f), - MaterialTheme.colorScheme.error.copy(alpha = 0.2f) - ) - ) - } - ) - .border( - width = 1.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.3f), - Color.Transparent - ) - ), - shape = bubbleShape - ) - } else { - Modifier.background( - color = when (message.participant) { - Participant.USER -> MaterialTheme.colorScheme.primaryContainer - Participant.MODEL -> MaterialTheme.colorScheme.surfaceVariant - Participant.ERROR -> MaterialTheme.colorScheme.errorContainer - } - ) - } - ) - .padding(12.dp) - ) { - RenderMessageWithSources( - fullText = message.text.replace(Regex("[*#]"), "").trim(), - baseColor = textColor, - linkColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - ) - } - } - } -} - -@Composable -private fun RenderMessageWithSources( - fullText: String, - baseColor: Color, - linkColor: Color -) { - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - val sourcesHeaderText = "**Sources:**" - val parts = fullText.split(sourcesHeaderText, limit = 2) - val mainText = parts[0] - val sourcesText = if (parts.size > 1) parts[1] else null - val sourceRegex = remember { Regex("""\[\d+\]\s*(.*?)\s*\((https?://[^\s)]+)\)""") } - - val annotatedString = buildAnnotatedString { - withStyle(style = SpanStyle(color = baseColor)) { - append(mainText) - } - if (sourcesText != null) { - withStyle(style = SpanStyle(color = baseColor, fontWeight = FontWeight.Bold)) { - append("\n\n\nSources:") - } - - var lastIndex = 0 - sourceRegex.findAll(sourcesText).forEach { matchResult -> - val title = matchResult.groupValues[1] - val url = matchResult.groupValues[2] - withStyle(style = SpanStyle(color = baseColor)) { - append(sourcesText.substring(lastIndex, matchResult.range.first)) - } - pushStringAnnotation(tag = "URL", annotation = url) - withStyle(style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { - append("\n[${matchResult.value.substring(1,2)}] $title") - } - pop() - - lastIndex = matchResult.range.last + 1 - } - if (lastIndex < sourcesText.length) { - withStyle(style = SpanStyle(color = baseColor)) { - append(sourcesText.substring(lastIndex)) - } - } - } - } - - ClickableText( - text = annotatedString, - style = MaterialTheme.typography.bodyLarge, - onClick = { offset -> - annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - try { - uriHandler.openUri(annotation.item) - } catch (e: ActivityNotFoundException) { - Toast.makeText(context, "Web tarayıcısı bulunamadı.", Toast.LENGTH_SHORT).show() - } catch (e: IllegalArgumentException) { - Toast.makeText(context, "URL açılamadı.", Toast.LENGTH_SHORT).show() - } - } - } - ) -} - -@Composable -fun RedesignedChatInputBar( - text: String, - onValueChange: (String) -> Unit, - onSendMessage: () -> Unit, - onImagePickerClicked: () -> Unit, - enabled: Boolean, - modifier: Modifier = Modifier, - showAttachmentButton: Boolean, - placeholderText: String -) { - val haptic = LocalHapticFeedback.current - val isDark = isSystemInDarkTheme() - val primaryAccentColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - val chatState by hiltViewModel().chatState.collectAsState() - - Surface( - modifier = modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(24.dp), - color = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.outline) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - AnimatedVisibility(visible = showAttachmentButton) { - IconButton(onClick = onImagePickerClicked, enabled = enabled) { - Icon( - Icons.Default.AttachFile, - contentDescription = "Attach File", - tint = if (isDark) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - TextField( - value = text, - onValueChange = onValueChange, - modifier = Modifier.weight(1f), - enabled = enabled, - placeholder = { - Text(placeholderText, color = if (isDark) Color.White.copy(alpha = 0.5f) else MaterialTheme.colorScheme.onSurfaceVariant) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = primaryAccentColor, - focusedTextColor = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = if (isDark) Color.White.copy(alpha = 0.9f) else MaterialTheme.colorScheme.onSurface, - disabledPlaceholderColor = if (isDark) Color.White.copy(alpha = 0.3f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - ) - AnimatedVisibility(visible = (text.isNotBlank() || chatState.pendingAttachmentUri != null) && enabled) { - IconButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onSendMessage() - }, - modifier = Modifier.padding(start = 8.dp), - colors = IconButtonDefaults.iconButtonColors( - containerColor = primaryAccentColor, - contentColor = if (isDark) Color.White else MaterialTheme.colorScheme.onPrimary - ) - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = "Send message" - ) - } - } - } - } -} - -@Composable -fun PreChatInputBar( - text: String, - onValueChange: (String) -> Unit, - onSendMessage: (String) -> Unit, - onImagePickerClicked: () -> Unit, - enabled: Boolean, - modifier: Modifier = Modifier, - showAttachmentButton: Boolean -) { - val isDark = isSystemInDarkTheme() - val primaryAccentColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - val chatState by hiltViewModel().chatState.collectAsState() - - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - shape = RoundedCornerShape(24.dp), - color = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainerHigh, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.outline) - ) { - Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = text, - onValueChange = onValueChange, - modifier = Modifier.weight(1f), - enabled = enabled, - placeholder = { - Text(stringResource(R.string.ask_airnote_ai), color = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant) - }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null, tint = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = primaryAccentColor, - focusedTextColor = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = if (isDark) Color.White.copy(alpha = 0.9f) else MaterialTheme.colorScheme.onSurface, - ), - singleLine = true - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - AnimatedVisibility(visible = showAttachmentButton) { - IconButton(onClick = onImagePickerClicked, enabled = enabled) { - Icon( - Icons.Default.AttachFile, - contentDescription = "Attach File", - tint = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - val searchString = stringResource(R.string.ai_search) - val thinkString = stringResource(R.string.ai_think) - - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) - ) { - InputActionButton( - text = searchString, - icon = Icons.Rounded.AutoAwesome, - onClick = { onValueChange("$searchString: ") } - ) - InputActionButton( - text = thinkString, - icon = Icons.Default.Language, - onClick = { onValueChange("$thinkString: ") } - ) - } - - IconButton( - onClick = { if (text.isNotBlank() || chatState.pendingAttachmentUri != null) onSendMessage(text) }, - enabled = (text.isNotBlank() || chatState.pendingAttachmentUri != null) && enabled, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - colors = IconButtonDefaults.iconButtonColors( - containerColor = primaryAccentColor, - contentColor = if (isDark) Color.White else MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = (if (isDark) Color.White else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.12f), - disabledContentColor = (if (isDark) Color.White else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.38f) - ) - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = "Send" - ) - } - } - } - } -} - -@Composable -private fun InputActionButton(text: String, icon: ImageVector, onClick: () -> Unit) { - val isDark = isSystemInDarkTheme() - TextButton( - onClick = onClick, - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.textButtonColors( - containerColor = if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.surfaceContainer, - contentColor = if (isDark) Color.White.copy(alpha = 0.9f) else MaterialTheme.colorScheme.onSurface - ), - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outlineVariant), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - } -} - -@Composable -fun AiDisclaimerText() { - val uriHandler = LocalUriHandler.current - val disclaimerText = stringResource(R.string.ai_disclaimer_text) - val linkText = stringResource(R.string.ai_disclaimer_link_text) - val linkUrl = stringResource(R.string.ai_disclaimer_link_url) - val isDark = isSystemInDarkTheme() - - val annotatedString = buildAnnotatedString { - append(disclaimerText) - pushStringAnnotation(tag = "URL", annotation = linkUrl) - withStyle( - style = SpanStyle( - color = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ) - ) { - append(linkText) - } - pop() - } - - ClickableText( - text = annotatedString, - onClick = { offset -> - annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - uriHandler.openUri(annotation.item) - } - }, - style = MaterialTheme.typography.bodySmall.copy( - color = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = 8.dp) - ) -} - -@Composable -fun AskAiQuestionDialog( - onDismiss: () -> Unit, - onConfirm: (String) -> Unit -) { - var question by remember { mutableStateOf("") } - val maxChars = 350 - val isDark = isSystemInDarkTheme() - val primaryAccentColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(24.dp), - color = if (isDark) Color(0xFF10141C) else MaterialTheme.colorScheme.surfaceContainerHigh, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outlineVariant) - ) { - Column( - modifier = Modifier.padding(24.dp) - ) { - Text( - text = stringResource(R.string.ask_a_question_title), - style = MaterialTheme.typography.headlineSmall, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(16.dp)) - TextField( - value = question, - onValueChange = { if (it.length <= maxChars) question = it }, - placeholder = { Text(stringResource(R.string.ask_a_question), color = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant) }, - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = primaryAccentColor, - focusedTextColor = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = if (isDark) Color.White.copy(alpha = 0.9f) else MaterialTheme.colorScheme.onSurface, - ), - shape = RoundedCornerShape(16.dp) - ) - Text( - text = "${question.length} / $maxChars", - textAlign = TextAlign.End, - style = MaterialTheme.typography.bodySmall, - color = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel), color = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { onConfirm(question) }, - enabled = question.isNotBlank(), - colors = ButtonDefaults.buttonColors( - containerColor = primaryAccentColor, - contentColor = if (isDark) Color(0xFF10141C) else MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = (if (isDark) Color.White else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.12f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Text(stringResource(R.string.send)) - } - } - } - } - } -} - -@Composable -fun CreateDraftDialog( - onDismiss: () -> Unit, - onConfirm: (String) -> Unit -) { - var topic by remember { mutableStateOf("") } - val maxChars = 250 - val isDark = isSystemInDarkTheme() - val primaryAccentColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(24.dp), - color = if (isDark) Color(0xFF10141C) else MaterialTheme.colorScheme.surfaceContainerHigh, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outlineVariant) - ) { - Column( - modifier = Modifier.padding(24.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = null, - tint = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(R.string.new_ai_note_draft), - style = MaterialTheme.typography.headlineSmall, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.sample_question_request), - style = MaterialTheme.typography.bodyMedium, - color = if (isDark) Color.White.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(16.dp)) - - TextField( - value = topic, - onValueChange = { if (it.length <= maxChars) topic = it }, - placeholder = { Text(stringResource(R.string.example_question), color = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - unfocusedContainerColor = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = primaryAccentColor, - focusedTextColor = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = if (isDark) Color.White.copy(alpha = 0.9f) else MaterialTheme.colorScheme.onSurface, - ), - shape = RoundedCornerShape(16.dp) - ) - Text( - text = "${topic.length} / $maxChars", - textAlign = TextAlign.End, - style = MaterialTheme.typography.bodySmall, - color = if (isDark) Color.White.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp) - ) - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel), color = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { onConfirm(topic) }, - enabled = topic.isNotBlank(), - colors = ButtonDefaults.buttonColors( - containerColor = primaryAccentColor, - contentColor = if (isDark) Color(0xFF10141C) else MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = (if (isDark) Color.White else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.12f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Text(stringResource(R.string.create)) - } - } - } - } - } -} - - -@Composable -fun DraftDisplay(draft: DraftedNote?, onSave: () -> Unit, onRegenerate: () -> Unit) { - if (draft == null) return - val isDark = isSystemInDarkTheme() - val primaryAccentColor = if (isDark) Color(0xFF33A2FF) else MaterialTheme.colorScheme.primary - - Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Column( - modifier = Modifier.fillMaxSize() - ) { - Surface( - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - color = if (isDark) Color.White.copy(alpha = 0.05f) else MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.1f) else MaterialTheme.colorScheme.outlineVariant) - ) { - LazyColumn( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp) - ) { - item { - Text( - text = draft.title, - style = MaterialTheme.typography.titleLarge, - color = if (isDark) Color.White else MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 12.dp) - ) - } - item { - Text( - text = draft.content, - style = MaterialTheme.typography.bodyLarge, - color = if (isDark) Color.White.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 24.sp - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onRegenerate, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, primaryAccentColor) - ) { - Icon(Icons.Rounded.AutoAwesome, contentDescription = null, tint = primaryAccentColor) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.regenerate_note), color = primaryAccentColor) - } - Button( - onClick = onSave, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = primaryAccentColor, - contentColor = if (isDark) Color(0xFF10141C) else MaterialTheme.colorScheme.onPrimary - ) - ) { - Text(stringResource(R.string.save_note)) - } - } - } - } -} - -@Composable -private fun TypingIndicator() { - val transition = rememberInfiniteTransition(label = "typing_indicator_transition") - val dotSize = 8.dp - val dotSpacing = 12.dp - - @Composable - fun Dot(offsetY: Float) { - Box( - modifier = Modifier - .size(dotSize) - .offset(y = offsetY.dp) - .background( - color = (if (isSystemInDarkTheme()) Color.White else MaterialTheme.colorScheme.onSurfaceVariant).copy( - alpha = 0.5f - ), - shape = CircleShape - ) - ) - } - - val yOffset1 by transition.animateFloat( - initialValue = 0f, - targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(400), - repeatMode = RepeatMode.Reverse - ), label = "dot1_offset" - ) - val yOffset2 by transition.animateFloat( - initialValue = 0f, - targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(400, delayMillis = 150), - repeatMode = RepeatMode.Reverse - ), label = "dot2_offset" - ) - val yOffset3 by transition.animateFloat( - initialValue = 0f, - targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(400, delayMillis = 300), - repeatMode = RepeatMode.Reverse - ), label = "dot3_offset" - ) - - Row( - modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(dotSpacing) - ) { - Dot(offsetY = yOffset1) - Dot(offsetY = yOffset2) - Dot(offsetY = yOffset3) - } -} - -@Composable -fun TerminalLoadingIndicator(topic: String) { - var displayedText by remember { mutableStateOf("") } - val fullText = """ -[INFO] Connecting to AirNote's Kai AI services... -[INFO] Authentication successful. -[PROCESS] Analyzing topic: "$topic" -[PROCESS] Generating content blocks... -[PROCESS] Assembling draft... -[SUCCESS] Note draft created. - """.trimIndent() - - LaunchedEffect(Unit) { - fullText.lines().forEach { line -> - for (char in line) { - displayedText += char - delay(10) - } - displayedText += "\n" - delay(280) - } - } - - val infiniteTransition = rememberInfiniteTransition(label = "cursor_blink") - val cursorAlpha by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(500), - repeatMode = RepeatMode.Reverse - ), label = "cursor_alpha" - ) - - val isDark = isSystemInDarkTheme() - val textColor = if (isDark) Color.Green.copy(alpha = 0.8f) else MaterialTheme.colorScheme.primary - - Surface( - shape = RoundedCornerShape(16.dp), - color = if (isDark) Color.Black.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceContainerLowest, - border = BorderStroke(1.dp, if (isDark) Color.White.copy(alpha = 0.2f) else MaterialTheme.colorScheme.outlineVariant) - ) { - Row(modifier = Modifier.padding(12.dp)) { - Text( - text = displayedText, - color = textColor, - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - lineHeight = 20.sp - ) - Text( - text = "█", - color = textColor.copy(alpha = cursorAlpha), - fontFamily = FontFamily.Monospace, - fontSize = 14.sp - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/HomeScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/HomeScreen.kt deleted file mode 100644 index b0300b0..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/HomeScreen.kt +++ /dev/null @@ -1,1362 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home - -import android.app.Activity -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.AccountBalance -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Alarm -import androidx.compose.material.icons.filled.Apartment -import androidx.compose.material.icons.filled.Assessment -import androidx.compose.material.icons.filled.AttachMoney -import androidx.compose.material.icons.filled.Backup -import androidx.compose.material.icons.filled.Book -import androidx.compose.material.icons.filled.Bookmark -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Cake -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.Campaign -import androidx.compose.material.icons.filled.CardGiftcard -import androidx.compose.material.icons.filled.Circle -import androidx.compose.material.icons.filled.Cloud -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.ContentCut -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.DirectionsCar -import androidx.compose.material.icons.filled.Eco -import androidx.compose.material.icons.filled.Event -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FitnessCenter -import androidx.compose.material.icons.filled.Flag -import androidx.compose.material.icons.filled.Flight -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.FolderOpen -import androidx.compose.material.icons.filled.Group -import androidx.compose.material.icons.filled.Headset -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Image -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.Lightbulb -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Map -import androidx.compose.material.icons.filled.Movie -import androidx.compose.material.icons.filled.MusicNote -import androidx.compose.material.icons.filled.Palette -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Pets -import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.Receipt -import androidx.compose.material.icons.filled.Restaurant -import androidx.compose.material.icons.filled.School -import androidx.compose.material.icons.filled.Shield -import androidx.compose.material.icons.filled.ShoppingCart -import androidx.compose.material.icons.filled.Spa -import androidx.compose.material.icons.filled.SportsEsports -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.Store -import androidx.compose.material.icons.filled.ThumbUp -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material.icons.filled.Work -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.LockOpen -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SearchBar -import androidx.compose.material3.SmallFloatingActionButton -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.domain.model.Settings -import com.babelsoftware.airnote.presentation.components.CloseButton -import com.babelsoftware.airnote.presentation.components.DeleteButton -import com.babelsoftware.airnote.presentation.components.NotesScaffold -import com.babelsoftware.airnote.presentation.components.PinButton -import com.babelsoftware.airnote.presentation.components.SelectAllButton -import com.babelsoftware.airnote.presentation.components.TitleText -import com.babelsoftware.airnote.presentation.components.UpdateScreen -import com.babelsoftware.airnote.presentation.components.VaultButton -import com.babelsoftware.airnote.presentation.components.defaultScreenEnterAnimation -import com.babelsoftware.airnote.presentation.components.defaultScreenExitAnimation -import com.babelsoftware.airnote.presentation.screens.home.desktop.DesktopHomeScreen -import com.babelsoftware.airnote.presentation.screens.home.viewmodel.HomeViewModel -import com.babelsoftware.airnote.presentation.screens.home.widgets.FolderActionBottomSheet -import com.babelsoftware.airnote.presentation.screens.home.widgets.MoveToFolderDialog -import com.babelsoftware.airnote.presentation.screens.home.widgets.NoteFilter -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.PasswordPrompt -import com.babelsoftware.airnote.presentation.screens.settings.settings.shapeManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalAnimationApi::class) -@Composable -fun HomeView ( - viewModel: HomeViewModel = hiltViewModel(), - settingsModel: SettingsViewModel, - settings: Settings, - onSettingsClicked: () -> Unit, - onNoteClicked: (noteId: Int, isVault: Boolean, folderId: Long?) -> Unit, - onNavigateToAbout: () -> Unit -) { - val context = LocalContext.current - var showUnlockDialog by remember { mutableStateOf(null) } - var showBulkUnlockDialog by remember { mutableStateOf(false) } - val activity = context as? Activity - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - val allFolders by viewModel.allFolders.collectAsState() - val notes by viewModel.displayedNotes.collectAsState() - val selectedFolderId by viewModel.selectedFolderId.collectAsState() - val query by viewModel.searchQuery.collectAsState() - val isVaultMode by viewModel.isVaultMode.collectAsState() - val liveSettings by settingsModel.settings - - if (activity != null) { - val windowSizeClass = calculateWindowSizeClass(activity) - val widthSizeClass = windowSizeClass.widthSizeClass - val isCompact = widthSizeClass == WindowWidthSizeClass.Compact - val shouldShowDesktopUi = !isCompact && settings.desktopModeEnabled - - if (shouldShowDesktopUi) { - DesktopHomeScreen( - viewModel = viewModel, - settingsModel = settingsModel, - settings = settings, - onNoteClicked = onNoteClicked, - onSettingsClicked = onSettingsClicked - ) - } else { - var showFolderSheet by remember { mutableStateOf(false) } - val selectedFolder = remember(selectedFolderId, allFolders) { - allFolders.find { it.id == selectedFolderId } - } - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { uri: Uri? -> - if (uri != null) { - val mimeType = context.contentResolver.getType(uri) - viewModel.onAttachmentSelected(uri, mimeType ?: "image/*") - } - } - ) - - val filePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { uri: Uri? -> - if (uri != null) { - val mimeType = context.contentResolver.getType(uri) - if (mimeType == "text/plain") { - viewModel.onAttachmentSelected(uri, mimeType) - } else { - scope.launch { - snackbarHostState.showSnackbar("Currently only '.txt' files are supported.") - } - } - } - } - ) - - val imageAnalysisLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { uri: Uri? -> - if (uri != null) { - val mimeType = context.contentResolver.getType(uri) - val analysisPrompt = context.getString(R.string.prompt_airnote_ai_analyzeimage) - - viewModel.analyzeFileAndCreateDraft(analysisPrompt, uri, mimeType ?: "image/*") - } - } - ) - - val fileAnalysisLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { uri: Uri? -> - if (uri != null) { - val mimeType = context.contentResolver.getType(uri) - if (mimeType == "text/plain") { - val analysisPrompt = context.getString(R.string.prompt_airnote_ai_analyzeimage) - viewModel.analyzeFileAndCreateDraft(analysisPrompt, uri, mimeType) - } else { - scope.launch { - snackbarHostState.showSnackbar("Currently only '.txt' files are supported for analysis.") - } - } - } - } - ) - - var showAttachmentTypeSheet by remember { mutableStateOf(false) } - if (showAttachmentTypeSheet) { - AttachmentTypeBottomSheet( - onDismiss = { showAttachmentTypeSheet = false }, - onImageClicked = { - imagePickerLauncher.launch("image/*") - }, - onFileClicked = { - filePickerLauncher.launch("text/plain") - } - ) - } - - LaunchedEffect(Unit) { - viewModel.uiActionChannel.collect { action -> - when (action) { - is HomeViewModel.UiAction.RequestImageForAnalysis -> { - imageAnalysisLauncher.launch("image/*") - } - is HomeViewModel.UiAction.RequestFileForAnalysis -> { - fileAnalysisLauncher.launch("text/plain") - } - is HomeViewModel.UiAction.RequestAttachmentType -> { - showAttachmentTypeSheet = true - } - } - } - } - - LaunchedEffect(key1 = Unit) { - settingsModel.checkForNewUpdate(context) - } - - if (showFolderSheet) { - FolderSelectionBottomSheet( - allFolders = allFolders, - selectedFolderId = selectedFolderId, - onDismiss = { showFolderSheet = false }, - onFolderSelected = { folderId -> - viewModel.selectFolder(folderId) - showFolderSheet = false - }, - onAddFolderClicked = { - viewModel.setAddFolderDialogVisibility(true) - }, - onFolderLongClick = { folder -> - viewModel.onFolderLongPressed(folder) - } - ) - } - - if (settingsModel.showUpdateDialog.value) { - UpdateScreen( - latestVersion = settingsModel.latestVersion.value, - onDismiss = { settingsModel.dismissUpdateDialog() }, - onNavigateToAbout = onNavigateToAbout - ) - } - val topBarColor = MaterialTheme.colorScheme.surfaceContainerLow - val listState = rememberLazyStaggeredGridState() - - val isFabExtended by remember { - derivedStateOf { - listState.firstVisibleItemIndex == 0 - } - } - viewModel.setFabExtended(isFabExtended) - - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - if (viewModel.isAiChatSheetVisible.value) { - ModalBottomSheet( - onDismissRequest = { viewModel.toggleAiChatSheet(false) }, - sheetState = sheetState, - modifier = Modifier.fillMaxWidth(), - tonalElevation = 0.dp - ) { - AiChatContainer(viewModel = viewModel, settings = liveSettings) - } - } - - if (viewModel.isPasswordPromptVisible.value) { - PasswordPrompt( - context = context, - text = stringResource(id = R.string.password_continue), - settingsViewModel = settingsModel, - onExit = { password -> - if (password != null) { - if (password.text.isNotBlank()) { - viewModel.encryptionHelper.setPassword(password.text) - viewModel.observeNotes() - } - } - viewModel.toggleIsPasswordPromptVisible(false) - } - ) - } - - if (viewModel.isAddFolderDialogVisible.value) { - AddFolderDialogWithIcons( - onDismiss = { viewModel.setAddFolderDialogVisibility(false) }, - onConfirm = { name, iconName -> - viewModel.addFolder(name, iconName) - } - ) - } - - viewModel.folderForAction.value?.let { folder -> - if (!viewModel.showDeleteConfirmDialog.value) { - FolderActionBottomSheet( - folder = folder, - onDismiss = { viewModel.onDismissFolderAction() }, - onEditClick = { viewModel.onEditFolderRequest() }, - onDeleteClick = { - viewModel.onDeleteFolderRequest() - } - ) - } - } - - viewModel.folderToEdit.value?.let { folder -> - AddFolderDialogWithIcons( - onDismiss = { viewModel.onDismissEditFolderDialog() }, - onConfirm = { name, iconName -> - viewModel.updateFolder(name, iconName) - }, - folderToEdit = folder - ) - } - - if (viewModel.isMoveToFolderDialogVisible.value) { - MoveToFolderDialog( - folders = allFolders, - onDismiss = { viewModel.setMoveToFolderDialogVisibility(false) }, - onFolderSelected = { folderId -> - viewModel.moveSelectedNotesToFolder(folderId) - } - ) - } - - if (viewModel.showDeleteConfirmDialog.value) { - viewModel.folderForAction.value?.let { folder -> - AlertDialog( - onDismissRequest = { viewModel.onDismissFolderAction() }, - title = { Text(stringResource(R.string.delete_folder)) }, - text = { - Text( - stringResource( - R.string.delete_folder_confirmation, - folder.name - ) - ) - }, - confirmButton = { - TextButton(onClick = { viewModel.confirmFolderDeletion() }) { - Text( - stringResource(R.string.delete), - color = MaterialTheme.colorScheme.error - ) - } - }, - dismissButton = { - TextButton(onClick = { viewModel.onDismissFolderAction() }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } - } - - LaunchedEffect(key1 = true) { - viewModel.uiEvent.collect { message -> - scope.launch { - snackbarHostState.showSnackbar(message) - } - } - } - - if (settingsModel.databaseUpdate.value) viewModel.observeNotes() - val containerColor = getContainerColor(settingsModel) - val sortedNotes = remember(notes, settings.sortDescending) { - notes.sortedWith(sorter(settings.sortDescending)) - } - - NotesScaffold( - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - floatingActionButton = { - AnimatedVisibility( - visible = viewModel.isFabExtended.value, - enter = slideInVertically(initialOffsetY = { it * 2 }), - exit = slideOutVertically(targetOffsetY = { it * 2 }) - ) { - MultiActionFloatingActionButton( - onNewNoteClicked = { onNoteClicked(0, viewModel.isVaultMode.value, selectedFolderId) }, - onAskAiClicked = { viewModel.toggleAiChatSheet(true) }, - onDreamJournalClicked = { viewModel.onDreamJournalClicked(onNoteClicked) } - ) - } - }, - topBar = { - AnimatedVisibility( - visible = viewModel.selectedNotes.isNotEmpty(), - enter = defaultScreenEnterAnimation(), - exit = defaultScreenExitAnimation() - ) { - SelectedNotesTopAppBar( - containerColor = topBarColor, - selectedNotes = viewModel.selectedNotes, - allNotes = notes, - settingsModel = settingsModel, - onPinClick = { viewModel.pinOrUnpinNotes() }, - onDeleteClick = { viewModel.toggleIsDeleteMode(true) }, - onSelectAllClick = { selectAllNotes(viewModel, notes) }, - onMoveToFolderClick = { - viewModel.setMoveToFolderDialogVisibility( - true - ) - }, - onCloseClick = { viewModel.selectedNotes.clear() }, - onLockClick = { - val anyLocked = viewModel.selectedNotes.any { it.isLocked } - if (anyLocked) { - if (settings.noteLockPassword.isNullOrBlank()) { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.first_set_a_note_password)) } - } else { - showBulkUnlockDialog = true - } - } else { - if (settings.noteLockPassword.isNullOrBlank()) { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.first_set_a_password_in_the_settings)) } - } else { - viewModel.toggleLockForSelectedNotes() - } - } - } - ) - } - - AnimatedVisibility( - visible = viewModel.selectedNotes.isEmpty(), - enter = defaultScreenEnterAnimation(), - exit = defaultScreenExitAnimation() - ) { - Column( - modifier = Modifier.background(topBarColor) - ) { - NotesSearchBar( - settingsModel = settingsModel, - query = query, - onQueryChange = { viewModel.changeSearchQuery(it) }, - onSettingsClick = onSettingsClicked, - onClearClick = { viewModel.changeSearchQuery("") }, - onVaultClicked = { - if (!isVaultMode) { - viewModel.toggleIsPasswordPromptVisible(true) - } else { - viewModel.toggleIsVaultMode(false) - viewModel.encryptionHelper.removePassword() - } - }, - isVaultMode = isVaultMode, - selectedFolderName = selectedFolder?.name ?: stringResource(R.string.all_notes), - selectedFolderIconName = selectedFolder?.iconName, - onFoldersClicked = { showFolderSheet = true } - ) - } - } - }, - content = { - NoteFilter( - listState = listState, - settingsViewModel = settingsModel, - containerColor = containerColor, - shape = shapeManager( - radius = settingsModel.settings.value.cornerRadius / 2, - isBoth = true - ), - notes = sortedNotes, - allFolders = allFolders, - onNoteClicked = { noteId -> - val clickedNote = viewModel.displayedNotes.value.find { it.id == noteId } - if (clickedNote != null) { - if (clickedNote.isLocked) { - if (settings.noteLockPassword.isNullOrBlank()) { - scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.note_is_locked_but_no_password)) } - } else { - showUnlockDialog = noteId - } - } else { - onNoteClicked(clickedNote.id, clickedNote.encrypted, clickedNote.folderId) - } - } - }, - selectedNotes = viewModel.selectedNotes, - viewMode = settingsModel.settings.value.viewMode, - searchText = query.ifBlank { null }, - isDeleteMode = viewModel.isDeleteMode.value, - onNoteUpdate = { note -> - scope.launch(Dispatchers.IO) { - viewModel.updateNote(note) - } - }, - onDeleteNote = { - viewModel.toggleIsDeleteMode(false) - viewModel.deleteNoteById(it) - }, - ) - } - ) - } - var showUnlockDialog by remember { mutableStateOf(null) } - if (showUnlockDialog != null) { - var passwordInput by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showUnlockDialog = null }, - title = { Text(stringResource(R.string.unlock_note)) }, - text = { - Column { - Text(stringResource(R.string.enter_your_password_to_view_this_note)) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = passwordInput, - onValueChange = { passwordInput = it }, - label = { Text(stringResource(R.string.note_password)) }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - Button(onClick = { - if (passwordInput == settings.noteLockPassword) { - val noteToOpen = notes.find { it.id == showUnlockDialog } - if (noteToOpen != null) { - onNoteClicked(noteToOpen.id, noteToOpen.encrypted, noteToOpen.folderId) - } - showUnlockDialog = null - } else { - scope.launch { snackbarHostState.showSnackbar("Wrong password!!") } - } - }) { Text("Aç") } - }, - dismissButton = { - TextButton(onClick = { showUnlockDialog = null }) { Text(stringResource(R.string.cancel)) } - } - ) - } - if (showBulkUnlockDialog) { - var passwordInput by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showBulkUnlockDialog = false }, - title = { Text(stringResource(R.string.unlock_selected_notes)) }, - text = { - OutlinedTextField( - value = passwordInput, - onValueChange = { passwordInput = it }, - label = { Text(stringResource(R.string.note_password)) }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true - ) - }, - confirmButton = { - Button(onClick = { - if (passwordInput == settings.noteLockPassword) { - viewModel.toggleLockForSelectedNotes() - showBulkUnlockDialog = false - } else { - scope.launch { snackbarHostState.showSnackbar("Wrong password!") } - } - }) { Text(stringResource(R.string.confirm)) } - }, - dismissButton = { - TextButton(onClick = { showBulkUnlockDialog = false }) { Text(stringResource(R.string.cancel)) } - } - ) - } - } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - } -} - -@Composable -@ReadOnlyComposable -fun getContainerColor(settingsModel: SettingsViewModel): Color { - return if (settingsModel.settings.value.extremeAmoledMode) Color.Black else MaterialTheme.colorScheme.surfaceContainerHigh -} - -@Composable -private fun MultiActionFloatingActionButton( - onNewNoteClicked: () -> Unit, - onAskAiClicked: () -> Unit, - onDreamJournalClicked: () -> Unit -) { - var isExpanded by remember { mutableStateOf(false) } - val transition = updateTransition(targetState = isExpanded, label = "fab_transition") - val mainButtonRotation by transition.animateFloat( - label = "fab_rotation", - transitionSpec = { spring(stiffness = Spring.StiffnessMedium) } - ) { expanded -> - if (expanded) 45f else 0f - } - val secondaryButtonAlpha by transition.animateFloat( - label = "fab_alpha", - transitionSpec = { spring(stiffness = Spring.StiffnessMedium) } - ) { expanded -> - if (expanded) 1f else 0f - } - val secondaryButtonScale by transition.animateFloat( - label = "fab_scale", - transitionSpec = { spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) } - ) { expanded -> - if (expanded) 1f else 0.5f - } - var hopTranslationY by remember { mutableStateOf(0f) - } - - LaunchedEffect(isExpanded) { - if (isExpanded) { - delay(40) - launch { - animate(0f, -20f, animationSpec = spring(stiffness = Spring.StiffnessMedium)) { v, _ -> hopTranslationY = v } - animate(-20f, 0f, animationSpec = spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow)) { v, _ -> hopTranslationY = v } - } - } else { - launch { - animate(hopTranslationY, 0f, animationSpec = tween(100)) { v, _ -> hopTranslationY = v } - } - } - } - - - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - AnimatedVisibility( - visible = isExpanded, - enter = fadeIn(animationSpec = tween(150)), - exit = fadeOut(animationSpec = tween(150)) - ) { - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.graphicsLayer { - alpha = secondaryButtonAlpha - scaleX = secondaryButtonScale - scaleY = secondaryButtonScale - } - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 2.dp - ) { - Text( - text = stringResource(R.string.ai_button_texts), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge - ) - } - SmallFloatingActionButton( - onClick = { - onAskAiClicked() - isExpanded = false - }, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = stringResource(R.string.ai_button_texts), - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.graphicsLayer { - translationY = hopTranslationY - } - ) - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.graphicsLayer { - alpha = secondaryButtonAlpha - scaleX = secondaryButtonScale - scaleY = secondaryButtonScale - } - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 2.dp - ) { - Text( - text = stringResource(R.string.new_note), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge - ) - } - SmallFloatingActionButton( - onClick = { - onNewNoteClicked() - isExpanded = false - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) { - Icon( - Icons.Rounded.Edit, - contentDescription = stringResource(R.string.new_note), - modifier = Modifier.graphicsLayer { - translationY = hopTranslationY - } - ) - } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.graphicsLayer { - alpha = secondaryButtonAlpha - scaleX = secondaryButtonScale - scaleY = secondaryButtonScale - } - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 2.dp - ) { - Text( - text = stringResource(R.string.dream_journal_new_note), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge - ) - } - SmallFloatingActionButton( - onClick = { - onDreamJournalClicked() - isExpanded = false - }, - containerColor = MaterialTheme.colorScheme.secondaryContainer - ) { - Icon( - Icons.Default.Book, - contentDescription = "Dream Journal", - modifier = Modifier.graphicsLayer { - translationY = hopTranslationY - } - ) - } - } - } - } - - FloatingActionButton( - onClick = { isExpanded = !isExpanded }, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Icon( - Icons.Default.Add, - contentDescription = "Add", - modifier = Modifier.rotate(mainButtonRotation) - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SelectedNotesTopAppBar( - selectedNotes: List, - allNotes: List, - settingsModel: SettingsViewModel, - onPinClick: () -> Unit, - onDeleteClick: () -> Unit, - onSelectAllClick: () -> Unit, - onMoveToFolderClick: () -> Unit, - containerColor: Color, - onCloseClick: () -> Unit, - onLockClick: () -> Unit = {}, -) { - var deletelaert by remember { - mutableStateOf(false) - } - AnimatedVisibility(visible = deletelaert) { - AlertDialog(onDismissRequest = { deletelaert = false }, title = { - Text( - text = stringResource(id = R.string.alert_text) - ) - }, confirmButton = { - TextButton(onClick = { deletelaert=false - onDeleteClick() - }) { - Text(text = stringResource(id = R.string.yes), color = MaterialTheme.colorScheme.error ) - } - }, - dismissButton = { - TextButton(onClick = { deletelaert = false }) { - Text(text =stringResource(id = R.string.cancel)) - } - }) - - } - TopAppBar( - modifier = Modifier.padding(bottom = 36.dp), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = containerColor - ), - title = { TitleText(titleText = selectedNotes.size.toString()) }, - navigationIcon = { CloseButton(onCloseClicked = onCloseClick) }, - actions = { - Row { - IconButton(onClick = onLockClick) { - val allLocked = selectedNotes.all { it.isLocked } - Icon( - imageVector = if (allLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, - contentDescription = "Lock/Unlock" - ) - } - IconButton(onClick = onMoveToFolderClick) { - Icon(Icons.Default.FolderOpen, contentDescription = "Move to Folder") - } - PinButton(isPinned = selectedNotes.all { it.pinned }, onClick = onPinClick) - DeleteButton(onClick = { deletelaert = true }) - SelectAllButton( - enabled = selectedNotes.size != allNotes.size, - onClick = onSelectAllClick - ) - } - } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun NotesSearchBar( - settingsModel: SettingsViewModel, - query: String, - onQueryChange: (String) -> Unit, - onSettingsClick: () -> Unit, - onVaultClicked: () -> Unit, - onClearClick: () -> Unit, - isVaultMode: Boolean, - selectedFolderName: String, - selectedFolderIconName: String?, - onFoldersClicked: () -> Unit -) { - SearchBar( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = if (settingsModel.settings.value.makeSearchBarLonger) 16.dp else 36.dp, - vertical = 8.dp - ), - query = query, - placeholder = { Text(stringResource(R.string.search)) }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = "Search") }, - trailingIcon = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (query.isNotBlank()) { - CloseButton(contentDescription = "Clear", onCloseClicked = onClearClick) - } - if (settingsModel.settings.value.vaultSettingEnabled) { - VaultButton(isVaultMode) { onVaultClicked() } - } - AnimatedVisibility( - visible = query.isBlank(), - enter = fadeIn() + slideInHorizontally { it }, - exit = fadeOut() + slideOutHorizontally { it } - ) { - Button( - onClick = onFoldersClicked, - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - modifier = Modifier - .widthIn(max = 120.dp) - .padding(horizontal = 4.dp), - contentPadding = PaddingValues(horizontal = 12.dp) - ) { - Icon( - imageVector = selectedFolderIconName?.let { iconName -> - iconNameToVector(iconName) - } ?: Icons.Default.FolderOpen, - contentDescription = "Folders", - modifier = Modifier.size(18.dp) - ) - - if (selectedFolderName != stringResource(R.string.all_notes)) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = selectedFolderName, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - modifier = Modifier.basicMarquee( - iterations = Int.MAX_VALUE, - initialDelayMillis = 1000 - ) - ) - } - } - } - BadgedBox( - badge = { - if (settingsModel.updateAvailable.value) { - Badge() - } - } - ) { - IconButton(onClick = onSettingsClick) { - Icon( - imageVector = Icons.Rounded.Settings, - contentDescription = stringResource(R.string.screen_settings) - ) - } - } - } - }, - onQueryChange = onQueryChange, - onSearch = onQueryChange, - onActiveChange = {}, - active = false, - ) {} -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AttachmentTypeBottomSheet( - onDismiss: () -> Unit, - onImageClicked: () -> Unit, - onFileClicked: () -> Unit -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 32.dp, top = 16.dp) - ) { - ListItem( - headlineContent = { Text(stringResource(R.string.add_image)) }, - leadingContent = { Icon(Icons.Default.Image, contentDescription = "Image") }, - modifier = Modifier.clickable { - onImageClicked() - onDismiss() - } - ) - ListItem( - headlineContent = { Text(stringResource(R.string.add_text_file)) }, - leadingContent = { Icon(Icons.Default.Description, contentDescription = "Fİle") }, - modifier = Modifier.clickable { - onFileClicked() - onDismiss() - } - ) - } - } -} - -private fun selectAllNotes(viewModel: HomeViewModel, allNotes: List) { - allNotes.forEach { - if (!viewModel.selectedNotes.contains(it)) { - viewModel.selectedNotes.add(it) - } - } -} - -fun sorter(descending: Boolean): Comparator { - return if (descending) { - compareByDescending { it.createdAt } - } else { - compareBy { it.createdAt } - } -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -fun FolderSelectionBottomSheet( - allFolders: List, - selectedFolderId: Long?, - onDismiss: () -> Unit, - onFolderSelected: (Long?) -> Unit, - onAddFolderClicked: () -> Unit, - onFolderLongClick: (Folder) -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var searchQuery by remember { mutableStateOf("") } - - val filteredFolders = remember(searchQuery, allFolders) { - if (searchQuery.isBlank()) { - allFolders - } else { - allFolders.filter { folder -> - folder.name.contains(searchQuery, ignoreCase = true) - } - } - } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - scrimColor = Color.Black.copy(alpha = 0.6f) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - TextField( - value = searchQuery, - onValueChange = { newQuery -> searchQuery = newQuery }, - placeholder = { Text(stringResource(R.string.search_folders)) }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = CircleShape, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 400.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - FolderListItem( - name = stringResource(R.string.all_notes), - iconName = "Inbox", - isSelected = selectedFolderId == null, - onClick = { onFolderSelected(null) } - ) - } - items(items = filteredFolders, key = { it.id }) { folder -> - FolderListItem( - name = folder.name, - iconName = folder.iconName, - isSelected = selectedFolderId == folder.id, - onClick = { onFolderSelected(folder.id) }, - onLongClick = { onFolderLongClick(folder) } - ) - } - - item { - Button( - onClick = onAddFolderClicked, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) { - Icon(Icons.Default.Add, contentDescription = "Add New Folder") - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.add_new_folder)) - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun FolderListItem( - name: String, - iconName: String, - isSelected: Boolean, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null -) { - val selectedBorder = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null - val cardColors = CardDefaults.cardColors( - containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .border(selectedBorder ?: BorderStroke(0.dp, Color.Transparent), RoundedCornerShape(16.dp)) - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ), - shape = RoundedCornerShape(16.dp), - colors = cardColors - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = iconNameToVector(iconName), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = name, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) - } - } -} - -@Composable -fun AddFolderDialogWithIcons( - onDismiss: () -> Unit, - onConfirm: (name: String, iconName: String) -> Unit, - folderToEdit: Folder? = null -) { - var name by remember { mutableStateOf(folderToEdit?.name ?: "") } - var selectedIconName by remember { mutableStateOf(folderToEdit?.iconName ?: "Folder") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(if (folderToEdit == null) stringResource(R.string.new_folder) else stringResource(R.string.edit_folder)) }, - text = { - Column { - Text( - text = stringResource(R.string.preview), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - FolderListItem( - name = name.ifBlank { stringResource(R.string.folder_name) }, - iconName = selectedIconName, - isSelected = false, - onClick = {} - ) - Spacer(modifier = Modifier.height(20.dp)) - OutlinedTextField( - value = name, - onValueChange = { newName -> name = newName }, - label = { Text(stringResource(R.string.folder_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(16.dp)) - Text(stringResource(R.string.choose_icon), style = MaterialTheme.typography.labelLarge) - Spacer(modifier = Modifier.height(8.dp)) - IconPicker( - selectedIconName = selectedIconName, - onIconSelected = { selectedIconName = it } - ) - } - }, - confirmButton = { - TextButton( - onClick = { - if (name.isNotBlank()) { - onConfirm(name, selectedIconName) - } - }, - enabled = name.isNotBlank() - ) { - Text(stringResource(R.string.save)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} - -@Composable -fun IconPicker( - selectedIconName: String, - onIconSelected: (String) -> Unit -) { - val iconList = remember { materialIconsList.keys.toList() } - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 48.dp), - modifier = Modifier.heightIn(max = 200.dp) - ) { - items(items = iconList, key = { it }) { iconName -> - val isSelected = selectedIconName == iconName - IconButton( - onClick = { onIconSelected(iconName) }, - colors = IconButtonDefaults.iconButtonColors( - containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent - ) - ) { - Icon( - imageVector = materialIconsList.getValue(iconName), - contentDescription = iconName, - tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -fun iconNameToVector(name: String): ImageVector { - return materialIconsList[name] ?: Icons.Default.Folder -} - -val materialIconsList = mapOf( - "Folder" to Icons.Default.Folder, - "Inbox" to Icons.Default.FolderOpen, - "Bookmark" to Icons.Default.Bookmark, - "Favorite" to Icons.Default.Favorite, - "Home" to Icons.Default.Home, - "Star" to Icons.Default.Star, - "Event" to Icons.Default.Event, - "Work" to Icons.Default.Work, - "School" to Icons.Default.School, - "Person" to Icons.Default.Person, - "Group" to Icons.Default.Group, - "ShoppingCart" to Icons.Default.ShoppingCart, - "AttachMoney" to Icons.Default.AttachMoney, - "FitnessCenter" to Icons.Default.FitnessCenter, - "Travel" to Icons.Default.Flight, - "MusicNote" to Icons.Default.MusicNote, - "Movie" to Icons.Default.Movie, - "Book" to Icons.Default.Book, - "Code" to Icons.Default.Code, - "Cloud" to Icons.Default.Cloud, - "Lightbulb" to Icons.Default.Lightbulb, - "Pets" to Icons.Default.Pets, - "Build" to Icons.Default.Build, - "Palette" to Icons.Default.Palette, - "CardGiftcard" to Icons.Default.CardGiftcard, - "DirectionsCar" to Icons.Default.DirectionsCar, - "AccountBalance" to Icons.Default.AccountBalance, - "Alarm" to Icons.Default.Alarm, - "Apartment" to Icons.Default.Apartment, - "Assessment" to Icons.Default.Assessment, - "Backup" to Icons.Default.Backup, - "Cake" to Icons.Default.Cake, - "CameraAlt" to Icons.Default.CameraAlt, - "Campaign" to Icons.Default.Campaign, - "Chat" to Icons.AutoMirrored.Filled.Chat, - "Circle" to Icons.Default.Circle, - "ContentCut" to Icons.Default.ContentCut, - "Eco" to Icons.Default.Eco, - "Extension" to Icons.Default.Extension, - "Flag" to Icons.Default.Flag, - "Headset" to Icons.Default.Headset, - "Key" to Icons.Default.Key, - "Link" to Icons.Default.Link, - "Lock" to Icons.Default.Lock, - "Map" to Icons.Default.Map, - "PushPin" to Icons.Default.PushPin, - "Receipt" to Icons.Default.Receipt, - "Restaurant" to Icons.Default.Restaurant, - "Shield" to Icons.Default.Shield, - "Spa" to Icons.Default.Spa, - "SportsEsports" to Icons.Default.SportsEsports, - "Store" to Icons.Default.Store, - "ThumbUp" to Icons.Default.ThumbUp, - "Visibility" to Icons.Default.Visibility, - "Warning" to Icons.Default.Warning -) diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopAiScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopAiScreen.kt deleted file mode 100644 index 9fe19a5..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopAiScreen.kt +++ /dev/null @@ -1,1503 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home.desktop - -import android.net.Uri -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.Language -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.rounded.AddComment -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.ExpandMore -import androidx.compose.material.icons.rounded.GolfCourse -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Notes -import androidx.compose.material.icons.rounded.Psychology -import androidx.compose.material.icons.rounded.School -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import coil.compose.AsyncImage -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.repository.AiMode -import com.babelsoftware.airnote.domain.model.AiChatSession -import com.babelsoftware.airnote.domain.model.Participant -import com.babelsoftware.airnote.presentation.screens.home.ActionCard -import com.babelsoftware.airnote.presentation.screens.home.AiCentralGraphic -import com.babelsoftware.airnote.presentation.screens.home.viewmodel.DraftedNote -import com.babelsoftware.airnote.presentation.screens.home.viewmodel.HomeViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.random.Random - -@Composable -fun DesktopAiAssistantDialog( - viewModel: HomeViewModel, - onDismissRequest: () -> Unit -) { - Dialog( - onDismissRequest = onDismissRequest, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Card( - shape = RoundedCornerShape(24.dp), - modifier = Modifier - .fillMaxWidth(0.8f) - .fillMaxHeight(0.85f), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) - ) { - AiChatContainer(viewModel = viewModel) - } - } -} - -// ================================================================= -// === Enhanced AirNote AI Interface -// ================================================================= - -@OptIn(ExperimentalAnimationApi::class) -@Composable -private fun AiChatContainer(viewModel: HomeViewModel) { - val backgroundBrush = Brush.verticalGradient( - colors = listOf(Color(0xFF10141C), Color(0xFF0A0D12)) - ) - val showHistoryScreen by viewModel.showAiHistoryScreen - val allSessions by viewModel.allChatSessions.collectAsState() - - Column( - modifier = Modifier - .fillMaxSize() - .background(backgroundBrush) - ) { - AiTopBar( - viewModel = viewModel, - showHistory = showHistoryScreen, - onToggleHistory = { viewModel.toggleAiHistoryScreen(!showHistoryScreen) } - ) - - AnimatedContent(targetState = showHistoryScreen, label = "AiScreenAnimation") { showHistory -> - if (showHistory) { - AiHistoryScreen( - sessions = allSessions, - onNewChatClicked = { viewModel.startNewChat() }, - onSessionClicked = { viewModel.loadChatSession(it) }, - onDeleteSession = { viewModel.deleteChatSession(it.id) } - ) - } else { - AiMainContent(viewModel = viewModel) - } - } - } -} - -@Composable -private fun AiMainContent(viewModel: HomeViewModel) { - val chatState by viewModel.chatState.collectAsState() - var text by remember { mutableStateOf("") } - val isChatActive = chatState.hasStartedConversation || chatState.messages.isNotEmpty() - - if (viewModel.showAskQuestionDialog.value) { - AskAiQuestionDialog( - onDismiss = { viewModel.onDismissQuestionDialog() }, - onConfirm = { question -> - viewModel.sendMessage(question) - viewModel.onDismissQuestionDialog() - } - ) - } - - if (viewModel.showCreateDraftDialog.value) { - CreateDraftDialog( - onDismiss = { viewModel.onDismissCreateDraftDialog() }, - onConfirm = { topic -> - viewModel.generateDraft(topic) - viewModel.onDismissCreateDraftDialog() - } - ) - } - - val onSendMessage = { message: String -> - if (message.isNotBlank()) { - if (chatState.isAwaitingDraftTopic) { - viewModel.generateDraft(message) - } else { - viewModel.sendMessage(message) - } - text = "" - } - } - - Column(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.weight(1f)) { - val (isLoading, isAnalyzingImage) = remember(chatState) { - val loadingMessage = chatState.messages.any { it.isLoading } - val imageUri = chatState.analyzingImageUri - (loadingMessage to (imageUri != null)) - } - - when { - chatState.latestDraft != null && chatState.latestDraft?.sourceImageUri != null -> { - DraftDisplayWithImage( - draft = chatState.latestDraft!!, - onSave = { viewModel.saveDraftedNote() }, - onRegenerate = { viewModel.regenerateDraft() } - ) - } - - isLoading && isAnalyzingImage -> { - ImageAnalysisScreen(imageUri = chatState.analyzingImageUri!!) - } - - chatState.latestDraft != null -> { - DraftDisplay( - draft = chatState.latestDraft, - onSave = { viewModel.saveDraftedNote() }, - onRegenerate = { viewModel.regenerateDraft() } - ) - } - isChatActive -> { - ChatScreenContent( - messages = chatState.messages, - topicForLoading = if ( - chatState.messages.lastOrNull()?.isLoading == true && - chatState.messages.getOrNull(chatState.messages.size - 2)?.participant == Participant.USER - ) { - chatState.messages.getOrNull(chatState.messages.size - 2)?.text - } else { - null - } - ) - } - else -> { - NewAiHomeScreen(viewModel = viewModel) - } - } - } - - val showInputBar = chatState.latestDraft == null && chatState.analyzingImageUri == null - val isLoading = chatState.messages.any { it.isLoading } - - if (showInputBar) { - if (isChatActive) { - RedesignedChatInputBar( - text = text, - onValueChange = { text = it }, - onSendMessage = { onSendMessage(text) }, - onImagePickerClicked = { viewModel.requestImageForAnalysis() }, - enabled = !isLoading, - placeholderText = if (chatState.isAwaitingDraftTopic) stringResource(R.string.draft_topic_placeholder) else stringResource(R.string.ask_airnote_ai) - ) - } else { - PreChatInputBar( - text = text, - onValueChange = { text = it }, - onSendMessage = onSendMessage, - onImagePickerClicked = { viewModel.requestImageForAnalysis() }, - enabled = !isLoading - ) - } - AiDisclaimerText() - } - } -} - -@Composable -private fun ImageAnalysisScreen(imageUri: Uri) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - AsyncImage( - model = imageUri, - contentDescription = stringResource(R.string.image_being_analyzed), - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Fit - ) - - MagicAnalysisAnimation() - } -} - -@Composable -private fun MagicAnalysisAnimation() { - val infiniteTransition = rememberInfiniteTransition(label = "analysis_animation") - val scanLinePosition by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(2000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "scan_line" - ) - - val particles = remember { - List(50) { - Pair( - Random.nextFloat(), // x - Random.nextFloat() // y - ) - } - } - val particleAlpha by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "particles" - ) - - val primaryColor = Color(0xFF33A2FF) - - Canvas(modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(16.dp))) { - val canvasWidth = size.width - val canvasHeight = size.height - val y = scanLinePosition * canvasHeight - - drawLine( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, primaryColor.copy(alpha = 0.5f), Color.Transparent), - startY = y - 20f, - endY = y + 20f - ), - start = Offset(x = 0f, y = y), - end = Offset(x = canvasWidth, y = y), - strokeWidth = 4f, - cap = StrokeCap.Round - ) - - particles.forEach { (x, y) -> - drawCircle( - color = primaryColor, - radius = (Random.nextFloat() * 4f + 2f), - center = Offset(x * canvasWidth, y * canvasHeight), - alpha = particleAlpha * Random.nextFloat() - ) - } - - val bracketSize = 30f - val bracketStroke = 6f - - // Top left - drawLine(primaryColor, Offset(0f, bracketSize), Offset(0f, 0f), bracketStroke) - drawLine(primaryColor, Offset(0f, 0f), Offset(bracketSize, 0f), bracketStroke) - // Top right - drawLine(primaryColor, Offset(canvasWidth - bracketSize, 0f), Offset(canvasWidth, 0f), bracketStroke) - drawLine(primaryColor, Offset(canvasWidth, 0f), Offset(canvasWidth, bracketSize), bracketStroke) - // Lower Left - drawLine(primaryColor, Offset(0f, canvasHeight - bracketSize), Offset(0f, canvasHeight), bracketStroke) - drawLine(primaryColor, Offset(0f, canvasHeight), Offset(bracketSize, canvasHeight), bracketStroke) - // Lower Right - drawLine(primaryColor, Offset(canvasWidth - bracketSize, canvasHeight), Offset(canvasWidth, canvasHeight), bracketStroke) - drawLine(primaryColor, Offset(canvasWidth, canvasHeight), Offset(canvasWidth, canvasHeight - bracketSize), bracketStroke) - } -} - -@Composable -private fun DraftDisplayWithImage(draft: DraftedNote, onSave: () -> Unit, onRegenerate: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - AsyncImage( - model = draft.sourceImageUri, - contentDescription = draft.title, - modifier = Modifier - .fillMaxWidth() - .height(150.dp) - .clip(RoundedCornerShape(16.dp)), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.height(16.dp)) - Surface( - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - color = Color.White.copy(alpha = 0.05f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) - ) { - LazyColumn( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp) - ) { - item { - Text( - text = draft.title, - style = MaterialTheme.typography.titleLarge, - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 12.dp) - ) - } - item { - Text( - text = draft.content, - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.8f), - lineHeight = 24.sp - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onRegenerate, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, Color(0xFF33A2FF)) - ) { - Icon(Icons.Rounded.AutoAwesome, contentDescription = null, tint = Color(0xFF33A2FF)) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.regenerate_note), color = Color(0xFF33A2FF)) - } - Button( - onClick = onSave, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF33A2FF)) - ) { - Text(stringResource(R.string.save_note), color = Color(0xFF10141C)) - } - } - } -} - -@Composable -fun NewAiHomeScreen(viewModel: HomeViewModel) { - val chatState by viewModel.chatState.collectAsState() - val suggestions by viewModel.suggestions.collectAsState() - - LazyColumn( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - contentPadding = PaddingValues(bottom = 16.dp) - ) { - item { - Spacer(modifier = Modifier.height(64.dp)) - AiCentralGraphic(isThinking = chatState.messages.any { it.isLoading }) - Spacer(modifier = Modifier.height(24.dp)) - } - - val rows = (suggestions.size + 1) / 2 - items(rows) { rowIndex -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - val itemIndex1 = rowIndex * 2 - Box(modifier = Modifier.weight(1f)) { - ActionCard( - text = suggestions[itemIndex1].title, - icon = suggestions[itemIndex1].icon, - onClick = suggestions[itemIndex1].action - ) - } - - val itemIndex2 = itemIndex1 + 1 - Box(modifier = Modifier.weight(1f)) { - if (itemIndex2 < suggestions.size) { - ActionCard( - text = suggestions[itemIndex2].title, - icon = suggestions[itemIndex2].icon, - onClick = suggestions[itemIndex2].action - ) - } - } - } - } - } -} - - -@Composable -private fun ChatScreenContent( - messages: List, - topicForLoading: String? -) { - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() - - LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { - scope.launch { - listState.animateScrollToItem(messages.size - 1) - } - } - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 8.dp), - state = listState, - contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp) - ) { - items(items = messages, key = { it.hashCode() }) { message -> - ChatMessageItem(message = message, topicForLoading = topicForLoading) - } - } -} - -@Composable -private fun AiHistoryScreen( - sessions: List, - onNewChatClicked: () -> Unit, - onSessionClicked: (AiChatSession) -> Unit, - onDeleteSession: (AiChatSession) -> Unit -) { - Column(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - OutlinedButton( - onClick = onNewChatClicked, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)), - colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) - ) { - Icon(Icons.Rounded.AddComment, contentDescription = stringResource(R.string.new_chat)) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.new_chat)) - } - Spacer(modifier = Modifier.height(24.dp)) - Text( - stringResource(R.string.history), - style = MaterialTheme.typography.titleMedium, - color = Color.White.copy(alpha = 0.8f) - ) - } - if (sessions.isEmpty()) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = 80.dp), - contentAlignment = Alignment.Center - ) { - Text( - stringResource(R.string.no_chat_history), - color = Color.White.copy(alpha = 0.6f), - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 32.dp) - ) - } - } else { - LazyColumn( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(items = sessions, key = { it.id }) { session -> - HistoryItem( - session = session, - onClick = { onSessionClicked(session) }, - onDelete = { onDeleteSession(session) } - ) - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalFoundationApi::class) -@Composable -private fun HistoryItem(session: AiChatSession, onClick: () -> Unit, onDelete: () -> Unit) { - var showDeleteConfirm by remember { mutableStateOf(false) } - - if (showDeleteConfirm) { - AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, - title = { Text(stringResource(R.string.delete_chat)) }, - text = { Text(stringResource(R.string.delete_chat_description)) }, - confirmButton = { - TextButton( - onClick = { - onDelete() - showDeleteConfirm = false - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text(stringResource(R.string.delete)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteConfirm = false }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } - - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .combinedClickable( - onClick = onClick, - onLongClick = { showDeleteConfirm = true } - ), - color = Color.White.copy(alpha = 0.05f), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - if (session.aiMode == AiMode.CREATIVE_MIND.name) Icons.Rounded.Psychology else Icons.Rounded.Notes, - contentDescription = null, - tint = Color.White.copy(alpha = 0.6f), - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = session.title, - color = Color.White.copy(alpha = 0.9f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - } -} - -@Composable -private fun AiTopBar( - viewModel: HomeViewModel, - showHistory: Boolean, - onToggleHistory: () -> Unit -) { - val currentAiMode by viewModel.aiMode.collectAsState() - var showModelMenu by remember { mutableStateOf(false) } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Box { - Row( - modifier = Modifier - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.1f)) - .clickable { showModelMenu = true } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = when(currentAiMode) { - AiMode.NOTE_ASSISTANT -> Icons.Rounded.Notes - AiMode.CREATIVE_MIND -> Icons.Rounded.Psychology - AiMode.ACADEMIC_RESEARCHER -> Icons.Rounded.School - AiMode.PROFESSIONAL_STRATEGIST -> Icons.Rounded.GolfCourse - }, - contentDescription = "AI Model", - tint = Color(0xFF33A2FF), - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = when (currentAiMode) { - AiMode.NOTE_ASSISTANT -> stringResource(R.string.ai_mode_note_assistant) - AiMode.CREATIVE_MIND -> stringResource(R.string.ai_mode_creative_mind) - AiMode.ACADEMIC_RESEARCHER -> stringResource(R.string.ai_mode_academic_research) - AiMode.PROFESSIONAL_STRATEGIST -> stringResource(R.string.ai_mode_professional_strategy) - }, - color = Color.White, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp - ) - Icon(Icons.Rounded.ExpandMore, contentDescription = "Change Model", tint = Color.White.copy(alpha = 0.7f)) - } - - DropdownMenu( - expanded = showModelMenu, - onDismissRequest = { showModelMenu = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.ai_mode_note_assistant)) }, - onClick = { - viewModel.setAiMode(AiMode.NOTE_ASSISTANT) - showModelMenu = false - }, - leadingIcon = { - Icon( - Icons.Rounded.Notes, - contentDescription = stringResource(R.string.ai_mode_note_assistant) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.ai_mode_creative_mind)) }, - onClick = { - viewModel.setAiMode(AiMode.CREATIVE_MIND) - showModelMenu = false - }, - leadingIcon = { - Icon( - Icons.Rounded.Psychology, - contentDescription = stringResource(R.string.ai_mode_creative_mind) - ) - } - ) - } - } - - IconButton(onClick = onToggleHistory) { - Icon( - if (showHistory) Icons.AutoMirrored.Filled.Chat else Icons.Rounded.History, - contentDescription = "Toggle History", - tint = Color.White.copy(alpha = 0.8f) - ) - } - } -} - -@Composable -private fun AiCentralGraphic(isThinking: Boolean) { - val infiniteTransition = rememberInfiniteTransition(label = "orb_animation") - val rotation by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable(tween(if (isThinking) 2000 else 10000, easing = LinearEasing), RepeatMode.Restart), - label = "orb_rotation" - ) - val scale by animateFloatAsState( - targetValue = if (isThinking) 1.1f else 1.0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "orb_scale" - ) - - Box( - modifier = Modifier.size(120.dp), - contentAlignment = Alignment.Center - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - drawCircle( - brush = Brush.radialGradient( - colors = listOf(Color(0xFF33A2FF).copy(alpha = 0.1f), Color.Transparent), - ), - radius = (size.minDimension / 2.0f) * scale - ) - } - Box( - modifier = Modifier - .size(120.dp) - .graphicsLayer { - rotationZ = rotation - } - .border( - BorderStroke( - 2.dp, - Brush.sweepGradient( - 0.0f to Color.Transparent, - 0.7f to Color(0xFF33A2FF), - 1.0f to Color.Transparent - ) - ), - CircleShape - ), - ) - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = "AI Core", - tint = Color(0xFF33A2FF), - modifier = Modifier.size(50.dp) - ) - } -} - -@Composable -private fun ActionCard(text: String, icon: ImageVector, onClick: () -> Unit) { - Surface( - onClick = onClick, - shape = RoundedCornerShape(16.dp), - color = Color.White.copy(alpha = 0.05f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), - modifier = Modifier - .fillMaxWidth() - .height(100.dp) - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.SpaceBetween - ) { - Icon( - imageVector = icon, - contentDescription = text, - tint = Color.White.copy(alpha = 0.8f) - ) - Text( - text = text, - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } -} - - -@Composable -private fun ChatMessageItem( - message: com.babelsoftware.airnote.domain.model.ChatMessage, - topicForLoading: String? -) { - val isUser = message.participant == Participant.USER - Row( - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 8.dp) - .fillMaxWidth(), - horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start - ) { - if (message.isLoading) { - if (message.text.startsWith("// Generating Note...")) { - TerminalLoadingIndicator(topic = topicForLoading ?: "") - } else if (message.text.isNotBlank()) { - TypingIndicator() - } - } else { - val bubbleShape = RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - bottomStart = if (isUser) 16.dp else 0.dp, - bottomEnd = if (isUser) 0.dp else 16.dp - ) - - val backgroundBrush = when (message.participant) { - Participant.USER -> Brush.verticalGradient( - colors = listOf( - Color(0xFF33A2FF).copy(alpha = 0.4f), - Color(0xFF33A2FF).copy(alpha = 0.1f) - ) - ) - Participant.MODEL -> Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.15f), - Color.White.copy(alpha = 0.05f) - ) - ) - Participant.ERROR -> Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.error.copy(alpha = 0.4f), - MaterialTheme.colorScheme.error.copy(alpha = 0.2f) - ) - ) - } - - Box( - modifier = Modifier - .clip(bubbleShape) - .background(brush = backgroundBrush) - .border( - width = 1.dp, - brush = Brush.verticalGradient( - colors = listOf( - Color.White.copy(alpha = 0.3f), - Color.Transparent - ) - ), - shape = bubbleShape - ) - .padding(12.dp) - ) { - Text( - text = message.text.replace(Regex("[*#]"), "").trim(), - style = MaterialTheme.typography.bodyLarge, - color = if (message.participant == Participant.ERROR) Color.White else Color.White.copy(alpha = 0.9f) - ) - } - } - } -} - -@Composable -private fun RedesignedChatInputBar( - text: String, - onValueChange: (String) -> Unit, - onSendMessage: () -> Unit, - onImagePickerClicked: () -> Unit, - enabled: Boolean, - modifier: Modifier = Modifier, - placeholderText: String -) { - val haptic = LocalHapticFeedback.current - - Surface( - modifier = modifier - .fillMaxWidth() - .padding(16.dp), - shape = RoundedCornerShape(24.dp), - color = Color.White.copy(alpha = 0.05f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onImagePickerClicked, enabled = enabled) { - Icon( - Icons.Default.AttachFile, - contentDescription = "Attach File", - tint = Color.White.copy(alpha = 0.7f) - ) - } - TextField( - value = text, - onValueChange = onValueChange, - modifier = Modifier.weight(1f), - enabled = enabled, - placeholder = { - Text(placeholderText, color = Color.White.copy(alpha = 0.5f)) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color(0xFF33A2FF), - focusedTextColor = Color.White, - unfocusedTextColor = Color.White.copy(alpha = 0.9f), - disabledPlaceholderColor = Color.White.copy(alpha = 0.3f) - ) - ) - AnimatedVisibility(visible = text.isNotBlank() && enabled) { - IconButton( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onSendMessage() - }, - modifier = Modifier.padding(start = 8.dp), - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color(0xFF33A2FF), - contentColor = Color.White - ) - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = "Send message" - ) - } - } - } - } -} - -@Composable -private fun PreChatInputBar( - text: String, - onValueChange: (String) -> Unit, - onSendMessage: (String) -> Unit, - onImagePickerClicked: () -> Unit, - enabled: Boolean, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - shape = RoundedCornerShape(24.dp), - color = Color.White.copy(alpha = 0.05f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) - ) { - Column( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = text, - onValueChange = onValueChange, - modifier = Modifier.weight(1f), - enabled = enabled, - placeholder = { - Text(stringResource(R.string.ask_airnote_ai), color = Color.White.copy(alpha = 0.6f)) - }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null, tint = Color.White.copy(alpha = 0.6f)) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color(0xFF33A2FF), - focusedTextColor = Color.White, - unfocusedTextColor = Color.White.copy(alpha = 0.9f), - ), - singleLine = true - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onImagePickerClicked, enabled = enabled) { - Icon( - Icons.Default.AttachFile, - contentDescription = "Attach File", - tint = Color.White.copy(alpha = 0.8f) - ) - } - - val searchString = stringResource(R.string.ai_search) - val thinkString = stringResource(R.string.ai_think) - - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) - ) { - InputActionButton( - text = searchString, - icon = Icons.Rounded.AutoAwesome, - onClick = { onValueChange("$searchString: ") } - ) - InputActionButton( - text = thinkString, - icon = Icons.Default.Language, - onClick = { onValueChange("$thinkString: ") } - ) - } - - IconButton( - onClick = { if (text.isNotBlank()) onSendMessage(text) }, - enabled = text.isNotBlank() && enabled, - modifier = Modifier - .size(40.dp) - .clip(CircleShape), - colors = IconButtonDefaults.iconButtonColors( - containerColor = Color(0xFF33A2FF), - contentColor = Color.White, - disabledContainerColor = Color.White.copy(alpha = 0.1f), - disabledContentColor = Color.White.copy(alpha = 0.4f) - ) - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = "Send" - ) - } - } - } - } -} - -@Composable -private fun InputActionButton(text: String, icon: ImageVector, onClick: () -> Unit) { - TextButton( - onClick = onClick, - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.textButtonColors( - containerColor = Color.White.copy(alpha = 0.1f), - contentColor = Color.White.copy(alpha = 0.9f) - ), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text(text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - } -} - -@Composable -private fun AiDisclaimerText() { - val uriHandler = LocalUriHandler.current - val disclaimerText = stringResource(R.string.ai_disclaimer_text) - val linkText = stringResource(R.string.ai_disclaimer_link_text) - val linkUrl = stringResource(R.string.ai_disclaimer_link_url) - - val annotatedString = buildAnnotatedString { - append(disclaimerText) - pushStringAnnotation(tag = "URL", annotation = linkUrl) - withStyle( - style = SpanStyle( - color = Color(0xFF33A2FF), // Link color - textDecoration = TextDecoration.Underline - ) - ) { - append(linkText) - } - pop() - } - - ClickableText( - text = annotatedString, - onClick = { offset -> - annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - uriHandler.openUri(annotation.item) - } - }, - style = MaterialTheme.typography.bodySmall.copy( - color = Color.White.copy(alpha = 0.6f), - textAlign = TextAlign.Center - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = 8.dp) - ) -} - -@Composable -private fun AskAiQuestionDialog( - onDismiss: () -> Unit, - onConfirm: (String) -> Unit -) { - var question by remember { mutableStateOf("") } - val maxChars = 280 - - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(24.dp), - color = Color(0xFF10141C), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)) - ) { - Column( - modifier = Modifier.padding(24.dp) - ) { - Text( - text = stringResource(R.string.ask_a_question_title), - style = MaterialTheme.typography.headlineSmall, - color = Color.White, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(16.dp)) - TextField( - value = question, - onValueChange = { if (it.length <= maxChars) question = it }, - placeholder = { Text(stringResource(R.string.ask_a_question), color = Color.White.copy(alpha = 0.6f)) }, - modifier = Modifier - .fillMaxWidth() - .height(100.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White.copy(alpha = 0.05f), - unfocusedContainerColor = Color.White.copy(alpha = 0.05f), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color(0xFF33A2FF), - focusedTextColor = Color.White, - unfocusedTextColor = Color.White.copy(alpha = 0.9f), - ), - shape = RoundedCornerShape(16.dp) - ) - Text( - text = "${question.length} / $maxChars", - textAlign = TextAlign.End, - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel), color = Color.White.copy(alpha = 0.8f)) - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { onConfirm(question) }, - enabled = question.isNotBlank(), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF33A2FF), - contentColor = Color(0xFF10141C), - disabledContainerColor = Color.White.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Text(stringResource(R.string.send)) - } - } - } - } - } -} - -@Composable -private fun CreateDraftDialog( - onDismiss: () -> Unit, - onConfirm: (String) -> Unit -) { - var topic by remember { mutableStateOf("") } - val maxChars = 100 - - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = RoundedCornerShape(24.dp), - color = Color(0xFF10141C), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)) - ) { - Column( - modifier = Modifier.padding(24.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Rounded.Edit, - contentDescription = null, - tint = Color.White.copy(alpha = 0.8f), - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(R.string.new_ai_note_draft), - style = MaterialTheme.typography.headlineSmall, - color = Color.White, - fontWeight = FontWeight.Bold - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.sample_question_request), - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.7f) - ) - Spacer(modifier = Modifier.height(16.dp)) - - TextField( - value = topic, - onValueChange = { if (it.length <= maxChars) topic = it }, - placeholder = { Text(stringResource(R.string.example_question), color = Color.White.copy(alpha = 0.6f)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White.copy(alpha = 0.05f), - unfocusedContainerColor = Color.White.copy(alpha = 0.05f), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = Color(0xFF33A2FF), - focusedTextColor = Color.White, - unfocusedTextColor = Color.White.copy(alpha = 0.9f), - ), - shape = RoundedCornerShape(16.dp) - ) - Text( - text = "${topic.length} / $maxChars", - textAlign = TextAlign.End, - style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f), - modifier = Modifier.fillMaxWidth().padding(top = 8.dp) - ) - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel), color = Color.White.copy(alpha = 0.8f)) - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { onConfirm(topic) }, - enabled = topic.isNotBlank(), - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFF33A2FF), - contentColor = Color(0xFF10141C), - disabledContainerColor = Color.White.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Text(stringResource(R.string.create)) - } - } - } - } - } -} - - -@Composable -private fun DraftDisplay(draft: DraftedNote?, onSave: () -> Unit, onRegenerate: () -> Unit) { - if (draft == null) return - - Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Column( - modifier = Modifier.fillMaxSize() - ) { - Surface( - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(16.dp), - color = Color.White.copy(alpha = 0.05f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) - ) { - LazyColumn( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp) - ) { - item { - Text( - text = draft.title, - style = MaterialTheme.typography.titleLarge, - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 12.dp) - ) - } - item { - Text( - text = draft.content, - style = MaterialTheme.typography.bodyLarge, - color = Color.White.copy(alpha = 0.8f), - lineHeight = 24.sp - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onRegenerate, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, Color(0xFF33A2FF)) - ) { - Icon(Icons.Rounded.AutoAwesome, contentDescription = null, tint = Color(0xFF33A2FF)) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.regenerate_note), color = Color(0xFF33A2FF)) - } - Button( - onClick = onSave, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF33A2FF)) - ) { - Text(stringResource(R.string.save_note), color = Color(0xFF10141C)) - } - } - } - } -} - -@Composable -private fun TypingIndicator() { - val transition = rememberInfiniteTransition(label = "typing_indicator_transition") - val dotSize = 8.dp - val dotSpacing = 12.dp - - @Composable - fun Dot(offsetY: Float) { - Box( - modifier = Modifier - .size(dotSize) - .offset(y = offsetY.dp) - .background( - color = Color.White.copy(alpha = 0.5f), - shape = CircleShape - ) - ) - } - - val yOffset1 by transition.animateFloat( - initialValue = 0f, - targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(400), - repeatMode = RepeatMode.Reverse - ), label = "dot1_offset" - ) - val yOffset2 by transition.animateFloat( - initialValue = 0f, - targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(400, delayMillis = 150), - repeatMode = RepeatMode.Reverse - ), label = "dot2_offset" - ) - val yOffset3 by transition.animateFloat( - initialValue = 0f, - targetValue = -10f, - animationSpec = infiniteRepeatable( - animation = tween(400, delayMillis = 300), - repeatMode = RepeatMode.Reverse - ), label = "dot3_offset" - ) - - Row( - modifier = Modifier.padding(vertical = 16.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(dotSpacing) - ) { - Dot(offsetY = yOffset1) - Dot(offsetY = yOffset2) - Dot(offsetY = yOffset3) - } -} - -@Composable -private fun TerminalLoadingIndicator(topic: String) { - var displayedText by remember { mutableStateOf("") } - val fullText = """ -[INFO] Connecting to AirNote AI services... -[INFO] Authentication successful. -[PROCESS] Analyzing topic: "$topic" -[PROCESS] Generating content blocks... -[PROCESS] Assembling draft... -[SUCCESS] Note draft created. - """.trimIndent() - - LaunchedEffect(Unit) { - fullText.lines().forEach { line -> - for (char in line) { - displayedText += char - delay(10) - } - displayedText += "\n" - delay(280) - } - } - - val infiniteTransition = rememberInfiniteTransition(label = "cursor_blink") - val cursorAlpha by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(500), - repeatMode = RepeatMode.Reverse - ), label = "cursor_alpha" - ) - - Surface( - shape = RoundedCornerShape(16.dp), - color = Color.Black.copy(alpha = 0.5f), - border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)) - ) { - Row(modifier = Modifier.padding(12.dp)) { - Text( - text = displayedText, - color = Color.Green.copy(alpha = 0.8f), - fontFamily = FontFamily.Monospace, - fontSize = 14.sp, - lineHeight = 20.sp - ) - Text( - text = "█", - color = Color.Green.copy(alpha = cursorAlpha), - fontFamily = FontFamily.Monospace, - fontSize = 14.sp - ) - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopHomeScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopHomeScreen.kt deleted file mode 100644 index 0aa2807..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/desktop/DesktopHomeScreen.kt +++ /dev/null @@ -1,823 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home.desktop - -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.DriveFileMove -import androidx.compose.material.icons.automirrored.rounded.LibraryBooks -import androidx.compose.material.icons.automirrored.rounded.MenuOpen -import androidx.compose.material.icons.automirrored.rounded.NoteAdd -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.AllInbox -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Code -import androidx.compose.material.icons.rounded.Compress -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.Folder -import androidx.compose.material.icons.rounded.FormatBold -import androidx.compose.material.icons.rounded.FormatItalic -import androidx.compose.material.icons.rounded.FormatListBulleted -import androidx.compose.material.icons.rounded.FormatQuote -import androidx.compose.material.icons.rounded.FormatUnderlined -import androidx.compose.material.icons.rounded.HorizontalRule -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.Menu -import androidx.compose.material.icons.rounded.NoteAdd -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material.icons.rounded.TheaterComedy -import androidx.compose.material.icons.rounded.Title -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Divider -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.scale -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.repository.AiAction -import com.babelsoftware.airnote.data.repository.AiTone -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.presentation.screens.home.viewmodel.HomeViewModel -import com.babelsoftware.airnote.presentation.screens.home.widgets.AddFolderDialog -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import kotlinx.coroutines.delay - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DesktopHomeScreen( - viewModel: HomeViewModel, - settingsModel: SettingsViewModel, - settings: com.babelsoftware.airnote.domain.model.Settings, - onNoteClicked: (noteId: Int, isVault: Boolean, folderId: Long?) -> Unit, - onSettingsClicked: () -> Unit, -) { - var showWelcomeAnimation by remember { mutableStateOf(true) } - - LaunchedEffect(Unit) { - delay(3200L) // 3,2s - showWelcomeAnimation = false - } - - val notes by viewModel.displayedNotes.collectAsState() - val selectedNote by viewModel.selectedNote.collectAsState() - val folders by viewModel.allFolders.collectAsState() - val selectedFolderId by viewModel.selectedFolderId.collectAsState() - val searchQuery by viewModel.searchQuery.collectAsState() - val noteForAction by viewModel.noteForAction - val isAiChatSheetVisible by viewModel.isAiChatSheetVisible - var isNavRailExpanded by remember { mutableStateOf(true) } - val isAddFolderDialogVisible by viewModel.isAddFolderDialogVisible - - if (isAddFolderDialogVisible) { - AddFolderDialog( - onDismiss = { viewModel.setAddFolderDialogVisibility(false) }, - onConfirm = { name, color -> - viewModel.addFolder(name, color) - } - ) - } - - if (isAiChatSheetVisible) { - DesktopAiAssistantDialog( - viewModel = viewModel, - onDismissRequest = { - viewModel.toggleAiChatSheet(false) - viewModel.resetChatState() - } - ) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainerLowest) - ) { - AnimatedVisibility( - visible = !showWelcomeAnimation, - enter = fadeIn(animationSpec = tween(durationMillis = 500, delayMillis = 200)) - ) { - Row( - modifier = Modifier - .fillMaxSize() - ) { - DesktopNavRail( - isExpanded = isNavRailExpanded, - onToggleExpand = { isNavRailExpanded = !isNavRailExpanded }, - aiEnabled = settings.desktopModeAiEnabled, - folders = folders, - selectedFolderId = selectedFolderId, - onFolderSelected = { folderId -> viewModel.selectFolder(folderId) }, - onAddNewNote = { viewModel.createNewNoteForDesktop() }, - onAddFolder = { viewModel.setAddFolderDialogVisibility(true) }, - onAskAi = { viewModel.toggleAiChatSheet(true) }, - onSettingsClicked = onSettingsClicked - ) - - NoteListPane( - notes = notes, - selectedNote = selectedNote, - searchQuery = searchQuery, - onQueryChange = { query -> viewModel.changeSearchQuery(query) }, - onNoteSelected = { note -> viewModel.selectNote(note) }, - onNewNoteClicked = { viewModel.createNewNoteForDesktop() }, - noteForAction = noteForAction, - onNoteLongPressed = { note -> viewModel.onNoteLongPressed(note) }, - onDismissNoteAction = { viewModel.onDismissNoteAction() }, - onDeleteAction = { viewModel.deleteNoteAction() }, - onMoveAction = { viewModel.requestMoveNoteAction() }, - modifier = Modifier.weight(0.4f) - ) - - AnimatedContent( - targetState = selectedNote, - modifier = Modifier.weight(0.6f), - transitionSpec = { - (fadeIn(animationSpec = tween(300, easing = LinearOutSlowInEasing)) + - slideInVertically(animationSpec = tween(300, easing = LinearOutSlowInEasing), initialOffsetY = { it / 8 })) - .togetherWith(fadeOut(animationSpec = tween(300, easing = LinearOutSlowInEasing))) - }, - label = "NoteDetailAnimation" - ) { targetNote -> - NoteDetailPane( - note = targetNote, - aiEnabled = settings.desktopModeAiEnabled, - onAiAction = { action, tone -> viewModel.executeDesktopAiAction(action, tone) }, - onUpdateNote = { noteToUpdate, newName, newDescription -> - viewModel.updateNoteDetails(noteToUpdate, newName, newDescription) - } - ) - } - } - } - AnimatedVisibility( - visible = showWelcomeAnimation, - exit = fadeOut(animationSpec = tween(durationMillis = 500)) - ) { - AnimatedWelcomeOverlay() - } - } -} - -@Composable -private fun DesktopNavRail( - isExpanded: Boolean, - onToggleExpand: () -> Unit, - aiEnabled: Boolean, - folders: List, - selectedFolderId: Long?, - onFolderSelected: (Long?) -> Unit, - onAddNewNote: () -> Unit, - onAddFolder: () -> Unit, - onAskAi: () -> Unit, - onSettingsClicked: () -> Unit -) { - val navRailBackground = Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.surfaceContainer, - MaterialTheme.colorScheme.surfaceContainerLow - ) - ) - - val animatedWidth by animateDpAsState( - targetValue = if (isExpanded) 240.dp else 80.dp, - label = "NavRailWidthAnimation", - animationSpec = spring(stiffness = Spring.StiffnessMedium) - ) - - Surface( - color = Color.Transparent, - modifier = Modifier - .fillMaxHeight() - .width(animatedWidth) - .background(navRailBackground) - .padding(vertical = 10.dp, horizontal = 12.dp) - ) { - Column(horizontalAlignment = Alignment.Start) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 4.dp, bottom = 16.dp) - ) { - IconButton(onClick = onToggleExpand) { - Icon( - imageVector = if (isExpanded) Icons.AutoMirrored.Rounded.MenuOpen else Icons.Rounded.Menu, - contentDescription = "Toggle Navigation Rail", - tint = MaterialTheme.colorScheme.onSurface - ) - } - AnimatedVisibility(visible = isExpanded) { - Text( - text = "AirNote DeX", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(start = 2.dp) - ) - } - } - - NavItem(isExpanded = isExpanded, text = stringResource(R.string.new_note), icon = Icons.Rounded.Edit, onClick = onAddNewNote) - if (aiEnabled) { - NavItem(isExpanded = isExpanded, text = stringResource(R.string.ai_button_texts), icon = Icons.Rounded.AutoAwesome, onClick = onAskAi) - } - - Divider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - AnimatedVisibility(visible = isExpanded) { - Text( - text = stringResource(R.string.folders), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - AnimatedVisibility(visible = isExpanded) { - IconButton(onClick = onAddFolder, modifier = Modifier.size(24.dp)) { - Icon(Icons.Rounded.Add, contentDescription = "Add New Folder", tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(top = 8.dp) - ) { - item { - NavItem(isExpanded = isExpanded, text = stringResource(R.string.all_notes), icon = Icons.Rounded.AllInbox, isSelected = selectedFolderId == null, onClick = { onFolderSelected(null) }) - } - items(folders) { folder -> - NavItem(isExpanded = isExpanded, text = folder.name, icon = Icons.Rounded.Folder, isSelected = selectedFolderId == folder.id, onClick = { onFolderSelected(folder.id) }) - } - } - - NavItem(isExpanded = isExpanded, text = stringResource(R.string.screen_settings), icon = Icons.Rounded.Settings, onClick = onSettingsClicked) - } - } -} - -@Composable -private fun NavItem( - isExpanded: Boolean, - text: String, - icon: ImageVector, - isSelected: Boolean = false, - onClick: () -> Unit -) { - val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent - val contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant - - Surface( - onClick = onClick, - color = backgroundColor, - shape = RoundedCornerShape(16.dp), - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp) - ) { - Icon(imageVector = icon, contentDescription = text, tint = contentColor) - AnimatedVisibility(visible = isExpanded) { - Row { - Spacer(modifier = Modifier.width(16.dp)) - Text(text = text, color = contentColor, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun NoteListPane( - notes: List, - selectedNote: Note?, - searchQuery: String, - onQueryChange: (String) -> Unit, - onNoteSelected: (Note) -> Unit, - onNewNoteClicked: () -> Unit, - noteForAction: Note?, - onNoteLongPressed: (Note) -> Unit, - onDismissNoteAction: () -> Unit, - onDeleteAction: () -> Unit, - onMoveAction: () -> Unit, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier - .fillMaxHeight() - .padding(top = 16.dp, bottom = 16.dp, start = 16.dp) - .shadow(elevation = 8.dp, shape = RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp)), - shape = RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - ) { - Column { - OutlinedTextField( - value = searchQuery, - onValueChange = onQueryChange, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - placeholder = { Text(stringResource(R.string.search_notes)) }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = "Search") }, - shape = RoundedCornerShape(16.dp), - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - ) - ) - - if (notes.isEmpty() && searchQuery.isEmpty()) { - EmptyNotesView(onNewNoteClicked = onNewNoteClicked) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp) - ) { - items(notes, key = { it.id }) { note -> - val isSelected = selectedNote?.id == note.id - Box { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .shadow(elevation = if (isSelected) 4.dp else 0.dp, shape = RoundedCornerShape(16.dp)) - .combinedClickable( - onClick = { onNoteSelected(note) }, - onLongClick = { onNoteLongPressed(note) } - ), - shape = RoundedCornerShape(16.dp), - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, - colors = if (isSelected) { - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) - } else { - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - } - ) { - Column(Modifier.padding(16.dp)) { - Text(text = note.name, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = note.description.take(120), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3 - ) - } - } - DropdownMenu( - expanded = noteForAction?.id == note.id, - onDismissRequest = { onDismissNoteAction() } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.move_to_folder)) }, - onClick = { onMoveAction() }, - leadingIcon = { Icon(Icons.AutoMirrored.Filled.DriveFileMove, contentDescription = "Move to Folder") } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }, - onClick = { onDeleteAction() }, - leadingIcon = { Icon(Icons.Default.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error) } - ) - } - } - } - } - } - } - } -} - -@Composable -private fun NoteDetailPane( - note: Note?, - modifier: Modifier = Modifier, - aiEnabled: Boolean, - onAiAction: (AiAction, AiTone?) -> Unit, - onUpdateNote: (noteToUpdate: Note, newName: String, newDescription: String) -> Unit -) { - var title by remember { mutableStateOf("") } - var description by remember { mutableStateOf(TextFieldValue("")) } - var isDescriptionFocused by remember { mutableStateOf(false) } - var showToneMenu by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = note) { - if (note != null) { - title = note.name - description = TextFieldValue(note.description) - } else { - title = "" - description = TextFieldValue("") - } - } - LaunchedEffect(key1 = title, key2 = description) { - if (note != null && (title != note.name || description.text != note.description)) { - delay(750L) - onUpdateNote(note, title, description.text) - } - } - - Box( - modifier = modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), - contentAlignment = Alignment.TopCenter - ) { - Card( - modifier = Modifier - .widthIn(max = 800.dp) - .fillMaxHeight() - .shadow(elevation = 8.dp, shape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp)), - shape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - if (note != null) { - Column(modifier = Modifier.fillMaxSize()) { - TextField( - value = title, - onValueChange = { title = it }, - modifier = Modifier.fillMaxWidth(), - textStyle = MaterialTheme.typography.headlineMedium.copy( - color = MaterialTheme.colorScheme.onSurface - ), - placeholder = { Text(stringResource(R.string.title_dex)) }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - Divider(modifier = Modifier.padding(horizontal = 16.dp)) - TextField( - value = description, - onValueChange = { description = it }, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .onFocusChanged { focusState -> - isDescriptionFocused = focusState.isFocused - }, - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface - ), - placeholder = { Text(stringResource(R.string.write_your_note_here)) }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - - AnimatedVisibility(visible = isDescriptionFocused) { - DesktopTextFormattingToolbar( - currentValue = description, - onValueChange = { newTextFieldValue -> - description = newTextFieldValue - } - ) - } - - if (aiEnabled) { - Surface( - tonalElevation = 3.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically - ) { - AiActionButton(icon = Icons.Rounded.AutoAwesome, text = stringResource(R.string.ai_action_improve_writing)) { - onAiAction(AiAction.IMPROVE_WRITING, null) - } - AiActionButton(icon = Icons.Rounded.Compress, text = stringResource(R.string.ai_action_summarize)) { - onAiAction(AiAction.SUMMARIZE, null) - } - AiActionButton(icon = Icons.Rounded.KeyboardArrowDown, text = stringResource(R.string.ai_action_make_shorter)) { - onAiAction(AiAction.MAKE_SHORTER, null) - } - AiActionButton(icon = Icons.Rounded.KeyboardArrowUp, text = stringResource(R.string.ai_action_make_longer)) { - onAiAction(AiAction.MAKE_LONGER, null) - } - Box { - AiActionButton(icon = Icons.Rounded.TheaterComedy, text = stringResource(R.string.ai_action_change_tone)) { - showToneMenu = true - } - DropdownMenu( - expanded = showToneMenu, - onDismissRequest = { showToneMenu = false } - ) { - AiTone.values().forEach { tone -> - DropdownMenuItem( - text = { Text(tone.name.replaceFirstChar { it.titlecase() }) }, - onClick = { - onAiAction(AiAction.CHANGE_TONE, tone) - showToneMenu = false - } - ) - } - } - } - } - } - } - } - } else { - EmptySelectionView() - } - } - } -} - -@Composable -private fun EmptyNotesView(onNewNoteClicked: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.NoteAdd, - contentDescription = "Empty Note Box", - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.no_created_notes), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(32.dp)) - Button(onClick = onNewNoteClicked) { - Icon(Icons.Rounded.Add, contentDescription = "New Note") - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.dex_create_new_note)) - } - } -} - -@Composable -private fun EmptySelectionView() { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.LibraryBooks, - contentDescription = "Note Not Selected", - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.dex_start), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.dex_note_preview), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } -} - -@Composable -private fun AiActionButton(icon: ImageVector, text: String, onClick: () -> Unit) { - TextButton(onClick = onClick) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(imageVector = icon, contentDescription = text) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = text, style = MaterialTheme.typography.labelSmall) - } - } -} - -@Composable -private fun AnimatedWelcomeOverlay() { - var iconScale by remember { mutableStateOf(0f) } - var textAlpha by remember { mutableStateOf(0f) } - var textOffsetY by remember { mutableStateOf(30f) } - - val animatedIconScale by animateFloatAsState( - targetValue = iconScale, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = 100f - ), label = "icon_scale_anim" - ) - - val animatedTextAlpha by animateFloatAsState( - targetValue = textAlpha, - animationSpec = tween(durationMillis = 600, delayMillis = 200), - label = "text_alpha_anim" - ) - - val animatedTextOffsetY by animateFloatAsState( - targetValue = textOffsetY, - animationSpec = tween(durationMillis = 600, delayMillis = 200), - label = "text_offset_anim" - ) - - LaunchedEffect(Unit) { - iconScale = 1f - textAlpha = 1f - textOffsetY = 0f - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(id = R.drawable.ic_launcher_foreground), - contentDescription = "AirNote Logo", - modifier = Modifier - .size(128.dp) - .scale(animatedIconScale), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(16.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .alpha(animatedTextAlpha) - .offset(y = animatedTextOffsetY.dp) - ) { - Text( - text = stringResource(R.string.welcome_to_airnote_dex), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.airnote_dex_description), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@Composable -private fun DesktopTextFormattingToolbar( - currentValue: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit -) { - Surface( - tonalElevation = 2.dp, - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("**", "**")) }) { Icon(Icons.Rounded.FormatBold, "Thick") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("*", "*")) }) { Icon(Icons.Rounded.FormatItalic, "Italic") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("", "")) }) { Icon(Icons.Rounded.FormatUnderlined, "Underlined") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("# ", isLinePrefix = true)) }) { Icon(Icons.Rounded.Title, "Title") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("\n- ", "")) }) { Icon(Icons.Rounded.FormatListBulleted, "List") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("> ", isLinePrefix = true)) }) { Icon(Icons.Rounded.FormatQuote, "Quote") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("`", "`")) }) { Icon(Icons.Rounded.Code, "Code") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("\n---\n", "")) }) { Icon(Icons.Rounded.HorizontalRule, "Bracket") } } - item { IconButton(onClick = { onValueChange(currentValue.applyMarkdown("[", "]()")) }) { Icon(Icons.Rounded.Link, "Link") } } - } - } -} - -private fun TextFieldValue.applyMarkdown( - prefix: String, - suffix: String = "", - isLinePrefix: Boolean = false -): TextFieldValue { - val selection = this.selection - val text = this.text - - if (isLinePrefix) { - var lineStart = selection.start - while (lineStart > 0 && text.getOrNull(lineStart - 1) != '\n') { - lineStart-- - } - val newText = text.substring(0, lineStart) + prefix + text.substring(lineStart) - return this.copy( - text = newText, - selection = TextRange(selection.end + prefix.length) - ) - } else if (selection.collapsed) { - val newText = text.substring(0, selection.start) + prefix + suffix + text.substring(selection.end) - return this.copy( - text = newText, - selection = TextRange(selection.start + prefix.length) - ) - } else { - val selectedText = text.substring(selection.min, selection.max) - val newText = text.replaceRange( - selection.min, - selection.max, - prefix + selectedText + suffix - ) - return this.copy( - text = newText, - selection = TextRange(selection.end + prefix.length + suffix.length) - ) - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/viewmodel/HomeModel.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/viewmodel/HomeModel.kt deleted file mode 100644 index 6f68b0d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/viewmodel/HomeModel.kt +++ /dev/null @@ -1,1193 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home.viewmodel - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import android.util.Log -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.ImageSearch -import androidx.compose.material.icons.rounded.School -import androidx.compose.material.icons.rounded.Search -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.provider.StringProvider -import com.babelsoftware.airnote.data.repository.AiAction -import com.babelsoftware.airnote.data.repository.AiActionPlan -import com.babelsoftware.airnote.data.repository.AiMode -import com.babelsoftware.airnote.data.repository.AiTone -import com.babelsoftware.airnote.data.repository.GeminiRepository -import com.babelsoftware.airnote.data.repository.SecureStorageRepository -import com.babelsoftware.airnote.domain.model.AiChatMessage -import com.babelsoftware.airnote.domain.model.AiChatSession -import com.babelsoftware.airnote.domain.model.AiSuggestion -import com.babelsoftware.airnote.domain.model.ChatMessage -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.domain.model.Participant -import com.babelsoftware.airnote.domain.repository.SettingsRepository -import com.babelsoftware.airnote.domain.usecase.AiChatUseCase -import com.babelsoftware.airnote.domain.usecase.FolderUseCase -import com.babelsoftware.airnote.domain.usecase.NoteUseCase -import com.babelsoftware.airnote.presentation.components.DecryptionResult -import com.babelsoftware.airnote.presentation.components.EncryptionHelper -import com.google.ai.client.generativeai.type.ImagePart -import com.google.ai.client.generativeai.type.Part -import com.google.ai.client.generativeai.type.TextPart -import com.google.gson.Gson -import com.google.gson.JsonSyntaxException -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import javax.inject.Inject - -enum class AiService { - GEMINI, - PERPLEXITY -} - -data class DraftedNote( - val topic: String, - val title: String, - val content: String, - val sourceImageUri: Uri? = null -) - -data class ChatState( - val messages: List = emptyList(), - val isAwaitingDraftTopic: Boolean = false, - val latestDraft: DraftedNote? = null, - val hasStartedConversation: Boolean = false, - val currentSessionId: Long? = null, - val analyzingImageUri: Uri? = null, - val pendingAttachmentUri: Uri? = null, - val pendingAttachmentMimeType: String? = null -) - -@HiltViewModel -class HomeViewModel @Inject constructor( - private val stringProvider: StringProvider, - val encryptionHelper: EncryptionHelper, - private val noteUseCase: NoteUseCase, - private val folderUseCase: FolderUseCase, - private val aiChatUseCase: AiChatUseCase, - @ApplicationContext private val context: Context, - private val geminiRepository: GeminiRepository, - private val settingsRepository: SettingsRepository, - private val secureStorageRepository: SecureStorageRepository -) : ViewModel() { - sealed class UiAction { - object RequestImageForAnalysis : UiAction() - object RequestFileForAnalysis : UiAction() - object RequestAttachmentType: UiAction() - } - var selectedNotes = mutableStateListOf() - - private var _isDeleteMode = mutableStateOf(false) - val isDeleteMode: State = _isDeleteMode - - private var _isPasswordPromptVisible = mutableStateOf(false) - val isPasswordPromptVisible: State = _isPasswordPromptVisible - - private val _isVaultMode = MutableStateFlow(false) - val isVaultMode: StateFlow = _isVaultMode.asStateFlow() - - private val _isAddFolderDialogVisible = mutableStateOf(false) - val isAddFolderDialogVisible: State = _isAddFolderDialogVisible - - private val _isMoveToFolderDialogVisible = mutableStateOf(false) - val isMoveToFolderDialogVisible: State = _isMoveToFolderDialogVisible - - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() - - private val _selectedFolderId = MutableStateFlow(null) - val selectedFolderId: StateFlow = _selectedFolderId.asStateFlow() - - private val _folderForAction = mutableStateOf(null) - val folderForAction: State = _folderForAction - - private val _showDeleteConfirmDialog = mutableStateOf(false) - val showDeleteConfirmDialog: State = _showDeleteConfirmDialog - - private val _folderToEdit = mutableStateOf(null) - val folderToEdit: State = _folderToEdit - - private val _noteForAction = mutableStateOf(null) - val noteForAction: State = _noteForAction - - private val _uiEvent = Channel() - val uiEvent = _uiEvent.receiveAsFlow() - - private suspend fun getApiKeyToUse(): String { - return secureStorageRepository.getUserApiKey() ?: "" - } - - private val _selectedNote = MutableStateFlow(null) - val selectedNote: StateFlow = _selectedNote.asStateFlow() - - fun selectNote(note: Note?) { - _selectedNote.value = note - } - - // --- Global AI Chat States --- - private val _isAiChatSheetVisible = mutableStateOf(false) - val isAiChatSheetVisible: State = _isAiChatSheetVisible - - private val _isFabExtended = mutableStateOf(true) - val isFabExtended: State = _isFabExtended - - private val _chatState = MutableStateFlow(ChatState()) - val chatState: StateFlow = _chatState.asStateFlow() - - private val _uiActionChannel = Channel() - val uiActionChannel = _uiActionChannel.receiveAsFlow() - - private val _aiMode = MutableStateFlow(AiMode.NOTE_ASSISTANT) - val aiMode: StateFlow = _aiMode.asStateFlow() - - private val _selectedAiService = MutableStateFlow(AiService.GEMINI) - val selectedAiService: StateFlow = _selectedAiService.asStateFlow() - - val allChatSessions: StateFlow> = aiChatUseCase.getAllSessions() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - private val _showAiHistoryScreen = mutableStateOf(false) - val showAiHistoryScreen: State = _showAiHistoryScreen - - private val _showAskQuestionDialog = mutableStateOf(false) - val showAskQuestionDialog: State = _showAskQuestionDialog - - private val _showCreateDraftDialog = mutableStateOf(false) - val showCreateDraftDialog: State = _showCreateDraftDialog - - val allNotesForAi: StateFlow> = noteUseCase.getAllNotes() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - /** - * Finds the note mentioned in the message text. - * Searches for an expression starting with “@” that matches the name of a note in the note list. - * Sorts the notes in reverse order to check the longest-named notes first. - */ - private fun findMentionedNote(message: String, allNotes: List): Note? { - val sortedNotes = allNotes.sortedByDescending { it.name.length } - - for (note in sortedNotes) { - val noteNamePattern = Regex.escape(note.name) - val noteMentionRegex = Regex( - pattern = "@$noteNamePattern(\\b|$)", - option = RegexOption.IGNORE_CASE - ) - - if (noteMentionRegex.containsMatchIn(message)) { - return note - } - } - - return null - } - - fun onAskQuestionClicked() { - _showAskQuestionDialog.value = true - } - - fun onDismissQuestionDialog() { - _showAskQuestionDialog.value = false - } - - fun onDraftAnythingClicked() { - _showCreateDraftDialog.value = true - } - - fun onDismissCreateDraftDialog() { - _showCreateDraftDialog.value = false - } - - fun selectAiService(service: AiService) { - _selectedAiService.value = service - resetChatState() - } - - val suggestions: StateFlow> = - _selectedAiService.map { service -> - if (service == AiService.PERPLEXITY) { - listOf( - AiSuggestion( - title = stringProvider.getString(R.string.ask_ai), - icon = Icons.Rounded.Search, - action = { onAskQuestionClicked() } - ), - AiSuggestion( - title = stringProvider.getString(R.string.ai_mode_academic_research), - icon = Icons.Rounded.School, - action = { - sendMessage("What's the latest news?") - } - ) - ) - } else { - listOf( - AiSuggestion( - title = stringProvider.getString(R.string.ask_ai), - icon = Icons.Rounded.Search, - action = { onAskQuestionClicked() } - ), - AiSuggestion( - title = stringProvider.getString(R.string.make_note), - icon = Icons.Rounded.Edit, - action = { onDraftAnythingClicked() } - ), - AiSuggestion( - title = stringProvider.getString(R.string.generate_ideas), - icon = Icons.Rounded.AutoAwesome, - action = { - setAiMode(AiMode.CREATIVE_MIND) - sendMessage(stringProvider.getString(R.string.generate_ideas_prompt)) - } - ), - AiSuggestion( - title = stringProvider.getString(R.string.create_note_from_object), - icon = Icons.Rounded.ImageSearch, - action = { requestImageForAnalysis() }, - ) - ) - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - init { - _chatState.asStateFlow().flatMapLatest { state: ChatState -> - state.currentSessionId?.let { sessionId: Long -> - aiChatUseCase.getMessagesForSession(sessionId) - } ?: flowOf(emptyList()) - }.onEach { dbMessages: List -> - val chatMessages = dbMessages.map { dbMessage: AiChatMessage -> - ChatMessage(dbMessage.text, dbMessage.participant, dbMessage.isLoading) - } - if (_chatState.value.latestDraft == null) { - _chatState.value = _chatState.value.copy(messages = chatMessages) - } - }.launchIn(viewModelScope) - - - noteUseCase.observe() - viewModelScope.launch { - val settings = settingsRepository.settings.first() - if (settings.openToLastUsedFolder && settings.lastUsedFolderId != null) { - _selectedFolderId.value = settings.lastUsedFolderId - settingsRepository.update(settings.copy(lastUsedFolderId = null)) - } - } - } - - // --- Global AI Chat Functions --- - fun setAiMode(mode: AiMode) { - _aiMode.value = mode - viewModelScope.launch { - _chatState.value.currentSessionId?.let { - aiChatUseCase.updateSessionMode(it, mode) - } - } - } - - fun toggleAiHistoryScreen(show: Boolean) { - _showAiHistoryScreen.value = show - } - - fun startNewChat() { - resetChatState() - toggleAiHistoryScreen(false) - } - - fun loadChatSession(session: AiChatSession) { - viewModelScope.launch { - _chatState.value = ChatState( - currentSessionId = session.id, - hasStartedConversation = true - ) - setAiMode(AiMode.valueOf(session.aiMode)) - selectAiService(AiService.valueOf(session.serviceName)) - toggleAiHistoryScreen(false) - } - } - - fun deleteChatSession(sessionId: Long) { - viewModelScope.launch { - aiChatUseCase.deleteSessionById(sessionId) - if (_chatState.value.currentSessionId == sessionId) { - resetChatState() - } - } - } - - fun toggleAiChatSheet(isVisible: Boolean) { - _isAiChatSheetVisible.value = isVisible - if (!isVisible) { - resetChatState() - toggleAiHistoryScreen(false) - } - } - - fun setFabExtended(isExtended: Boolean) { - _isFabExtended.value = isExtended - } - - fun onAttachmentIconClicked() { - viewModelScope.launch { - _uiActionChannel.send(UiAction.RequestAttachmentType) - } - } - - fun onAttachmentSelected(uri: Uri, mimeType: String?) { - _chatState.value = _chatState.value.copy( - pendingAttachmentUri = uri, - pendingAttachmentMimeType = mimeType - ) - } - - fun onAttachmentRemoved() { - _chatState.value = _chatState.value.copy( - pendingAttachmentUri = null, - pendingAttachmentMimeType = null - ) - } - - private val createNoteTriggers = setOf( - stringProvider.getString(R.string.analyze_create_note), - stringProvider.getString(R.string.analyze_analyze), - stringProvider.getString(R.string.analyze_take_notes), - stringProvider.getString(R.string.analyze_summarize_and_take_notes), - stringProvider.getString(R.string.analyze_prepare_a_draft), - stringProvider.getString(R.string.analyze_analyze_my_file_image)) - - fun sendMessage(userMessage: String) = viewModelScope.launch { - if (_selectedAiService.value == AiService.PERPLEXITY) { - sendPerplexityChat(userMessage) - return@launch - } - - val currentState = _chatState.value - val attachmentUri = currentState.pendingAttachmentUri - val attachmentMime = currentState.pendingAttachmentMimeType - - if (attachmentUri != null && attachmentMime != null) { - val isCreateNoteRequest = createNoteTriggers.any { userMessage.contains(it, ignoreCase = true) } - - if (isCreateNoteRequest) { - analyzeFileAndCreateDraft(userMessage, attachmentUri, attachmentMime) - } else { - sendChatWithAttachment(userMessage, attachmentUri, attachmentMime) - } - } else { - sendChatOnly(userMessage) - } - } - - private fun sendPerplexityChat(userMessage: String) = viewModelScope.launch { - var sessionId = _chatState.value.currentSessionId - if (sessionId == null) { - val newSessionId = aiChatUseCase.startNewSession( - title = userMessage.take(40), - aiMode = _aiMode.value, - serviceName = _selectedAiService.value.name - ) - _chatState.value = _chatState.value.copy(currentSessionId = newSessionId, hasStartedConversation = true) - sessionId = newSessionId - } - - val userChatMessage = ChatMessage(userMessage, Participant.USER) - aiChatUseCase.addMessageToSession(sessionId, userChatMessage) - val loadingMessage = ChatMessage("", Participant.MODEL, isLoading = true) - val loadingMessageId = aiChatUseCase.addMessageToSession(sessionId, loadingMessage) - val historyForApi = (_chatState.value.messages + userChatMessage).filter { !it.isLoading } - - val perplexityApiKey = secureStorageRepository.getPerplexityApiKey() ?: "" - if (perplexityApiKey.isBlank()) { - aiChatUseCase.updateMessageById(loadingMessageId, "Perplexity API anahtarı ayarlanmamış.", false, true) - return@launch - } - - val responseBuilder = StringBuilder() - try { - geminiRepository.generatePerplexityChatResponse(historyForApi, perplexityApiKey) - .collect { chunk -> - responseBuilder.append(chunk) - aiChatUseCase.updateMessageById(loadingMessageId, responseBuilder.toString(), true, false) - } - } catch (e: Exception) { - Log.e("HomeViewModel_PplxChat", "Akış hatası: ${e.message}", e) - aiChatUseCase.updateMessageById(loadingMessageId, e.message ?: "Bir hata oluştu", false, true) - } finally { - val fullResponse = responseBuilder.toString().trim() - if (fullResponse.isNotBlank()) { - aiChatUseCase.updateMessageById(loadingMessageId, fullResponse, false, false) - } else { - val currentMessages = aiChatUseCase.getMessagesForSession(sessionId).first() - val msg = currentMessages.find { it.id == loadingMessageId } - if (msg != null && !msg.text.isNotBlank() && msg.participant != Participant.ERROR) { - aiChatUseCase.deleteMessageById(loadingMessageId) - } - } - } - } - - private val _lastActionContext = mutableStateOf(null) - private val gson = Gson() - private fun sendChatOnly(userMessage: String) = viewModelScope.launch { - var sessionId = _chatState.value.currentSessionId - if (sessionId == null) { - val newSessionId = aiChatUseCase.startNewSession( - title = userMessage.take(40), - aiMode = _aiMode.value, - serviceName = _selectedAiService.value.name - ) - _chatState.value = _chatState.value.copy(currentSessionId = newSessionId, hasStartedConversation = true) - sessionId = newSessionId - } - - val userChatMessage = ChatMessage(userMessage, Participant.USER) - aiChatUseCase.addMessageToSession(sessionId, userChatMessage) - val loadingMessage = ChatMessage("", Participant.MODEL, isLoading = true) - val loadingMessageId = aiChatUseCase.addMessageToSession(sessionId, loadingMessage) - val historyForApi = (_chatState.value.messages + userChatMessage).filter { !it.isLoading } - val allNotes = allNotesForAi.first() - val contextFromMemory = _lastActionContext.value - val contextFromMention = findMentionedNote(userMessage, allNotes) - val mentionedNote = contextFromMemory ?: contextFromMention - - if (contextFromMemory != null) { - _lastActionContext.value = null - } - - try { - val jsonResult = geminiRepository.generateActionPlan( - userRequest = userMessage, - chatHistory = historyForApi, - apiKey = getApiKeyToUse(), - aiMode = _aiMode.value, - mentionedNote = mentionedNote - ) - - if (jsonResult.isSuccess) { - val jsonString = jsonResult.getOrThrow() - val plan = gson.fromJson(jsonString, AiActionPlan::class.java) - var finalResponseMessage = plan.response_message - - for (action in plan.actions) { - val targetNoteForModification = mentionedNote ?: allNotes.find { - it.name.equals(action.note_title, ignoreCase = true) - } - - when (action.action_type) { - - "CREATE_NOTE" -> { - val title = action.title ?: stringProvider.getString(R.string.title_dex) - val newNote = Note( - name = title, - description = action.content ?: "", - folderId = selectedFolderId.value, - encrypted = isVaultMode.value - ) - noteUseCase.addNote(newNote) - val createdNote = noteUseCase.getAllNotes().first().lastOrNull { it.name == title } - _lastActionContext.value = createdNote - } - - "CREATE_TODO_NOTE" -> { - val todoContent = action.tasks?.joinToString("\n") { "[ ] $it" } ?: "" - val title = action.title ?: stringProvider.getString(R.string.ai_command_todo) - val newTodoNote = Note( - name = title, - description = todoContent, - folderId = selectedFolderId.value, - encrypted = isVaultMode.value - ) - noteUseCase.addNote(newTodoNote) - - val createdTodoNote = noteUseCase.getAllNotes().first().lastOrNull { it.name == title } - _lastActionContext.value = createdTodoNote - } - - "CREATE_FOLDER" -> { - val folderName = action.name ?: "New Folder" - folderUseCase.addFolder( - Folder(name = folderName, iconName = action.iconName ?: "Folder") - ) - } - - "MOVE_NOTE_TO_FOLDER" -> { - val targetFolder = folderUseCase.getAllFolders().first() - .find { it.name.equals(action.folder_name, ignoreCase = true) } - - if (targetNoteForModification != null && targetFolder != null) { - val movedNote = targetNoteForModification.copy(folderId = targetFolder.id) - noteUseCase.addNote(movedNote) - _lastActionContext.value = movedNote - } else { - Log.w("HomeViewModel", "Move action failed: Note or Folder not found.") - finalResponseMessage = stringProvider.getString(R.string.ai_error_modification_failed) - } - } - - "APPEND_TO_NOTE" -> { - val contentToAppend = action.content ?: "" - if (targetNoteForModification != null) { - val updatedNote = targetNoteForModification.copy( - description = targetNoteForModification.description + "\n" + contentToAppend - ) - noteUseCase.addNote(updatedNote) - _lastActionContext.value = updatedNote - } else { - Log.w("HomeViewModel", "Append action failed: Note not found.") - finalResponseMessage = stringProvider.getString(R.string.ai_error_modification_failed) - } - } - - "REWRITE_NOTE" -> { - val newContent = action.new_content ?: "" - if (targetNoteForModification != null) { - val updatedNote = targetNoteForModification.copy(description = newContent) - noteUseCase.addNote(updatedNote) - _lastActionContext.value = updatedNote - } else { - Log.w("HomeViewModel", "Rewrite action failed: Note not found.") - finalResponseMessage = stringProvider.getString(R.string.ai_error_modification_failed) - } - } - - "DELETE_NOTE" -> { - if (targetNoteForModification != null) { - noteUseCase.deleteNoteById(targetNoteForModification.id) - _lastActionContext.value = null - } else { - Log.w("HomeViewModel", "Delete action failed: Note not found.") - finalResponseMessage = stringProvider.getString(R.string.ai_error_modification_failed) - } - } - - "QUERY_NOTES" -> { - val allFolders = folderUseCase.getAllFolders().first() - var notesToSearch = allNotes - - if (!action.folder_name.isNullOrBlank()) { - val targetFolder = allFolders.find { it.name.equals(action.folder_name, ignoreCase = true) } - if (targetFolder != null) { - notesToSearch = notesToSearch.filter { it.folderId == targetFolder.id } - } else { - finalResponseMessage = stringProvider.getString(R.string.folder_not_found) - break - } - } - - val searchTerm = action.search_term - val searchResults = if (!searchTerm.isNullOrBlank()) { - notesToSearch.filter { - it.name.contains(searchTerm, ignoreCase = true) || - it.description.contains(searchTerm, ignoreCase = true) - } - } else { - notesToSearch - } - - if (searchResults.isEmpty()) { - finalResponseMessage = stringProvider.getString(R.string.not_found_note) - } else { - val resultsText = searchResults.joinToString("\n") { "- ${it.name}" } - finalResponseMessage = stringProvider.getString(R.string.found_note, resultsText) - } - } - - "CHAT" -> { - if (action.response != null) { - finalResponseMessage = action.response - } - _lastActionContext.value = null - } - - else -> { - Log.w("HomeViewModel", "Unknown AI action type: ${action.action_type}") - _lastActionContext.value = null - } - } - } - aiChatUseCase.updateMessageById(loadingMessageId, finalResponseMessage, false) - } else { - _lastActionContext.value = null - val errorMessage = jsonResult.exceptionOrNull()?.message - ?: stringProvider.getString(R.string.error_unknown) - aiChatUseCase.updateMessageById(loadingMessageId, errorMessage, false, true) - } - - } catch (e: JsonSyntaxException) { - _lastActionContext.value = null - Log.e("HomeViewModel", "JSON Syntax Error: ${e.message}") - aiChatUseCase.updateMessageById(loadingMessageId, stringProvider.getString(R.string.ai_error_invalid_json), false, true) - - } catch (e: Exception) { - _lastActionContext.value = null - Log.e("HomeViewModel", "Error in sendChatOnly: ${e.message}", e) - val errorMessage = e.message ?: stringProvider.getString(R.string.error_unknown) - aiChatUseCase.updateMessageById(loadingMessageId, errorMessage, false, true) - } - } - - private fun sendChatWithAttachment(userMessage: String, uri: Uri, mimeType: String) = viewModelScope.launch { - val currentState = _chatState.value - val apiKey = getApiKeyToUse() - - val attachmentPart: Part? = try { - if (mimeType.startsWith("image/")) { - val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) - } else { - @Suppress("DEPRECATION") - MediaStore.Images.Media.getBitmap(context.contentResolver, uri) - } - ImagePart(bitmap) - } else if (mimeType == "text/plain") { - val textContent = readTextFromUri(uri) - if (textContent.isBlank()) throw Exception(stringProvider.getString(R.string.file_empty_or_not_read)) - TextPart(textContent) - } else { - throw IllegalArgumentException("Unsupported file type: $mimeType") - } - } catch (e: Exception) { - onAttachmentRemoved() - sendChatOnly(userMessage) - return@launch - } - - if (attachmentPart == null) { - onAttachmentRemoved() - sendChatOnly(userMessage) - return@launch - } - - var sessionId = currentState.currentSessionId - if (sessionId == null) { - val newSessionId = aiChatUseCase.startNewSession( - title = userMessage.take(40), - aiMode = _aiMode.value, - serviceName = _selectedAiService.value.name - ) - _chatState.value = currentState.copy(currentSessionId = newSessionId, hasStartedConversation = true) - sessionId = newSessionId - } - - val userChatMessage = ChatMessage(userMessage, Participant.USER) - aiChatUseCase.addMessageToSession(sessionId, userChatMessage) - onAttachmentRemoved() - - val loadingMessage = ChatMessage("", Participant.MODEL, isLoading = true) - val loadingMessageId = aiChatUseCase.addMessageToSession(sessionId, loadingMessage) - - val historyForApi = (currentState.messages + userChatMessage).filter { !it.isLoading } - val responseBuilder = StringBuilder() - - try { - geminiRepository.generateChatResponse( - historyForApi, - apiKey, - _aiMode.value, - null, - attachmentPart - ) - .collect { chunk -> - responseBuilder.append(chunk) - aiChatUseCase.updateMessageById(loadingMessageId, responseBuilder.toString(), true, false) - } - } catch (e: Exception) { - Log.e("HomeViewModel_GeminiChat", "Akış hatası: ${e.message}", e) - aiChatUseCase.updateMessageById(loadingMessageId, e.message ?: "Bir hata oluştu", false, true) - } finally { - val fullResponse = responseBuilder.toString().trim() - if (fullResponse.isNotBlank()) { - aiChatUseCase.updateMessageById(loadingMessageId, fullResponse, false, false) - } else { - val currentMessages = aiChatUseCase.getMessagesForSession(sessionId).first() - val msg = currentMessages.find { it.id == loadingMessageId } - if (msg != null && !msg.text.isNotBlank() && msg.participant != Participant.ERROR) { - aiChatUseCase.deleteMessageById(loadingMessageId) - } - } - } - } - - fun generateDraft(topic: String) = viewModelScope.launch { - resetChatState() - setAiMode(AiMode.NOTE_ASSISTANT) - - val sessionId = aiChatUseCase.startNewSession( - title = topic.take(40), - aiMode = _aiMode.value, - serviceName = _selectedAiService.value.name - ) - - _chatState.value = _chatState.value.copy(currentSessionId = sessionId, hasStartedConversation = true) - - val userMessage = ChatMessage(text = topic, participant = Participant.USER) - aiChatUseCase.addMessageToSession(sessionId, userMessage) - - val loadingMessage = ChatMessage("// Generating Note...", Participant.MODEL, isLoading = true) - val loadingMessageId = aiChatUseCase.addMessageToSession(sessionId, loadingMessage) - - geminiRepository.generateDraft(topic, getApiKeyToUse(), _aiMode.value) - .onSuccess { result -> - val (title, content) = parseDraft(result) - _chatState.value = _chatState.value.copy( - latestDraft = DraftedNote( - topic = topic, - title = title, - content = content, - sourceImageUri = null - ) - ) - val confirmationMessage = "Harika! '$title' üzerine bir not taslağı hazırladım." - aiChatUseCase.updateMessageById(loadingMessageId, confirmationMessage, false) - } - .onFailure { exception -> - val errorMessage = exception.message ?: stringProvider.getString(R.string.error_message_image_analysis_failed) - aiChatUseCase.updateMessageById(loadingMessageId, errorMessage, false, isError = true) - _chatState.value = _chatState.value.copy(latestDraft = null) - } - } - - private fun parseDraft(result: String): Pair { - val titleRegex = """(TITLE:|\*\*TITLE:\*\*)\s*(.*)""".toRegex(RegexOption.IGNORE_CASE) - val contentRegex = """(CONTENT:|\*\*CONTENT:\*\*)\s*(.*)""".toRegex(setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) - - val titleMatch = titleRegex.find(result) - val title = titleMatch?.groupValues?.get(2)?.trim()?.replace(Regex("[*#]"), "") ?: "Generated Note" - - val contentMatch = contentRegex.find(result) - val content = contentMatch?.groupValues?.get(2)?.trim()?.replace(Regex("[*#]"), "") ?: "" - - return Pair(title, content) - } - - private fun parseAndSetDraft(result: String, topic: String, imageUri: Uri?) { - val (title, content) = parseDraft(result) - _chatState.value = _chatState.value.copy( - messages = emptyList(), - latestDraft = DraftedNote( - topic = topic, - title = title, - content = content, - sourceImageUri = imageUri - ), - analyzingImageUri = null - ) - } - - private fun handleFailure(exception: Throwable) { - val finalMessages = listOf( - ChatMessage( - text = exception.message ?: stringProvider.getString(R.string.error_message_image_analysis_failed), - participant = Participant.ERROR - ) - ) - _chatState.value = _chatState.value.copy( - messages = finalMessages, - latestDraft = null, - analyzingImageUri = null - ) - } - - private suspend fun getOrCreateDreamFolderId(): Long? { - val dreamFolderName = stringProvider.getString(R.string.dream_journal) - val foldersFlow = folderUseCase.getAllFolders() - var folders = foldersFlow.first() - var dreamFolder = folders.find { it.name.equals(dreamFolderName, ignoreCase = true) } - - if (dreamFolder != null) { - return dreamFolder.id - } - - val newFolder = Folder(name = dreamFolderName, iconName = "Book") - try { - folderUseCase.addFolder(newFolder) - folders = foldersFlow.first() - dreamFolder = folders.find { it.name.equals(dreamFolderName, ignoreCase = true) } - return dreamFolder?.id - } catch (e: Exception) { - e.printStackTrace() - folders = foldersFlow.first() - dreamFolder = folders.find { it.name.equals(dreamFolderName, ignoreCase = true) } - return dreamFolder?.id - } - } - - fun onDreamJournalClicked(onNoteClicked: (noteId: Int, isVault: Boolean, folderId: Long?) -> Unit) { - viewModelScope.launch { - val folderId = getOrCreateDreamFolderId() - onNoteClicked(0, _isVaultMode.value, folderId) - } - } - - - fun saveDraftedNote() = viewModelScope.launch { - val draft = _chatState.value.latestDraft ?: return@launch - noteUseCase.addNote( - Note( - name = draft.title, - description = draft.content, - folderId = selectedFolderId.value, - encrypted = isVaultMode.value - ) - ) - resetChatState() - toggleAiChatSheet(false) - } - - fun requestImageForAnalysis() { - viewModelScope.launch { - _uiActionChannel.send(UiAction.RequestImageForAnalysis) - } - } - - fun analyzeFileAndCreateDraft(prompt: String, uri: Uri, mimeType: String) = viewModelScope.launch { - _chatState.value = _chatState.value.copy( - latestDraft = null, - messages = listOf(ChatMessage(text = "", participant = Participant.MODEL, isLoading = true)), - hasStartedConversation = true, - analyzingImageUri = if (mimeType.startsWith("image/")) uri else null, - pendingAttachmentUri = null, - pendingAttachmentMimeType = null - ) - - val apiKey = getApiKeyToUse() - - val attachmentPart: Part? = try { - if (mimeType.startsWith("image/")) { - val bitmap: Bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) - } else { - @Suppress("DEPRECATION") - MediaStore.Images.Media.getBitmap(context.contentResolver, uri) - } - ImagePart(bitmap) - } else if (mimeType == "text/plain") { - val textContent = readTextFromUri(uri) - if (textContent.isBlank()) throw Exception(stringProvider.getString(R.string.file_empty_or_not_read)) - TextPart(textContent) - } else { - throw IllegalArgumentException("Desteklenmeyen dosya türü: $mimeType") - } - } catch (e: Exception) { - handleFailure(e) - null - } - - if (attachmentPart == null) return@launch - - geminiRepository.generateDraftFromAttachment(prompt, attachmentPart, apiKey, _aiMode.value) - .onSuccess { result -> - parseAndSetDraft(result, prompt, if (mimeType.startsWith("image/")) uri else null) - } - .onFailure { exception -> - handleFailure(exception) - } - } - - - fun regenerateDraft() { - val draft = _chatState.value.latestDraft ?: return - val sourceImageUri = draft.sourceImageUri - _chatState.value = _chatState.value.copy(latestDraft = null) - - if (sourceImageUri != null) { - analyzeFileAndCreateDraft(draft.topic, sourceImageUri, "image/*") - } else { - generateDraft(draft.topic) - } - } - - fun resetChatState() { - _chatState.value = ChatState() - } - - private fun readTextFromUri(uri: Uri): String { - return try { - context.contentResolver.openInputStream(uri)?.bufferedReader().use { reader -> - reader?.readText() - } ?: "" - } catch (e: Exception) { - e.printStackTrace() - "" - } - } - - // --- Note and Folder Functions --- - - val allFolders: StateFlow> = folderUseCase.getAllFolders() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - val displayedNotes: StateFlow> = - combine, Long?, Boolean, String, List>( - noteUseCase.getAllNotes(), - selectedFolderId, - isVaultMode, - searchQuery - ) { allNotes, folderId, isVault, query -> - val notesAfterFolderFilter = if (folderId == null) { - allNotes - } else { - allNotes.filter { it.folderId == folderId } - } - val notesAfterVaultFilter = notesAfterFolderFilter.filter { it.encrypted == isVault } - - if (query.isBlank()) { - notesAfterVaultFilter - } else { - notesAfterFolderFilter.filter { note -> - note.name.contains(query, ignoreCase = true) || - note.description.contains(query, ignoreCase = true) - } - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - fun onFolderLongPressed(folder: Folder) { - _folderForAction.value = folder - } - - fun onDismissFolderAction() { - _folderForAction.value = null - _showDeleteConfirmDialog.value = false - } - - fun onEditFolderRequest() { - _folderToEdit.value = _folderForAction.value - onDismissFolderAction() - } - - fun onDeleteFolderRequest() { - _showDeleteConfirmDialog.value = true - } - - fun confirmFolderDeletion() { - viewModelScope.launch { - _folderForAction.value?.let { folder -> - folderUseCase.deleteFolderAndUnlinkNotes(folder) - } - onDismissFolderAction() - } - } - - fun onDismissEditFolderDialog() { - _folderToEdit.value = null - } - - fun setAddFolderDialogVisibility(isVisible: Boolean) { - _isAddFolderDialogVisible.value = isVisible - } - - - - fun toggleIsDeleteMode(enabled: Boolean) { - _isDeleteMode.value = enabled - } - - fun toggleIsVaultMode(enabled: Boolean) { - _isVaultMode.value = enabled - if (!enabled) { - noteUseCase.decryptionResult = DecryptionResult.LOADING - } - noteUseCase.observe() - } - - - fun toggleIsPasswordPromptVisible(enabled: Boolean) { - _isPasswordPromptVisible.value = enabled - } - - fun changeSearchQuery(newValue: String) { - selectNote(null) - _searchQuery.value = newValue - } - - fun pinOrUnpinNotes() { - if (selectedNotes.all { it.pinned }) { - selectedNotes.forEach { note -> - val updatedNote = note.copy(pinned = false) - noteUseCase.pinNote(updatedNote) - } - } else { - selectedNotes.forEach { note -> - val updatedNote = note.copy(pinned = true) - noteUseCase.pinNote(updatedNote) - } - } - selectedNotes.clear() - } - - fun selectFolder(folderId: Long?) { - selectNote(null) - _selectedFolderId.value = folderId - viewModelScope.launch { - val settings = settingsRepository.settings.first() - if (settings.openToLastUsedFolder) { - settingsRepository.update(settings.copy(lastUsedFolderId = folderId)) - } - } - } - - fun addFolder(name: String, iconName: String) { - viewModelScope.launch { - folderUseCase.addFolder(Folder(name = name, iconName = iconName)) - setAddFolderDialogVisibility(false) - } - } - - fun setMoveToFolderDialogVisibility(isVisible: Boolean) { - _isMoveToFolderDialogVisible.value = isVisible - } - - fun moveSelectedNotesToFolder(targetFolderId: Long) { - viewModelScope.launch { - val movedNotesCount = selectedNotes.size - selectedNotes.forEach { note -> - noteUseCase.addNote(note.copy(folderId = targetFolderId)) - } - selectedNotes.clear() - setMoveToFolderDialogVisibility(false) - _uiEvent.send("$movedNotesCount not taşındı.") - } - } - - fun onAddNewNoteClicked(onNoteClicked: (noteId: Int, isVault: Boolean, folderId: Long?) -> Unit) { - val targetFolderId = selectedFolderId.value - onNoteClicked(0, isVaultMode.value, targetFolderId) - } - - fun updateNote(note: Note) { - viewModelScope.launch { - noteUseCase.addNote(note) - } - } - - fun updateNoteDetails(noteToUpdate: Note, newName: String, newDescription: String) { - viewModelScope.launch { - val updatedNote = noteToUpdate.copy(name = newName, description = newDescription) - noteUseCase.addNote(updatedNote) - } - } - - fun onNoteLongPressed(note: Note) { - _noteForAction.value = note - } - - fun onDismissNoteAction() { - _noteForAction.value = null - } - - fun deleteNoteAction() { - _noteForAction.value?.let { noteToDelete -> - deleteNoteById(noteToDelete.id) - } - onDismissNoteAction() - } - - fun requestMoveNoteAction() { - _noteForAction.value?.let { noteToMove -> - selectedNotes.clear() - selectedNotes.add(noteToMove) - setMoveToFolderDialogVisibility(true) - } - onDismissNoteAction() - } - - fun updateFolder(name: String, iconName: String) { - _folderToEdit.value?.let { folder -> - viewModelScope.launch { - val updatedFolder = folder.copy(name = name, iconName = iconName) - folderUseCase.updateFolder(updatedFolder) - onDismissEditFolderDialog() - } - } - } - - fun deleteNoteById(id: Int) { - viewModelScope.launch { - noteUseCase.deleteNoteById(id) - } - } - - fun observeNotes() { - noteUseCase.observe() - } - - fun createNewNoteForDesktop() { - viewModelScope.launch { - val targetFolderId = _selectedFolderId.value - val newNote = Note( - name = "Untitled Note", - description = "", - folderId = targetFolderId, - encrypted = isVaultMode.value - ) - noteUseCase.addNote(newNote) - - val latestNote = noteUseCase.getAllNotes().first().maxByOrNull { it.createdAt } - if (latestNote != null) { - selectNote(latestNote) - } - } - } - - fun executeDesktopAiAction(action: AiAction, tone: AiTone? = null) { - val currentNote = _selectedNote.value ?: return - val currentDescription = currentNote.description - - viewModelScope.launch { - geminiRepository.processAiAction( - action = action, - text = currentDescription, - tone = tone, - apiKey = getApiKeyToUse(), - aiMode = _aiMode.value - ) - .onSuccess { result -> - updateNoteDetails(currentNote, currentNote.name, result) - } - .onFailure { exception -> - _uiEvent.send(exception.message ?: "AI işlemi başarısız oldu.") - } - } - } - - fun toggleLockForSelectedNotes() { - val allLocked = selectedNotes.all { it.isLocked } - viewModelScope.launch { - selectedNotes.forEach { note -> - val updatedNote = note.copy(isLocked = !allLocked) - noteUseCase.addNote(updatedNote) - } - selectedNotes.clear() - _uiEvent.send(if (!allLocked) stringProvider.getString(R.string.notes_are_locked) else stringProvider.getString(R.string.notes_are_unlocked)) - } - } - fun isNoteLocked(noteId: Int): Boolean { - return displayedNotes.value.find { it.id == noteId }?.isLocked == true - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/AddFolderDialog.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/AddFolderDialog.kt deleted file mode 100644 index c0d4f45..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/AddFolderDialog.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.presentation.screens.home.widgets - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.domain.model.Folder - -@Composable -fun AddFolderDialog( - onDismiss: () -> Unit, - onConfirm: (name: String, colorHex: String) -> Unit, - folderToEdit: Folder? = null -) { - var folderName by remember { mutableStateOf("") } - val folderColors = listOf( - "#FF5733", "#33FF57", "#3357FF", "#FF33A1", - "#A133FF", "#33FFA1", "#FFC300", "#C70039" - ) - var selectedColor by remember { mutableStateOf(folderColors.first()) } - val isEditing = folderToEdit != null - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(if (isEditing) R.string.edit_folder else R.string.add_new_folder)) }, - text = { - Column { - // Klasör adı için TextField - OutlinedTextField( - value = folderName, - onValueChange = { folderName = it }, - label = { Text(stringResource(R.string.folder_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(16.dp)) - - Text(text = stringResource(R.string.folder_color), style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.height(8.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(folderColors) { colorHex -> - val color = Color(android.graphics.Color.parseColor(colorHex)) - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(color) - .clickable { selectedColor = colorHex } - .border( - width = 2.dp, - color = if (selectedColor == colorHex) MaterialTheme.colorScheme.primary else Color.Transparent, - shape = CircleShape - ) - ) - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - if (folderName.isNotBlank()) { - onConfirm(folderName, selectedColor) - } - }, - enabled = folderName.isNotBlank() - ) { - Text(stringResource(if (isEditing) R.string.save else R.string.add)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/FolderActionBottomSheet.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/FolderActionBottomSheet.kt deleted file mode 100644 index ada8cb9..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/FolderActionBottomSheet.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.presentation.screens.home.widgets - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.domain.model.Folder - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FolderActionBottomSheet( - folder: Folder, - onDismiss: () -> Unit, - onEditClick: () -> Unit, - onDeleteClick: () -> Unit -) { - ModalBottomSheet(onDismissRequest = onDismiss) { - Column(modifier = Modifier.padding(bottom = 32.dp)) { - Text( - text = folder.name, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) - Divider() - ListItem( - headlineContent = { Text(stringResource(R.string.edit_folder)) }, - leadingContent = { Icon(Icons.Rounded.Edit, contentDescription = null) }, - modifier = Modifier.clickable(onClick = onEditClick) - ) - ListItem( - headlineContent = { Text(stringResource(R.string.delete_folder), color = MaterialTheme.colorScheme.error) }, - leadingContent = { Icon(Icons.Rounded.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, - modifier = Modifier.clickable(onClick = onDeleteClick) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/MoveToFolderDialog.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/MoveToFolderDialog.kt deleted file mode 100644 index a40d7af..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/MoveToFolderDialog.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.presentation.screens.home.widgets - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.presentation.screens.home.iconNameToVector - -@Composable -fun MoveToFolderDialog( - folders: List, - onDismiss: () -> Unit, - onFolderSelected: (folderId: Long) -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(text = stringResource(R.string.move_to_folder)) }, - text = { - if (folders.isEmpty()) { - Text(text = stringResource(R.string.no_folders_available)) - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth() - ) { - items(folders) { folder -> - ListItem( - headlineContent = { Text(folder.name) }, - leadingContent = { - Icon( - imageVector = iconNameToVector(folder.iconName), - contentDescription = folder.name - ) - }, - modifier = Modifier.clickable { - onFolderSelected(folder.id) - } - ) - } - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cancel)) - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteCard.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteCard.kt deleted file mode 100644 index d090e1c..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteCard.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home.widgets - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.presentation.components.FolderIndicator -import com.babelsoftware.airnote.presentation.components.markdown.MarkdownText -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.theme.FontUtils - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun NoteCard( - settingsViewModel: SettingsViewModel, - containerColor: Color, - note: Note, - allFolders: List, - isBorderEnabled: Boolean, - shape: RoundedCornerShape, - onShortClick: () -> Unit, - onLongClick: () -> Unit, - onNoteUpdate: (Note) -> Unit -) { - val borderModifier = if (isBorderEnabled) { - Modifier.border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.primary, - shape = shape - ) - } else if (containerColor == Color.Black) { - Modifier.border( - width = 1.5.dp, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = shape - ) - } else { - Modifier - } - - val folder = note.folderId?.let { id -> - allFolders.find { it.id == id } - } - - val contentModifier = if (note.isLocked) Modifier.blur(15.dp) else Modifier - - ElevatedCard( - modifier = Modifier - .padding(bottom = 12.dp) - .clip(shape) - .combinedClickable( - onClick = onShortClick, - onLongClick = onLongClick - ) - .then(borderModifier), - colors = CardDefaults.cardColors( - containerColor = containerColor - ), - elevation = CardDefaults.cardElevation( - defaultElevation = if (containerColor != Color.Black) 6.dp else 0.dp - ), - ) { - Box(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .padding(16.dp, 12.dp, 16.dp, 12.dp) - .then(contentModifier) - ) { - if (note.isLocked) { - } else { - if (note.name.isNotBlank()) { - MarkdownText( - isPreview = true, - isEnabled = settingsViewModel.settings.value.isMarkdownEnabled, - markdown = note.name.replaceFirstChar { it.uppercase() }, - modifier = Modifier - .heightIn(max = dimensionResource(R.dimen.max_name_height)) - .then( - if (note.description.isNotBlank() && !settingsViewModel.settings.value.showOnlyTitle) { - Modifier.padding(bottom = 9.dp) - } else { - Modifier - } - ), - weight = FontWeight.Bold, - spacing = 0.dp, - onContentChange = { onNoteUpdate(note.copy(name = it)) }, - fontSize = FontUtils.getTitleFontSize(settingsViewModel), - radius = settingsViewModel.settings.value.cornerRadius, - settingsViewModel = settingsViewModel - ) - } - - if (note.description.isNotBlank() && !settingsViewModel.settings.value.showOnlyTitle) { - MarkdownText( - isPreview = true, - markdown = note.description, - isEnabled = settingsViewModel.settings.value.isMarkdownEnabled, - spacing = 0.dp, - modifier = Modifier - .heightIn(max = dimensionResource(R.dimen.max_description_height)), - onContentChange = { onNoteUpdate(note.copy(description = it)) }, - fontSize = FontUtils.getBodyFontSize(settingsViewModel), - radius = settingsViewModel.settings.value.cornerRadius, - settingsViewModel = settingsViewModel - ) - } - } - } - - if (note.isLocked) { - Icon( - imageVector = Icons.Rounded.Lock, - contentDescription = "Locked", - modifier = Modifier.align(Alignment.Center).size(32.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - } - - if (folder != null && settingsViewModel.settings.value.showFolderIndicator) { - FolderIndicator( - folderName = folder.name, - modifier = Modifier.align(Alignment.TopEnd) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteFilter.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteFilter.kt deleted file mode 100644 index b11c3d7..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteFilter.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home.widgets - -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState -import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Notes -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.domain.model.Folder -import com.babelsoftware.airnote.R - -@Composable -fun NoteFilter( - modifier: Modifier = Modifier, - listState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), - settingsViewModel: SettingsViewModel, - containerColor : Color, - onNoteClicked: (Int) -> Unit, - shape: RoundedCornerShape, - notes: List, - allFolders: List, - searchText: String? = null, - selectedNotes: MutableList = mutableListOf(), - viewMode: Boolean = false, - isDeleteMode: Boolean = false, - onNoteUpdate: (Note) -> Unit = {}, - onDeleteNote: (Int) -> Unit = {} -) { - if (notes.isEmpty()) { - Placeholder( - modifier = modifier, - placeholderIcon = { - Icon( - getEmptyIcon(searchText), - contentDescription = "Placeholder icon", - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(64.dp) - ) - }, - placeholderText = getEmptyText(searchText) - ) - } else { - NotesGrid( - modifier = modifier, - listState = listState, - settingsViewModel = settingsViewModel, - containerColor = containerColor, - onNoteClicked = onNoteClicked, - notes = notes, - allFolders = allFolders, - shape = shape, - onNoteUpdate = onNoteUpdate, - selectedNotes = selectedNotes, - viewMode = viewMode, - isDeleteClicked = isDeleteMode, - animationFinished = onDeleteNote - ) - } -} - -@Composable -private fun getEmptyText(searchText: String?): String { - return when { - searchText.isNullOrEmpty() -> stringResource(R.string.no_created_notes) - else -> stringResource(R.string.no_found_notes) - } -} - -@Composable -private fun getEmptyIcon(searchText: String?): ImageVector { - return when { - searchText.isNullOrEmpty() -> Icons.AutoMirrored.Rounded.Notes - else -> Icons.Rounded.Search - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteGrid.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteGrid.kt deleted file mode 100644 index 977c628..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/NoteGrid.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home.widgets - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.presentation.components.getNoteEnterAnimation -import com.babelsoftware.airnote.presentation.components.getNoteExitAnimation -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.domain.model.Folder - -@Composable -fun NotesGrid( - modifier: Modifier = Modifier, - listState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), - settingsViewModel: SettingsViewModel, - containerColor: Color, - onNoteClicked: (Int) -> Unit, - shape : RoundedCornerShape, - notes: List, - allFolders: List, - onNoteUpdate: (Note) -> Unit, - selectedNotes: MutableList, - viewMode: Boolean, - isDeleteClicked: Boolean, - animationFinished: (Int) -> Unit -) { - val (pinnedNotes, otherNotes) = notes.partition { it.pinned } - - @Composable - fun Note(note: Note, notes: List) { - val isAnimationVisible = rememberTransitionState() - AnimatedVisibility( - visibleState = isAnimationVisible, - enter = getNoteEnterAnimation(), - exit = getNoteExitAnimation(calculateSlideDirection(notes, note)) - ) { - NoteCard( - settingsViewModel = settingsViewModel, - containerColor = containerColor, - note = note, - allFolders = allFolders, - shape = shape, - isBorderEnabled = selectedNotes.contains(note), - onShortClick = { handleShortClick(selectedNotes, note, onNoteClicked) }, - onNoteUpdate = onNoteUpdate, - onLongClick = { handleLongClick(selectedNotes, note) } - ) - if (isDeleteClicked && selectedNotes.contains(note)) { - isAnimationVisible.targetState = false - } - } - handleDeleteAnimation(selectedNotes, note, isAnimationVisible, animationFinished) - } - - LazyVerticalStaggeredGrid( - modifier = modifier.padding(horizontal = 12.dp), - state = listState, - columns = when(viewMode) { - true -> StaggeredGridCells.Fixed(2) - false -> StaggeredGridCells.Fixed(1) - }, - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(bottom = 160.dp), - content = { - if (pinnedNotes.isNotEmpty()) { - item(span = StaggeredGridItemSpan.FullLine) { - Text( - modifier = Modifier.padding(bottom = 16.dp), - text = stringResource(id = R.string.pinned).uppercase(), - style = TextStyle(fontSize = 10.sp, color = MaterialTheme.colorScheme.secondary) - ) - } - items(pinnedNotes) { note -> - Note(note, pinnedNotes) - } - if (otherNotes.isNotEmpty()) { - item(span = StaggeredGridItemSpan.FullLine) { - Text( - modifier = Modifier.padding(vertical = 16.dp), - text = stringResource(id = R.string.others).uppercase(), - style = TextStyle(fontSize = 10.sp, color = MaterialTheme.colorScheme.secondary) - ) - } - } - } - items(otherNotes) { note -> - Note(note, otherNotes) - } - }, - ) -} - - -private fun calculateSlideDirection(notes: List, note: Note): Int { - return if (notes.indexOf(note) % 2 == 0) -1 else 1 -} - -@Composable -private fun rememberTransitionState(): MutableTransitionState { - return remember { MutableTransitionState(false).apply { targetState = true } } -} - -private fun handleShortClick( - selectedNotes: MutableList, - note: Note, - onNoteClicked: (Int) -> Unit -) { - if (selectedNotes.isNotEmpty()) { - if (selectedNotes.contains(note)) { - selectedNotes.remove(note) - } else { - selectedNotes.add(note) - } - } else { - onNoteClicked(note.id) - } -} - -private fun handleLongClick(selectedNotes: MutableList, note: Note) { - if (!selectedNotes.contains(note)) { - selectedNotes.add(note) - } -} - -private fun handleDeleteAnimation( - selectedNotes: MutableList, - note: Note, - isAnimationVisible: MutableTransitionState, - animationFinished: (Int) -> Unit -) { - if (!isAnimationVisible.targetState && !isAnimationVisible.currentState && selectedNotes.contains(note)) { - selectedNotes.remove(note) - isAnimationVisible.targetState = true - animationFinished(note.id) - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/Placeholder.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/Placeholder.kt deleted file mode 100644 index 518384f..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/home/widgets/Placeholder.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.home.widgets - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@Composable -fun Placeholder( - modifier: Modifier = Modifier, - placeholderIcon: @Composable () -> Unit, - placeholderText: String -) { - Box( - modifier = Modifier - .imePadding() - .fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - placeholderIcon() - - Text( - text = placeholderText, - color = MaterialTheme.colorScheme.outline, - fontSize = 14.sp - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/SettingsScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/SettingsScreen.kt deleted file mode 100644 index 5f310e1..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/SettingsScreen.kt +++ /dev/null @@ -1,271 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos -import androidx.compose.material.icons.rounded.Cloud -import androidx.compose.material.icons.rounded.Coffee -import androidx.compose.material.icons.rounded.CurrencyBitcoin -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Palette -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.DesktopWindows -import androidx.compose.material.icons.rounded.TextFields -import androidx.compose.material.icons.rounded.Work -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.key -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.babelsoftware.airnote.constant.ConnectionConst -import com.babelsoftware.airnote.presentation.components.NavigationIcon -import com.babelsoftware.airnote.presentation.components.NotesScaffold -import com.babelsoftware.airnote.presentation.components.TitleText -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.shapeManager -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingCategory -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox - -@Composable -fun SettingsScaffold( - settingsViewModel: SettingsViewModel, - title: String, - onBackNavClicked: () -> Unit, - snackbarHost: @Composable () -> Unit = {}, - content: @Composable () -> Unit -) { - NotesScaffold( - snackbarHost = snackbarHost, - topBar = { - key(settingsViewModel.settings.value) { - TopBar(title, onBackNavClicked) - } - }, - content = { - Box(Modifier.padding(16.dp, 8.dp, 16.dp)) { - content() - } - } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TopBar( - title: String, - onBackNavClicked: () -> Unit, -) { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - title = { - TitleText(titleText = title) - }, - navigationIcon = { NavigationIcon { onBackNavClicked() } } - ) -} - -@Composable -fun MainSettings(settingsViewModel: SettingsViewModel, navController: NavController) { - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.screen_settings), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingCategory( - smallSetting = true, - title = stringResource(id = R.string.support), - subTitle = stringResource(id = R.string.support_description), - icon = Icons.AutoMirrored.Rounded.ArrowForwardIos, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isBoth = true - ), - isLast = true, - composableAction = { onExit -> - BottomModal( - navController = navController, - settingsViewModel = settingsViewModel - ) { onExit() } - }, - settingsViewModel = settingsViewModel - ) - } - item { - SettingCategory( - title = stringResource(id = R.string.color_styles), - subTitle = stringResource(R.string.description_color_styles), - icon = Icons.Rounded.Palette, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - action = { navController.navigate(NavRoutes.ColorStyles.route) }, - settingsViewModel = settingsViewModel - ) - } - item { - SettingCategory( - title = stringResource(R.string.dex_mode), - subTitle = stringResource(R.string.dex_mode_description), - icon = Icons.Rounded.DesktopWindows, - shape = shapeManager(radius = settingsViewModel.settings.value.cornerRadius), - action = { navController.navigate(NavRoutes.DesktopModeSettings.route) }, - settingsViewModel = settingsViewModel - ) - } - item { - SettingCategory( - title = stringResource(id = R.string.Behavior), - subTitle = stringResource(id = R.string.description_markdown), - icon = Icons.Rounded.TextFields, - shape = shapeManager(radius = settingsViewModel.settings.value.cornerRadius), - action = { navController.navigate(NavRoutes.Markdown.route) }, - settingsViewModel = settingsViewModel - ) - } - item { - SettingCategory( - title = stringResource(R.string.airnote_ai), - subTitle = stringResource(R.string.airnote_ai_description), - icon = Icons.Rounded.AutoAwesome, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius - ), - action = { navController.navigate(NavRoutes.AirNoteAiSettings.route) }, - settingsViewModel = settingsViewModel - ) - } - item { - SettingCategory( - title = stringResource(id = R.string.language), - subTitle = stringResource(R.string.description_language), - icon = Icons.Rounded.Language, - isLast = true, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - action = { navController.navigate(NavRoutes.Language.route) }, - settingsViewModel = settingsViewModel - ) - } - item { - SettingCategory( - title = stringResource(id = R.string.backup), - subTitle = stringResource(R.string.description_cloud), - icon = Icons.Rounded.Cloud, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - action = { navController.navigate(NavRoutes.Cloud.route) }, - settingsViewModel = settingsViewModel - ) - } - item { - SettingCategory( - title = stringResource(id = R.string.privacy), - subTitle = stringResource(id = R.string.screen_protection), - icon = ImageVector.vectorResource(id = R.drawable.incognito_fill), - shape = shapeManager(radius = settingsViewModel.settings.value.cornerRadius), - settingsViewModel = settingsViewModel, - action = { navController.navigate(NavRoutes.Privacy.route) } - ) - } - item { - SettingCategory( - title = stringResource(id = R.string.tools), - subTitle = stringResource(R.string.description_tools), - icon = Icons.Rounded.Work, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - isLast = true, - action = { navController.navigate(NavRoutes.Tools.route) }, - settingsViewModel = settingsViewModel - ) - } - item { - val aboutSubtitle = if (settingsViewModel.updateAvailable.value) { - stringResource(R.string.update_available_description) - } else { - stringResource(R.string.description_about) - } - SettingCategory( - title = stringResource(id = R.string.about), - subTitle = aboutSubtitle, - icon = Icons.Rounded.Info, - shape = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isBoth = true - ), - action = { navController.navigate(NavRoutes.About.route) }, - settingsViewModel = settingsViewModel - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BottomModal(navController: NavController, settingsViewModel: SettingsViewModel, onExit: () -> Unit) { - val uriHandler = LocalUriHandler.current - - ModalBottomSheet( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - onDismissRequest = { onExit() } - ) { - Column( - modifier = Modifier.padding(20.dp, 0.dp, 20.dp, 20.dp) - ) { - SettingsBox( - size = 8.dp, - title = "Buy Me A Coffee", - icon = IconResource.Vector(Icons.Rounded.Coffee), - isCentered = true, - actionType = ActionType.CUSTOM, - radius = shapeManager( - isFirst = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - customAction = { uriHandler.openUri(ConnectionConst.SUPPORT_BUYMEACOFFEE) }, - settingsViewModel = settingsViewModel - ) - SettingsBox( - title = stringResource(R.string.cryptocurrency), - size = 8.dp, - icon = IconResource.Vector(Icons.Rounded.CurrencyBitcoin), - isCentered = true, - actionType = ActionType.CUSTOM, - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - customAction = { LaunchedEffect(true) { navController.navigate(NavRoutes.Support.route) } }, - settingsViewModel = settingsViewModel - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/IconResource.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/IconResource.kt deleted file mode 100644 index 7821f87..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/IconResource.kt +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - */ - -package com.babelsoftware.airnote.presentation.screens.settings.model - -import androidx.compose.ui.graphics.vector.ImageVector - -sealed class IconResource { - data class Vector(val imageVector: ImageVector) : IconResource() - data class Url(val url: String) : IconResource() -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/SettingsModel.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/SettingsModel.kt deleted file mode 100644 index 9933661..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/SettingsModel.kt +++ /dev/null @@ -1,412 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.model - -import android.content.Context -import android.net.Uri -import android.util.Log -import android.widget.Toast -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.core.os.LocaleListCompat -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.babelsoftware.airnote.data.repository.BackupResult -import com.babelsoftware.airnote.data.repository.ImportExportRepository -import com.babelsoftware.airnote.domain.model.Settings -import com.babelsoftware.airnote.domain.usecase.ImportExportUseCase -import com.babelsoftware.airnote.domain.usecase.ImportResult -import com.babelsoftware.airnote.domain.usecase.NoteUseCase -import com.babelsoftware.airnote.domain.usecase.SettingsUseCase -import com.babelsoftware.airnote.presentation.components.GalleryObserver -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.data.repository.SecureStorageRepository -import com.babelsoftware.airnote.BuildConfig -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.provider.StringProvider -import com.babelsoftware.airnote.data.repository.GeminiRepository -import com.babelsoftware.airnote.domain.repository.SettingsRepository -import com.babelsoftware.airnote.util.checkForUpdates -import com.babelsoftware.airnote.util.isNewerVersion -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserException -import java.io.IOException -import javax.inject.Inject - - -@HiltViewModel -class SettingsViewModel @Inject constructor( - val galleryObserver: GalleryObserver, - val backup: ImportExportRepository, - private val settingsUseCase: SettingsUseCase, - val noteUseCase: NoteUseCase, - private val importExportUseCase: ImportExportUseCase, - private val secureStorageRepository: SecureStorageRepository, - private val settingsRepository: SettingsRepository, - val geminiRepository: GeminiRepository, - private val stringProvider: StringProvider -) : ViewModel() { - var defaultRoute: String? = null - - // ---> Update Check States - private val _updateAvailable = mutableStateOf(false) - val updateAvailable: State = _updateAvailable - - private val _latestVersion = mutableStateOf("") - val latestVersion: MutableState = _latestVersion - - private val _showUpdateDialog = mutableStateOf(false) - val showUpdateDialog: State = _showUpdateDialog - - // --- Gemini API Key States --- - private val _userApiKey = mutableStateOf("") - val userApiKey: State = _userApiKey - - private val _isVerifyingApiKey = mutableStateOf(false) - val isVerifyingApiKey: State = _isVerifyingApiKey - - private val _isApiKeyVerified = mutableStateOf(false) - val isApiKeyVerified: State = _isApiKeyVerified - - // --- Perplexity API Key States --- - private val _perplexityApiKey = mutableStateOf("") - val perplexityApiKey: State = _perplexityApiKey - - private val _isVerifyingPerplexityApiKey = mutableStateOf(false) - val isVerifyingPerplexityApiKey: State = _isVerifyingPerplexityApiKey - - private val _isPerplexityApiKeyVerified = mutableStateOf(false) - val isPerplexityApiKeyVerified: State = _isPerplexityApiKeyVerified - // --- - - private val _uiEvent = Channel() - val uiEvent = _uiEvent.receiveAsFlow() - private val _downloadedModels = MutableStateFlow>(emptySet()) - val downloadedModels: StateFlow> = _downloadedModels.asStateFlow() - - private val _processingLanguageCode = MutableStateFlow(null) - val processingLanguageCode: StateFlow = _processingLanguageCode.asStateFlow() - - fun fetchDownloadedModels() { - viewModelScope.launch { - geminiRepository.getDownloadedModels() - .onSuccess { _downloadedModels.value = it } - .onFailure { - Log.e("SettingsViewModel", "Failed to fetch models", it) - viewModelScope.launch { _uiEvent.send(stringProvider.getString(R.string.lang_models_fetch_error)) } - } - } - } - - fun downloadLanguageModel(languageCode: String, onResult: (Boolean, String) -> Unit) { - viewModelScope.launch { - _processingLanguageCode.value = languageCode - geminiRepository.downloadLanguageModel(languageCode) - .onSuccess { - _downloadedModels.value = _downloadedModels.value + languageCode - val languageName = geminiRepository.supportedLanguages[languageCode] ?: languageCode - onResult(true, stringProvider.getString(R.string.lang_model_download_success, languageName)) - } - .onFailure { - val languageName = geminiRepository.supportedLanguages[languageCode] ?: languageCode - onResult(false, stringProvider.getString(R.string.lang_model_download_failure, languageName)) - } - _processingLanguageCode.value = null - } - } - - fun deleteLanguageModel(languageCode: String, onResult: (Boolean, String) -> Unit) { - viewModelScope.launch { - _processingLanguageCode.value = languageCode - geminiRepository.deleteLanguageModel(languageCode) - .onSuccess { - _downloadedModels.value = _downloadedModels.value - languageCode - val languageName = geminiRepository.supportedLanguages[languageCode] ?: languageCode - onResult(true, stringProvider.getString(R.string.lang_model_delete_success, languageName)) - } - .onFailure { - val languageName = geminiRepository.supportedLanguages[languageCode] ?: languageCode - onResult(false, stringProvider.getString(R.string.lang_model_delete_failure, languageName)) - } - _processingLanguageCode.value = null - } - } - - - fun checkForNewUpdate(context: Context) { - viewModelScope.launch { - val latestVersionFromGitHub = checkForUpdates() - if (latestVersionFromGitHub != null) { - val isNewer = isNewerVersion(latestVersionFromGitHub, version) - _updateAvailable.value = isNewer - if (isNewer) { - _latestVersion.value = latestVersionFromGitHub - val sharedPreferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) - val lastNotificationTime = sharedPreferences.getLong("last_update_notification_time", 0L) - val currentTime = System.currentTimeMillis() - // --->Show update pop-up every 6 hours - if (currentTime - lastNotificationTime > 6 * 60 * 60 * 1000) { - _showUpdateDialog.value = true - sharedPreferences.edit().putLong("last_update_notification_time", currentTime).apply() - } - // <--- - } - } - } - } - - fun dismissUpdateDialog() { - _showUpdateDialog.value = false - } - // <--- - - fun loadDefaultRoute() { - if (!_settings.value.appLockEnabled) { - defaultRoute = NavRoutes.Home.route - return - } - - if (_settings.value.fingerprint == false && _settings.value.passcode == null && _settings.value.pattern == null) { - defaultRoute = NavRoutes.Home.route - } else { - defaultRoute = _settings.value.defaultRouteType - } - } - - fun updateUserApiKey(newApiKey: String) { - _userApiKey.value = newApiKey - if (_isApiKeyVerified.value) { - _isApiKeyVerified.value = false - } - } - - fun updatePerplexityApiKey(newApiKey: String) { - _perplexityApiKey.value = newApiKey - if (_isPerplexityApiKeyVerified.value) { - _isPerplexityApiKeyVerified.value = false - } - } - - private fun checkStoredApiKey() { - val storedKey = secureStorageRepository.getUserApiKey() ?: "" - _userApiKey.value = storedKey - val storedModel = settings.value.selectedModelName - - if (storedKey.isNotBlank()) { - viewModelScope.launch { - val result = geminiRepository.validateApiKey( - apiKey = storedKey, - modelName = storedModel - ) - _isApiKeyVerified.value = result.isSuccess - } - } - } - - private fun checkStoredPerplexityApiKey() { - val storedKey = secureStorageRepository.getPerplexityApiKey() ?: "" - _perplexityApiKey.value = storedKey - if (storedKey.isNotBlank()) { - viewModelScope.launch { - val result = geminiRepository.validatePerplexityApiKey(storedKey) - _isPerplexityApiKeyVerified.value = result.isSuccess - } - } - } - - fun verifyUserApiKey() { - val keyToVerify = _userApiKey.value - val modelToVerify = settings.value.selectedModelName - - if (keyToVerify.isBlank()) { - viewModelScope.launch { _uiEvent.send(stringProvider.getString(R.string.error_api_key_blank)) } - return - } - - viewModelScope.launch { - _isVerifyingApiKey.value = true - val result = geminiRepository.validateApiKey( - apiKey = keyToVerify, - modelName = modelToVerify - ) - _isApiKeyVerified.value = result.isSuccess - - val message: String - if (result.isSuccess) { - secureStorageRepository.saveUserApiKey(keyToVerify) - message = stringProvider.getString(R.string.api_key_validation_success) - } else { - message = stringProvider.getString(R.string.api_key_validation_failure) - } - _uiEvent.send(message) - _isVerifyingApiKey.value = false - } - } - - fun verifyPerplexityApiKey() { - val keyToVerify = _perplexityApiKey.value - if (keyToVerify.isBlank()) { - viewModelScope.launch { _uiEvent.send(stringProvider.getString(R.string.perplexity_api_not_empty)) } - return - } - - viewModelScope.launch { - _isVerifyingPerplexityApiKey.value = true - val result = geminiRepository.validatePerplexityApiKey(keyToVerify) - _isPerplexityApiKeyVerified.value = result.isSuccess - - val message: String - if (result.isSuccess) { - secureStorageRepository.savePerplexityApiKey(keyToVerify) - message = stringProvider.getString(R.string.api_key_validation_success) - } else { - message = result.exceptionOrNull()?.message ?: stringProvider.getString(R.string.api_key_validation_failure) - } - _uiEvent.send(message) - _isVerifyingPerplexityApiKey.value = false - } - } - - fun updateSelectedModel(modelName: String) { - update(settings.value.copy(selectedModelName = modelName)) - if (_isApiKeyVerified.value) { - _isApiKeyVerified.value = false - } - } - - fun updateSelectedPerplexityModel(modelName: String) { - update(settings.value.copy(selectedPerplexityModelName = modelName)) - if (_isPerplexityApiKeyVerified.value) { - _isPerplexityApiKeyVerified.value = false - } - } - - fun updateDefaultRoute(route: String) { - _settings.value = _settings.value.copy(defaultRouteType = route) - update(settings.value.copy(defaultRouteType = route)) - } - - val databaseUpdate = mutableStateOf(false) - var password : String? = null - - private val _settings = mutableStateOf(Settings()) - var settings: State = _settings - - private suspend fun loadSettings() { - val loadedSettings = runBlocking(Dispatchers.IO) { - settingsUseCase.loadSettingsFromRepository() - } - _settings.value = loadedSettings - if (_settings.value.fingerprint == false && _settings.value.passcode == null && _settings.value.pattern == null) { - defaultRoute = NavRoutes.Home.route - } else { - defaultRoute = loadedSettings.defaultRouteType - } - } - - fun update(newSettings: Settings) { - _settings.value = newSettings.copy() - viewModelScope.launch { - settingsUseCase.saveSettingsToRepository(newSettings) - } - } - - - fun onExportBackup(uri: Uri, context: Context) { - viewModelScope.launch { - val result = backup.exportBackup(uri, password) - handleBackupResult(result, context) - databaseUpdate.value = true - } - } - - fun onImportBackup(uri: Uri, context: Context) { - viewModelScope.launch { - val result = backup.importBackup(uri, password) - handleBackupResult(result, context) - databaseUpdate.value = true - } - } - - fun onImportFiles(uris: List, context: Context) { - viewModelScope.launch { - importExportUseCase.importNotes(uris) { result -> - handleImportResult(result, context) - } - } - } - - // Taken from: https://stackoverflow.com/questions/74114067/get-list-of-locales-from-locale-config-in-android-13 - private fun getLocaleListFromXml(context: Context): LocaleListCompat { - val tagsList = mutableListOf() - try { - val xpp: XmlPullParser = context.resources.getXml(R.xml.locales_config) - while (xpp.eventType != XmlPullParser.END_DOCUMENT) { - if (xpp.eventType == XmlPullParser.START_TAG) { - if (xpp.name == "locale") { - tagsList.add(xpp.getAttributeValue(0)) - } - } - xpp.next() - } - } catch (e: XmlPullParserException) { - e.printStackTrace() - } catch (e: IOException) { - e.printStackTrace() - } - - return LocaleListCompat.forLanguageTags(tagsList.joinToString(",")) - } - - fun getSupportedLanguages(context: Context): Map { - val localeList = getLocaleListFromXml(context) - val map = mutableMapOf() - - for (a in 0 until localeList.size()) { - localeList[a].let { - it?.let { it1 -> map.put(it1.getDisplayName(it), it.toLanguageTag()) } - } - } - return map - } - - private fun handleBackupResult(result: BackupResult, context: Context) { - when (result) { - is BackupResult.Success -> {} - is BackupResult.Error -> showToast(stringProvider.getString(R.string.error_generic), context) - BackupResult.BadPassword -> showToast(stringProvider.getString(R.string.detabase_restore_error), context) - } - } - - private fun handleImportResult(result: ImportResult, context: Context) { - when (result.successful) { - result.total -> {showToast(stringProvider.getString(R.string.file_import_success), context)} - 0 -> {showToast(stringProvider.getString(R.string.file_import_error), context)} - else -> {showToast(stringProvider.getString(R.string.file_import_partial_error), context)} - } - } - - private fun showToast(message: String, context: Context) { - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - - val version: String = BuildConfig.VERSION_NAME - val build: String = BuildConfig.BUILD_TYPE - - init { - runBlocking { - loadSettings() - } - checkStoredApiKey() - checkStoredPerplexityApiKey() - fetchDownloadedModels() - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/About.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/About.kt deleted file mode 100644 index 52c1e5d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/About.kt +++ /dev/null @@ -1,512 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.provider.Settings -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.RocketLaunch -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.BugReport -import androidx.compose.material.icons.rounded.Build -import androidx.compose.material.icons.rounded.DesktopWindows -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.Email -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Translate -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.constant.ConnectionConst -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.AppUpdateViewModel -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.model.UpdateState -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox -import com.babelsoftware.airnote.util.ReleaseInfo -import com.babelsoftware.airnote.util.getLatestReleaseInfo -import com.babelsoftware.airnote.util.translateText -import kotlinx.coroutines.launch -import java.util.Locale - - -@Composable -fun AboutScreen( - navController: NavController, - settingsViewModel: SettingsViewModel -) { - val uriHandler = LocalUriHandler.current - val cornerRadius = settingsViewModel.settings.value.cornerRadius.dp - - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.about), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth() - ) { - item { - UpdateCard(settingsViewModel = settingsViewModel) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.build_type), - description = settingsViewModel.build, - icon = IconResource.Vector(Icons.Rounded.Build), - actionType = ActionType.TEXT, - radius = RoundedCornerShape(topStart = cornerRadius, topEnd = cornerRadius) - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.version), - description = settingsViewModel.version, - icon = IconResource.Vector(Icons.Rounded.Info), - actionType = ActionType.TEXT, - radius = RoundedCornerShape(0.dp) - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.ai_version), - description = "v1.3 build v0.9.0", - icon = IconResource.Vector(Icons.Rounded.AutoAwesome), - actionType = ActionType.TEXT, - radius = RoundedCornerShape(0.dp) - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.dex_version), - description = "v0.2.0-beta", - icon = IconResource.Vector(Icons.Rounded.DesktopWindows), - actionType = ActionType.TEXT, - radius = RoundedCornerShape(0.dp) - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.developer), - description = stringResource(id = R.string.info_dev), - icon = IconResource.Url("https://avatars.githubusercontent.com/u/178022701?v=4"), - actionType = ActionType.TEXT, - radius = RoundedCornerShape(bottomStart = cornerRadius, bottomEnd = cornerRadius) - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.instagram), - icon = IconResource.Url("https://www.apkmirror.com/wp-content/themes/APKMirror/ap_resize/ap_resize.php?src=https%3A%2F%2Fdownloadr2.apkmirror.com%2Fwp-content%2Fuploads%2F2022%2F08%2F91%2F6309b6f6b92ef.png&w=96&h=96&q=100"), - linkClicked = { uriHandler.openUri("https://instagram.com/airnoteapp") }, - actionType = ActionType.LINK, - radius = RoundedCornerShape(topStart = cornerRadius, topEnd = cornerRadius, bottomStart = cornerRadius, bottomEnd = cornerRadius) - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.email), - icon = IconResource.Vector(Icons.Rounded.Email), - clipboardText = ConnectionConst.SUPPORT_MAIL, - actionType = ActionType.CLIPBOARD, - radius = RoundedCornerShape(topStart = cornerRadius, topEnd = cornerRadius) - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.source_code), - icon = IconResource.Vector(Icons.Rounded.Download), - actionType = ActionType.LINK, - radius = RoundedCornerShape(0.dp), - linkClicked = { uriHandler.openUri("https://github.com/RRechz/AirNote/") } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - size = 8.dp, - title = stringResource(id = R.string.feature), - icon = IconResource.Vector(Icons.Rounded.BugReport), - linkClicked = { uriHandler.openUri(ConnectionConst.FEATURE_REQUEST) }, - actionType = ActionType.LINK, - radius = RoundedCornerShape(bottomStart = cornerRadius, bottomEnd = cornerRadius) - ) - } - } - } -} - -@Composable -fun UpdateCard( - settingsViewModel: SettingsViewModel, - updateViewModel: AppUpdateViewModel = hiltViewModel() -) { - val context = LocalContext.current - val updateState by updateViewModel.updateState.collectAsState() - - var latestReleaseInfo by remember { mutableStateOf(null) } - var isLoading by remember { mutableStateOf(true) } - var changelogVisible by remember { mutableStateOf(false) } - - val scope = rememberCoroutineScope() - var displayedChangelog by remember { mutableStateOf("") } - var isTranslating by remember { mutableStateOf(false) } - var isOriginalChangelog by remember { mutableStateOf(true) } - - - LaunchedEffect(key1 = true) { - if (latestReleaseInfo == null) { - isLoading = true - latestReleaseInfo = getLatestReleaseInfo() - isLoading = false - } - } - - LaunchedEffect(latestReleaseInfo) { - latestReleaseInfo?.let { - val cleanOriginalChangelog = it.changelog.substringAfter("## Changelog", it.changelog).trim() - displayedChangelog = cleanOriginalChangelog - isOriginalChangelog = true - isTranslating = false - } - } - - val installPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { - if (context.canInstallUnknownApps()) { - (updateState as? UpdateState.ReadyToInstall)?.let { - installApk(context, it.apkUri) - } - } else { - Toast.makeText(context, context.getString(R.string.update_card_install_permission_needed), Toast.LENGTH_LONG).show() - } - } - - if (isLoading) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - CircularProgressIndicator() - Text( - text = stringResource(id = R.string.update_card_checking_for_updates), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - } - } else { - val isUpdateAvailable = settingsViewModel.updateAvailable.value && latestReleaseInfo != null - val isError = updateState is UpdateState.Failed - val currentCornerRadius = settingsViewModel.settings.value.cornerRadius.dp - - val updateBrush = Brush.linearGradient(colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.tertiary)) - val successBrush = Brush.linearGradient(colors = listOf(MaterialTheme.colorScheme.secondary, MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f))) - val errorBrush = Brush.linearGradient(colors = listOf(MaterialTheme.colorScheme.error, MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f))) - - val backgroundBrush = when { - isError -> errorBrush - isUpdateAvailable -> updateBrush - else -> successBrush - } - val contentColor = MaterialTheme.colorScheme.onPrimary - - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(currentCornerRadius)) - .background(backgroundBrush) - .border(BorderStroke(1.dp, contentColor.copy(alpha = 0.3f)), RoundedCornerShape(currentCornerRadius)) - .animateContentSize(animationSpec = tween(500)) - .padding(horizontal = 20.dp, vertical = 24.dp) - ) { - Column { - Row(verticalAlignment = Alignment.Top) { - Box( - modifier = Modifier - .padding(top = 4.dp) - .size(64.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center - ) { - val infiniteTransition = rememberInfiniteTransition(label = "icon animation") - val iconOffsetY by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = if (isUpdateAvailable && !changelogVisible) -6f else 0f, - animationSpec = infiniteRepeatable(animation = tween(1500), repeatMode = RepeatMode.Reverse), label = "" - ) - val icon = when { - isError -> Icons.Filled.ErrorOutline - isUpdateAvailable -> Icons.Filled.CloudDownload - else -> Icons.Filled.CheckCircle - } - Icon( - imageVector = icon, - contentDescription = "Update Status Icon", - modifier = Modifier - .size(40.dp) - .offset(y = iconOffsetY.dp), - tint = contentColor - ) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { - val updateStateType = remember(updateState) { - when (updateState) { - is UpdateState.Downloading -> "Downloading" - is UpdateState.Failed -> "Failed" - is UpdateState.Idle -> "Idle" - is UpdateState.ReadyToInstall -> "ReadyToInstall" - } - } - - Crossfade(targetState = updateStateType, animationSpec = tween(300), label = "state crossfade") { stateType -> - val currentState = updateState - Column { - if (isUpdateAvailable) { - val info = latestReleaseInfo!! - Text(text = stringResource(id = R.string.update_card_new_version_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = contentColor) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = "${settingsViewModel.version} → ${settingsViewModel.latestVersion.value}", style = MaterialTheme.typography.bodyMedium, color = contentColor.copy(alpha = 0.9f), fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.height(16.dp)) - - when (stateType) { - "Idle" -> { - Column(verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start) { - Button( - onClick = { if (info.apkDownloadUrl != null) updateViewModel.downloadAndInstallApk(info.apkDownloadUrl) else Toast.makeText(context, context.getString(R.string.update_card_download_link_not_found), Toast.LENGTH_SHORT).show() }, - colors = ButtonDefaults.buttonColors(containerColor = contentColor, contentColor = MaterialTheme.colorScheme.primary) - ) { - Icon(Icons.Default.ArrowDownward, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(id = R.string.update_card_download_now)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = { changelogVisible = !changelogVisible }, colors = ButtonDefaults.textButtonColors(contentColor = contentColor)) { - Text(if (changelogVisible) stringResource(R.string.hide) else stringResource(R.string.whats_new)) - } - if (changelogVisible) { - Spacer(Modifier.width(8.dp)) - if (isTranslating) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), color = contentColor, strokeWidth = 2.dp) - } else { - TextButton( - onClick = { - scope.launch { - isTranslating = true - val originalChangelog = latestReleaseInfo?.changelog?.substringAfter("## Changelog", latestReleaseInfo?.changelog ?: "")?.trim() ?: "" - if (isOriginalChangelog) { - val translatedLines = StringBuilder() - originalChangelog.lines().forEach { line -> - val trimmedLine = line.trim() - if (trimmedLine.startsWith("*") || trimmedLine.startsWith("-")) { - val contentToTranslate = trimmedLine.substring(1).trim() - val translatedContent = translateText(contentToTranslate, Locale.getDefault().language) - translatedLines.append("* ${translatedContent ?: contentToTranslate}\n") - } else { - translatedLines.append("$line\n") - } - } - displayedChangelog = translatedLines.toString().trim() - isOriginalChangelog = false - } else { - displayedChangelog = originalChangelog - isOriginalChangelog = true - } - isTranslating = false - } - }, - colors = ButtonDefaults.textButtonColors(contentColor = contentColor) - ) { - Icon(Icons.Rounded.Translate, contentDescription = "Translate", modifier = Modifier.size(ButtonDefaults.IconSize)) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(if (isOriginalChangelog) stringResource(R.string.translate) else stringResource(R.string.original_text)) - } - } - } - } - } - } - "Downloading" -> { - val progress = (currentState as? UpdateState.Downloading)?.progress ?: 0 - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = stringResource(id = R.string.update_card_downloading, progress), style = MaterialTheme.typography.bodyMedium, color = contentColor, modifier = Modifier.weight(1f)) - IconButton(onClick = { updateViewModel.resetState() }) { - Icon(Icons.Default.Cancel, contentDescription = "Cancel Download", tint = contentColor) - } - } - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator(progress = { if (progress > 0) progress / 100f else 0f }, modifier = Modifier.fillMaxWidth().height(8.dp).clip(CircleShape), color = contentColor, trackColor = contentColor.copy(alpha = 0.3f)) - } - } - "ReadyToInstall" -> { - val apkUri = (currentState as? UpdateState.ReadyToInstall)?.apkUri - if (apkUri != null) { - Column { - Text(text = stringResource(R.string.ready_to_setup), style = MaterialTheme.typography.bodyMedium, color = contentColor.copy(alpha = 0.9f)) - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = { - if (context.canInstallUnknownApps()) installApk(context, apkUri) - else installPermissionLauncher.launch(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).setData(Uri.parse("package:${context.packageName}"))) - }, - colors = ButtonDefaults.buttonColors(containerColor = contentColor, contentColor = MaterialTheme.colorScheme.primary) - ) { - Icon(Icons.Default.RocketLaunch, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(id = R.string.update_card_install)) - } - } - } - } - } - } else if (isError && currentState is UpdateState.Failed) { - Text(text = stringResource(id = R.string.update_card_update_failed), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = contentColor) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = currentState.error, style = MaterialTheme.typography.bodyMedium, color = contentColor.copy(alpha = 0.9f)) - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = { updateViewModel.resetState() }, - colors = ButtonDefaults.buttonColors(containerColor = contentColor, contentColor = MaterialTheme.colorScheme.error) - ) { - Icon(Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(id = R.string.update_card_try_again)) - } - } else { - Text(text = stringResource(id = R.string.update_card_app_up_to_date), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = contentColor) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = stringResource(R.string.using_latest_features), style = MaterialTheme.typography.bodyMedium, color = contentColor.copy(alpha = 0.9f)) - } - } - } - } - } - - if (changelogVisible && latestReleaseInfo != null) { - Spacer(modifier = Modifier.height(16.dp)) - ChangelogContent(changelogText = displayedChangelog, color = contentColor) - } - } - } - } -} - -@Composable -private fun ChangelogContent(changelogText: String, color: Color) { - Column { - Text(text = stringResource(R.string.whats_new_this_version), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = color) - Spacer(modifier = Modifier.height(8.dp)) - changelogText.lines().forEach { line -> - if (line.trim().startsWith("*") || line.trim().startsWith("-")) { - Row(modifier = Modifier.padding(bottom = 4.dp)) { - Text("• ", color = color.copy(alpha = 0.9f), style = MaterialTheme.typography.bodyMedium) - Text(text = line.trim().substring(1).trim(), style = MaterialTheme.typography.bodyMedium, color = color.copy(alpha = 0.9f), lineHeight = 20.sp) - } - } else if (line.isNotBlank()){ - Text(text = line, style = MaterialTheme.typography.bodyMedium, color = color.copy(alpha = 0.9f), lineHeight = 20.sp, modifier = Modifier.padding(bottom = 4.dp)) - } - } - } -} - - -private fun installApk(context: Context, uri: Uri) { - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity(intent) -} - -private fun Context.canInstallUnknownApps(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - packageManager.canRequestPackageInstalls() - } else { - true - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/AirNoteAi.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/AirNoteAi.kt deleted file mode 100644 index 038535d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/AirNoteAi.kt +++ /dev/null @@ -1,918 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import androidx.compose.runtime.getValue -import com.babelsoftware.airnote.domain.model.Settings -import android.content.ActivityNotFoundException -import android.widget.Toast -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.HelpOutline -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Delete -import androidx.compose.material.icons.rounded.Key -import androidx.compose.material.icons.rounded.Memory -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.navigation.NavController -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.data.repository.GeminiModels -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox -import kotlinx.coroutines.launch -import java.lang.IllegalArgumentException - -private data class PerplexityModelInfo(val name: String, @StringRes val displayNameResId: Int) -private object PerplexityModels { - val supportedModels = listOf( - PerplexityModelInfo( - "pplx-7b-online", - R.string.perplexity_models_pplx_7b_online - ), - PerplexityModelInfo( - "sonar-medium-online", - R.string.perplexity_models_sonar_medium_online - ) - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AirNoteAiSettingsScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() - val settings: Settings by settingsViewModel.settings - val geminiSheetState = rememberModalBottomSheetState() - var showGeminiBottomSheet by remember { mutableStateOf(false) } - val perplexitySheetState = rememberModalBottomSheetState() - var showPerplexityBottomSheet by remember { mutableStateOf(false) } - - - LaunchedEffect(key1 = true) { - settingsViewModel.uiEvent.collect { message -> - scope.launch { snackbarHostState.showSnackbar(message) } - } - } - - if (showGeminiBottomSheet) { - ModalBottomSheet(onDismissRequest = { showGeminiBottomSheet = false }, sheetState = geminiSheetState) { - ApiKeyGuide() - Spacer(modifier = Modifier.height(32.dp)) - } - } - - if (showPerplexityBottomSheet) { - ModalBottomSheet(onDismissRequest = { showPerplexityBottomSheet = false }, sheetState = perplexitySheetState) { - PerplexityApiKeyGuide() - Spacer(modifier = Modifier.height(32.dp)) - } - } - - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.airnote_ai), - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn(modifier = Modifier.padding(top = 16.dp, start = 12.dp, end = 12.dp)) { - item { - ApiKeySetting( - settingsViewModel = settingsViewModel, - onHelpClick = { showGeminiBottomSheet = true }, - shape = shapeManager( - isFirst = true, - isLast = false, - radius = settings.cornerRadius - ) - ) - } - item { - ModelChoiceSetting( - settingsViewModel = settingsViewModel, - shape = shapeManager( - isFirst = false, - isLast = true, - radius = settings.cornerRadius - ) - ) - Spacer(modifier = Modifier.height(24.dp)) - } - item { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.ai_service_perplexity), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .weight(1f) - .padding(start = 4.dp) - ) - IconButton(onClick = { showPerplexityBottomSheet = true }) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.HelpOutline, - contentDescription = "Perplexity Help" - ) - } - } - Spacer(modifier = Modifier.height(8.dp)) - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.enable_perplexity_ai), - description = if (settings.isPerplexityEnabled) stringResource(R.string.enabled) else stringResource(R.string.perplexity_ai_service_description), - icon = IconResource.Vector(Icons.Rounded.Search), - actionType = ActionType.SWITCH, - variable = settings.isPerplexityEnabled, - switchEnabled = { isEnabled -> - settingsViewModel.update(settings.copy(isPerplexityEnabled = isEnabled)) - }, - radius = if (settings.isPerplexityEnabled) { - shapeManager(isFirst = true, isLast = false, radius = settings.cornerRadius) - } else { - shapeManager(isBoth = true, radius = settings.cornerRadius) - }, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - } - } - item { - AnimatedVisibility( - visible = settings.isPerplexityEnabled, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut() - ) { - Column { - PerplexityApiKeyBox( - settingsViewModel = settingsViewModel, - shape = RoundedCornerShape(0.dp) - ) - PerplexityModelChoiceSetting( - settingsViewModel = settingsViewModel, - shape = shapeManager( - isFirst = false, - isLast = true, - radius = settings.cornerRadius - ) - ) - Spacer(modifier = Modifier.height(24.dp)) - } - } - if (!settings.isPerplexityEnabled) { - Spacer(modifier = Modifier.height(24.dp)) - } - } - - item { - Text( - text = stringResource(id = R.string.offline_translation_models), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) - ) - } - item { - OfflineTranslationSettings(settingsViewModel = settingsViewModel) - } - } - } -} - -@Composable -private fun ApiKeySetting( - settingsViewModel: SettingsViewModel, - onHelpClick: () -> Unit, - shape: RoundedCornerShape -) { - val userApiKey by settingsViewModel.userApiKey - - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.ai_service_gemini), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .weight(1f) - .padding(start = 4.dp) - ) - IconButton(onClick = onHelpClick) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.HelpOutline, - contentDescription = stringResource(R.string.api_key_guide_title) - ) - } - } - Spacer(modifier = Modifier.height(8.dp)) - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.gemini_api_key), - description = if (userApiKey.isNotBlank()) "••••••••••••••••••••" else stringResource(R.string.not_set), - icon = IconResource.Vector(Icons.Rounded.Key), - actionType = ActionType.CUSTOM, - radius = shape, - customAction = { onDismiss -> - ApiKeyPopup( - settingsViewModel = settingsViewModel, - onDismiss = onDismiss - ) - } - ) - } -} - -@Composable -private fun ModelChoiceSetting( - settingsViewModel: SettingsViewModel, - shape: RoundedCornerShape -) { - val settings = settingsViewModel.settings.value - val selectedModelInfo = GeminiModels.supportedModels.find { - it.name == settings.selectedModelName - } - val selectedModelDisplayName = if (selectedModelInfo != null) { - stringResource(id = selectedModelInfo.displayNameResId) - } else { - settings.selectedModelName - } - val descriptionText = stringResource( - R.string.model_in_use_prefix, - selectedModelDisplayName - ) - - Column { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.model_to_use), - description = descriptionText, - icon = IconResource.Vector(Icons.Rounded.Memory), - actionType = ActionType.CUSTOM, - radius = shape, - customAction = { onDismiss -> - ModelChoicePopup( - settingsViewModel = settingsViewModel, - onDismiss = onDismiss - ) - } - ) - } -} - -@Composable -private fun PerplexityModelChoiceSetting( - settingsViewModel: SettingsViewModel, - shape: RoundedCornerShape -) { - val settings = settingsViewModel.settings.value - val selectedModelInfo = PerplexityModels.supportedModels.find { - it.name == settings.selectedPerplexityModelName - } - val selectedModelDisplayName = if (selectedModelInfo != null) { - stringResource(id = selectedModelInfo.displayNameResId) - } else { - settings.selectedPerplexityModelName - } - - val descriptionText = stringResource( - R.string.model_in_use_prefix, - selectedModelDisplayName - ) - - Column { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.model_to_use), - description = descriptionText, - icon = IconResource.Vector(Icons.Rounded.Memory), - actionType = ActionType.CUSTOM, - radius = shape, - customAction = { onDismiss -> - PerplexityModelChoicePopup( - settingsViewModel = settingsViewModel, - onDismiss = onDismiss - ) - } - ) - } -} - -@Composable -private fun PerplexityApiKeyBox( - settingsViewModel: SettingsViewModel, - shape: RoundedCornerShape -) { - val perplexityApiKey by settingsViewModel.perplexityApiKey - - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.perplexity_api_key), - description = if (perplexityApiKey.isNotBlank()) "••••••••••••••••••••" else stringResource(R.string.not_set), - icon = IconResource.Vector(Icons.Rounded.Key), - actionType = ActionType.CUSTOM, - radius = shape, - customAction = { onDismiss -> - PerplexityApiKeyPopup( - settingsViewModel = settingsViewModel, - onDismiss = onDismiss - ) - } - ) -} - - -@Composable -private fun ApiKeyPopup( - settingsViewModel: SettingsViewModel, - onDismiss: () -> Unit -) { - val userApiKey by settingsViewModel.userApiKey - val isApiKeyVerified by settingsViewModel.isApiKeyVerified - val isVerifyingApiKey by settingsViewModel.isVerifyingApiKey - var tempApiKey by remember { mutableStateOf(userApiKey) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .fillMaxWidth(0.9f) - .padding(16.dp), - shape = RoundedCornerShape(32.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - tonalElevation = 4.dp - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.gemini_api_key), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - OutlinedTextField( - value = tempApiKey, - onValueChange = { tempApiKey = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.gemini_api_key)) }, - singleLine = true, - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - settingsViewModel.updateUserApiKey(tempApiKey) - settingsViewModel.verifyUserApiKey() - }, - enabled = !isVerifyingApiKey && tempApiKey.isNotBlank(), - modifier = Modifier.fillMaxWidth() - ) { - if (isVerifyingApiKey) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text(stringResource(R.string.save_and_check)) - } - } - AnimatedVisibility( - visible = isApiKeyVerified && !isVerifyingApiKey, - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut() - ) { - Row( - modifier = Modifier.padding(top = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = stringResource(R.string.successfully_verified_icon_cd), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.successfully_verified), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 4.dp) - ) - } - } - } - } - } -} - -@Composable -private fun ModelChoicePopup( - settingsViewModel: SettingsViewModel, - onDismiss: () -> Unit -) { - val settings = settingsViewModel.settings.value - val models = GeminiModels.supportedModels - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Column( - modifier = Modifier - .fillMaxWidth(0.8f) - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(32.dp) - ) - .padding(12.dp) - ) { - Text( - text = stringResource(id = R.string.model_choice), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - fontSize = 20.sp, - ) - LazyColumn( - modifier = Modifier - .padding(bottom = 12.dp) - .clip(RoundedCornerShape(32.dp)) - ) { - itemsIndexed(models) { index, model -> - val isFirst = index == 0 - val isLast = index == models.lastIndex - Surface( - modifier = Modifier - .fillMaxWidth() - .clip( - shapeManager( - isFirst = isFirst, isLast = isLast, - radius = settings.cornerRadius - ) - ) - .clickable { - settingsViewModel.updateSelectedModel(model.name) - onDismiss() - }, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 1.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = model.displayNameResId), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - RadioButton( - selected = settings.selectedModelName == model.name, - onClick = { - settingsViewModel.updateSelectedModel(model.name) - onDismiss() - } - ) - } - } - } - } - } - } -} - -@Composable -private fun PerplexityModelChoicePopup( - settingsViewModel: SettingsViewModel, - onDismiss: () -> Unit -) { - val settings = settingsViewModel.settings.value - val models = PerplexityModels.supportedModels - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Column( - modifier = Modifier - .fillMaxWidth(0.8f) - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(32.dp) - ) - .padding(12.dp) - ) { - Text( - text = stringResource(R.string.model_choice), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - fontSize = 20.sp, - ) - LazyColumn( - modifier = Modifier - .padding(bottom = 12.dp) - .clip(RoundedCornerShape(32.dp)) - ) { - itemsIndexed(models) { index, model -> - val isFirst = index == 0 - val isLast = index == models.lastIndex - Surface( - modifier = Modifier - .fillMaxWidth() - .clip( - shapeManager( - isFirst = isFirst, isLast = isLast, - radius = settings.cornerRadius - ) - ) - .clickable { - settingsViewModel.updateSelectedPerplexityModel(model.name) - onDismiss() - }, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 1.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = model.displayNameResId), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - RadioButton( - selected = settings.selectedPerplexityModelName == model.name, - onClick = { - settingsViewModel.updateSelectedPerplexityModel(model.name) - onDismiss() - } - ) - } - } - } - } - } - } -} - -@Composable -private fun PerplexityApiKeyPopup( - settingsViewModel: SettingsViewModel, - onDismiss: () -> Unit -) { - val perplexityApiKey by settingsViewModel.perplexityApiKey - val isApiKeyVerified by settingsViewModel.isPerplexityApiKeyVerified - val isVerifyingApiKey by settingsViewModel.isVerifyingPerplexityApiKey - var tempApiKey by remember { mutableStateOf(perplexityApiKey) } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .fillMaxWidth(0.9f) - .padding(16.dp), - shape = RoundedCornerShape(32.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - tonalElevation = 4.dp - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.perplexity_api_key), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp) - ) - OutlinedTextField( - value = tempApiKey, - onValueChange = { tempApiKey = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.perplexity_api_key)) }, - singleLine = true, - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - settingsViewModel.updatePerplexityApiKey(tempApiKey) - settingsViewModel.verifyPerplexityApiKey() - }, - enabled = !isVerifyingApiKey && tempApiKey.isNotBlank(), - modifier = Modifier.fillMaxWidth() - ) { - if (isVerifyingApiKey) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text(stringResource(R.string.save_and_check)) - } - } - AnimatedVisibility( - visible = isApiKeyVerified && !isVerifyingApiKey, - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut() - ) { - Row( - modifier = Modifier.padding(top = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = stringResource(R.string.successfully_verified_icon_cd), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.successfully_verified), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 4.dp) - ) - } - } - } - } - } -} - - -@Composable -private fun OfflineTranslationSettings(settingsViewModel: SettingsViewModel) { - val downloadedModels by settingsViewModel.downloadedModels.collectAsState() - val processingLanguageCode by settingsViewModel.processingLanguageCode.collectAsState() - val context = LocalContext.current - var showDeleteDialog by remember { mutableStateOf(null) } - - if (showDeleteDialog != null) { - val langCodeToDelete = showDeleteDialog!! - val langNameToDelete = settingsViewModel.geminiRepository.supportedLanguages[langCodeToDelete] ?: langCodeToDelete - - AlertDialog( - onDismissRequest = { showDeleteDialog = null }, - title = { Text(text = stringResource(R.string.delete_language_model_title, langNameToDelete)) }, - text = { Text(text = stringResource(R.string.delete_language_model_body)) }, - confirmButton = { - TextButton( - onClick = { - settingsViewModel.deleteLanguageModel(langCodeToDelete) { _, message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - showDeleteDialog = null - } - ) { - Text(stringResource(R.string.delete)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = null }) { - Text(stringResource(R.string.cancel)) - } - } - ) - } - - Column(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .fillMaxWidth() - ) { - Column { - settingsViewModel.geminiRepository.supportedLanguages.forEach { (code, name) -> - val isDownloaded = downloadedModels.contains(code) - - ListItem( - headlineContent = { Text(name) }, - supportingContent = { - Text(if (isDownloaded) stringResource(R.string.downloaded) else stringResource(R.string.not_downloaded)) - }, - trailingContent = { - if (processingLanguageCode == code) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } else { - if (isDownloaded) { - IconButton( - onClick = { showDeleteDialog = code }, - enabled = processingLanguageCode == null - ) { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = stringResource(R.string.delete_language_model_cd, name), - tint = MaterialTheme.colorScheme.error - ) - } - } else { - TextButton( - onClick = { - settingsViewModel.downloadLanguageModel(code) { _, message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - }, - enabled = processingLanguageCode == null - ) { - Text(stringResource(id = R.string.download)) - } - } - } - } - ) - } - } - } - } -} - - -@Composable -private fun ApiKeyGuide() { - val uriHandler = LocalUriHandler.current - val geminiStudioUrl = "https://aistudio.google.com/app/apikey" - val context = LocalContext.current - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp) - ) { - Text( - text = stringResource(id = R.string.api_key_guide_title), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 16.dp) - ) - LazyColumn { - item { - Text( - text = stringResource(id = R.string.api_key_guide_intro), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 24.dp) - ) - } - item { Text(stringResource(id = R.string.api_key_guide_step_1)) } - item { Spacer(modifier = Modifier.height(12.dp)) } - item { - Button( - onClick = { - try { - uriHandler.openUri(geminiStudioUrl) - } catch (e: ActivityNotFoundException) { - Toast.makeText(context, "Web tarayıcısı bulunamadı.", Toast.LENGTH_SHORT).show() - } catch (e: IllegalArgumentException) { - Toast.makeText(context, "URL açılamadı. Cihazınızda bir web tarayıcı yüklü mü?", Toast.LENGTH_LONG).show() - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(id = R.string.api_key_guide_button)) - } - } - item { Spacer(modifier = Modifier.height(12.dp)) } - - items( - listOf( - R.string.api_key_guide_step_2, - R.string.api_key_guide_step_3, - R.string.api_key_guide_step_4, - R.string.api_key_guide_step_5, - R.string.api_key_guide_step_6 - ) - ) { - Text( - stringResource(id = it), - modifier = Modifier.padding(vertical = 4.dp) - ) - } - } - } -} - -@Composable -private fun PerplexityApiKeyGuide() { - val uriHandler = LocalUriHandler.current - val perplexityApiUrl = "https://perplexity.ai/settings/api" - val context = LocalContext.current - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp) - ) { - Text( - text = stringResource(R.string.api_key_guide_title), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 16.dp) - ) - LazyColumn { - item { - Text( - text = stringResource(R.string.perplexity_api_key_guide_intro), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 24.dp) - ) - } - item { Text(stringResource(R.string.perplexity_api_key_guide_step_1)) } - item { Spacer(modifier = Modifier.height(12.dp)) } - item { - Button( - onClick = { - try { - uriHandler.openUri(perplexityApiUrl) - } catch (e: ActivityNotFoundException) { - Toast.makeText(context, "Web tarayıcısı bulunamadı.", Toast.LENGTH_SHORT).show() - } catch (e: IllegalArgumentException) { - Toast.makeText(context, "URL açılamadı. Cihazınızda bir web tarayıcı yüklü mü?", Toast.LENGTH_LONG).show() - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(stringResource(R.string.perplexity_api_key_guide_button)) - } - } - item { Spacer(modifier = Modifier.height(12.dp)) } - - items( - listOf( - R.string.perplexity_api_key_guide_step_2, - R.string.perplexity_api_key_guide_step_3, - R.string.perplexity_api_key_guide_step_4, - R.string.perplexity_api_key_guide_step_5, - R.string.perplexity_api_key_guide_step_6, - R.string.perplexity_api_key_guide_step_7 - ) - ) { - Text( - stringResource(id = it), - modifier = Modifier.padding(vertical = 4.dp) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Backup.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Backup.kt deleted file mode 100644 index 2a00f5e..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Backup.kt +++ /dev/null @@ -1,276 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import android.content.Context -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Backup -import androidx.compose.material.icons.rounded.EnhancedEncryption -import androidx.compose.material.icons.rounded.FileOpen -import androidx.compose.material.icons.rounded.ImportExport -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.navigation.NavController -import com.babelsoftware.airnote.constant.DatabaseConst -import com.babelsoftware.airnote.presentation.screens.edit.components.CustomTextField -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -@Composable -fun CloudScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - val context = LocalContext.current - - val exportBackupLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.CreateDocument("*/.zip"), - onResult = { uri -> - if (uri != null) settingsViewModel.onExportBackup(uri, context) - } - ) - val importBackupLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument(), - onResult = { uri -> - if (uri != null) settingsViewModel.onImportBackup(uri, context) - } - ) - - val importFileLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenMultipleDocuments(), - onResult = { uris -> - settingsViewModel.onImportFiles(uris, context) - } - ) - - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.backup), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.encrypt_databse), - description = stringResource(id = R.string.encrypt_databse_description), - icon = IconResource.Vector(Icons.Rounded.EnhancedEncryption), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isBoth = true - ), - variable = settingsViewModel.settings.value.encryptBackup, - actionType = ActionType.SWITCH, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - encryptBackup = it - ) - ) - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.backup), - description = stringResource(id = R.string.backup_description), - icon = IconResource.Vector(Icons.Rounded.Backup), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - actionType = ActionType.CUSTOM, - customAction = { onExit -> - if (settingsViewModel.settings.value.encryptBackup) { - PasswordPrompt( - context = context, - text = stringResource(id = R.string.backup), - settingsViewModel = settingsViewModel, - onExit = { password -> - if (password != null) { - settingsViewModel.password = password.text - } - onExit() - }, - onBackup = { - exportBackupLauncher.launch("${DatabaseConst.NOTES_DATABASE_BACKUP_NAME}-${currentDateTime()}.zip") - } - ) - } else { - LaunchedEffect(true) { - exportBackupLauncher.launch("${DatabaseConst.NOTES_DATABASE_BACKUP_NAME}-${currentDateTime()}.zip") - } - } - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.restore), - description = stringResource(id = R.string.restore_description), - icon = IconResource.Vector(Icons.Rounded.ImportExport), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - actionType = ActionType.CUSTOM, - customAction = { onExit -> - if (settingsViewModel.settings.value.encryptBackup) { - PasswordPrompt( - context = context, - text = stringResource(id = R.string.restore), - settingsViewModel = settingsViewModel, - onExit = { password -> - if (password != null) { - settingsViewModel.password = password.text - } - onExit() - }, - onBackup = { - importBackupLauncher.launch(arrayOf("application/zip")) - } - ) - } else { - LaunchedEffect(true) { - settingsViewModel.password = null - importBackupLauncher.launch(arrayOf("application/zip")) - } - } - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = context.getString(R.string.file_import_title), - description = context.getString(R.string.file_import_description), - icon = IconResource.Vector(Icons.Rounded.FileOpen), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isBoth = true - ), - actionType = ActionType.CUSTOM, - customAction = { - LaunchedEffect(true) { - importFileLauncher.launch(arrayOf("text/*")) - } - } - ) - } - } - } -} - -fun currentDateTime(): String { - val currentDateTime = LocalDateTime.now() - val formatter = DateTimeFormatter.ofPattern("MM-dd-HH-mm-ms") - val formattedDateTime = currentDateTime.format(formatter) - - return formattedDateTime -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun PasswordPrompt(context: Context, text: String, settingsViewModel: SettingsViewModel, onExit: (TextFieldValue?) -> Unit, onBackup: () -> Unit = {}) { - var password by remember { mutableStateOf(TextFieldValue("")) } - val focusManager = LocalFocusManager.current - - Dialog( - onDismissRequest = { onExit(null) }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - LazyColumn { - item { - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth(0.8f) - .fillMaxHeight(0.2f) - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = shapeManager( - isBoth = true, - radius = settingsViewModel.settings.value.cornerRadius - ) - ) - ) { - Box( - modifier = Modifier - .padding(12.dp) - .background( - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = shapeManager( - isBoth = true, - radius = settingsViewModel.settings.value.cornerRadius - ) - ) - ) { - CustomTextField( - hideContent = true, - value = password, - onValueChange = { password = it }, - placeholder = stringResource(id = R.string.password_prompt), - autofillTypes = listOf(AutofillType.Password) - ) - } - Button( - modifier = Modifier - .padding(12.dp) - .wrapContentWidth() - .align(Alignment.End), - onClick = { - if (password.text.isNotBlank()) { - focusManager.clearFocus() // Clear focus to dismiss autofill - onExit(password) - onBackup() - } else { - Toast.makeText(context, R.string.invalid_input, Toast.LENGTH_SHORT).show() - } - }, - content = { - Text(text) - } - ) - } - } - } - } -} - diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Behaviour.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Behaviour.kt deleted file mode 100644 index 4307711..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Behaviour.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import android.provider.MediaStore -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Edit -import androidx.compose.material.icons.rounded.FolderOpen -import androidx.compose.material.icons.rounded.Image -import androidx.compose.material.icons.rounded.Style -import androidx.compose.material.icons.rounded.Swipe -import androidx.compose.material.icons.rounded.Title -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.components.unregisterGalleryObserver -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox - -@Composable -fun MarkdownScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.Behavior), - onBackNavClicked = { navController.navigateUp() } - ) { - val context = LocalContext.current - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.markdown), - description = stringResource(id = R.string.markdown_description), - icon = IconResource.Vector(Icons.Rounded.Style), - actionType = ActionType.SWITCH, - radius = shapeManager( - isBoth = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - variable = settingsViewModel.settings.value.isMarkdownEnabled, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - isMarkdownEnabled = it - ) - ) - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.always_edit), - description = stringResource(id = R.string.always_edit_description), - icon = IconResource.Vector(Icons.Rounded.Edit), - actionType = ActionType.SWITCH, - radius = shapeManager( - isFirst = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - variable = settingsViewModel.settings.value.editMode, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - editMode = it - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.gallery_sync), - description = stringResource(id = R.string.gallery_sync_description), - icon = IconResource.Vector(Icons.Rounded.Image), - actionType = ActionType.SWITCH, - radius = shapeManager( - isLast = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - variable = settingsViewModel.settings.value.gallerySync, - switchEnabled = { - if (!it) { - unregisterGalleryObserver(context, settingsViewModel.galleryObserver) - } else { - context.contentResolver.registerContentObserver( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - true, - settingsViewModel.galleryObserver - ) - } - settingsViewModel.update(settingsViewModel.settings.value.copy(gallerySync = it)) - } - ) - } - - item { - Spacer(modifier = Modifier.height(18.dp)) - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.show_only_title), - description = stringResource(id = R.string.show_only_title_description), - icon = IconResource.Vector(Icons.Rounded.Title), - actionType = ActionType.SWITCH, - radius = shapeManager( - isFirst = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - variable = settingsViewModel.settings.value.showOnlyTitle, - switchEnabled = { - settingsViewModel.update(settingsViewModel.settings.value.copy(showOnlyTitle = it)) - } - ) - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.disable_swipe_edit), - description = stringResource(id = R.string.disable_swipe_edit_description), - icon = IconResource.Vector(Icons.Rounded.Swipe), - actionType = ActionType.SWITCH, - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius - ), - variable = settingsViewModel.settings.value.disableSwipeInEditMode, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - disableSwipeInEditMode = it - ) - ) - } - ) - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.open_last_folder), - description = stringResource(id = R.string.open_last_folder_description), - icon = IconResource.Vector(Icons.Rounded.FolderOpen), - actionType = ActionType.SWITCH, - radius = shapeManager( - isLast = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - variable = settingsViewModel.settings.value.openToLastUsedFolder, - switchEnabled = { isEnabled -> - settingsViewModel.update( - if (isEnabled) { - // Enable the feature. The next folder selection will be saved. - settingsViewModel.settings.value.copy(openToLastUsedFolder = true) - } else { - // When disabling the feature, clear the last used folder ID - // to ensure a clean state for the next time it's enabled. - settingsViewModel.settings.value.copy( - openToLastUsedFolder = false, - lastUsedFolderId = null - ) - } - ) - } - ) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Colors.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Colors.kt deleted file mode 100644 index 30ad2a8..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Colors.kt +++ /dev/null @@ -1,472 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Label -import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.rounded.Battery1Bar -import androidx.compose.material.icons.rounded.Colorize -import androidx.compose.material.icons.rounded.DarkMode -import androidx.compose.material.icons.rounded.DynamicFeed -import androidx.compose.material.icons.rounded.FormatSize -import androidx.compose.material.icons.rounded.GridView -import androidx.compose.material.icons.rounded.HdrAuto -import androidx.compose.material.icons.rounded.Palette -import androidx.compose.material.icons.rounded.RoundedCorner -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.ViewAgenda -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.navigation.NavController -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox - -fun shapeManager(isBoth: Boolean = false,isLast: Boolean = false,isFirst: Boolean = false,radius: Int): RoundedCornerShape { - val smallerRadius: Dp = (radius/5).dp - val defaultRadius: Dp = radius.dp - - return when { - isBoth -> RoundedCornerShape(defaultRadius) - isLast -> RoundedCornerShape(smallerRadius,smallerRadius,defaultRadius,defaultRadius) - isFirst -> RoundedCornerShape(defaultRadius,defaultRadius,smallerRadius,smallerRadius) - else -> RoundedCornerShape(smallerRadius) - } -} - -@Composable -fun ColorStylesScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.color_styles), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.minimalistic_mode), - description = stringResource(id = R.string.minimalistic_mode_description), - icon = IconResource.Vector(Icons.Rounded.DynamicFeed), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isBoth = true - ), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.minimalisticMode, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - minimalisticMode = it - ) - ) - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.system_theme), - description = stringResource(id = R.string.system_theme_description), - icon = IconResource.Vector(Icons.Rounded.HdrAuto), - actionType = ActionType.SWITCH, - radius = shapeManager( - isFirst = true, - isBoth = (!isSystemInDarkTheme() && settingsViewModel.settings.value.automaticTheme), - radius = settingsViewModel.settings.value.cornerRadius - ), - variable = settingsViewModel.settings.value.automaticTheme, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - automaticTheme = it - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.dark_theme), - description = stringResource(id = R.string.dark_theme_description), - isEnabled = !settingsViewModel.settings.value.automaticTheme, - icon = IconResource.Vector(Icons.Rounded.Palette), - radius = shapeManager(radius = settingsViewModel.settings.value.cornerRadius), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.darkTheme, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - automaticTheme = false, - darkTheme = it - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.dynamic_colors), - description = stringResource(id = R.string.dynamic_colors_description), - icon = IconResource.Vector(Icons.Rounded.Colorize), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = !(settingsViewModel.settings.value.darkTheme) - ), - isEnabled = !settingsViewModel.settings.value.automaticTheme, - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.dynamicTheme, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - automaticTheme = false, - dynamicTheme = it - ) - ) - } - ) - } - item { - val value = settingsViewModel.settings.value.amoledTheme - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.amoled_colors), - description = stringResource(id = R.string.amoled_colors_description), - icon = IconResource.Vector(Icons.Rounded.DarkMode), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = !value - ), - actionType = ActionType.SWITCH, - isEnabled = settingsViewModel.settings.value.darkTheme, - variable = value, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - amoledTheme = it - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.extreme_amoled_mode), - icon = IconResource.Vector(Icons.Rounded.Battery1Bar), - description = stringResource(id = R.string.extreme_amoled_mode_description), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.extremeAmoledMode, - isEnabled = settingsViewModel.settings.value.amoledTheme && settingsViewModel.settings.value.darkTheme, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - extremeAmoledMode = it - ) - ) - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.radius), - description = stringResource(id = R.string.radius_description), - icon = IconResource.Vector(Icons.Rounded.RoundedCorner), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - actionType = ActionType.CUSTOM, - customAction = { onExit -> - SettingsSliderDialog( - title = stringResource(id = R.string.select_radius), - valueLabelText = "${settingsViewModel.settings.value.cornerRadius} dp", - initialValue = settingsViewModel.settings.value.cornerRadius.toFloat(), - valueRange = 5f..35f, - cornerRadius = settingsViewModel.settings.value.cornerRadius, - onValueChange = { newValue -> - settingsViewModel.update( - settingsViewModel.settings.value.copy(cornerRadius = newValue.toInt()) - ) - }, - onDismiss = { onExit() } - ) { currentValue -> - @Composable - fun Example(shape: RoundedCornerShape) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp, 3.dp, 32.dp, 1.dp) - .height(62.dp) - .background( - shape = shape, - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) - ) - } - Example(shapeManager(radius = currentValue.toInt(), isFirst = true)) - Example(shapeManager(radius = currentValue.toInt())) - Example(shapeManager(radius = currentValue.toInt(), isLast = true)) - } - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = if (settingsViewModel.settings.value.viewMode) stringResource(id = R.string.grid_view) else stringResource( - id = R.string.column_view - ), - icon = if (settingsViewModel.settings.value.viewMode) IconResource.Vector(Icons.Rounded.GridView) else IconResource.Vector(Icons.Rounded.ViewAgenda), - description = stringResource(id = R.string.view_style_description), - radius = shapeManager(radius = settingsViewModel.settings.value.cornerRadius), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.viewMode, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - viewMode = it - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.folder_tag), - description = stringResource(R.string.description_folder_tag), - icon = IconResource.Vector(Icons.AutoMirrored.Rounded.Label), - radius = shapeManager(radius = settingsViewModel.settings.value.cornerRadius), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.showFolderIndicator, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - showFolderIndicator = it - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = if (settingsViewModel.settings.value.sortDescending) stringResource(id = R.string.sort_descending) else stringResource( - id = R.string.sort_ascending - ), - description = stringResource(id = R.string.sort_description), - icon = IconResource.Vector(Icons.AutoMirrored.Rounded.Sort), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.sortDescending, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - sortDescending = it - ) - ) - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.make_search_bar_longer), - description = stringResource(id = R.string.make_search_bar_longer_description), - icon = IconResource.Vector(Icons.Rounded.Search), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.makeSearchBarLonger, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - makeSearchBarLonger = it - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.font_size), - description = stringResource(id = R.string.font_size_description), - icon = IconResource.Vector(Icons.Rounded.FormatSize), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - actionType = ActionType.CUSTOM, - customAction = { onExit -> - SettingsSliderDialog( - title = stringResource(id = R.string.font_size), - valueLabelText = stringResource(id = R.string.font_size_value, settingsViewModel.settings.value.fontSize), - initialValue = settingsViewModel.settings.value.fontSize.toFloat(), - valueRange = 12f..18f, - cornerRadius = settingsViewModel.settings.value.cornerRadius, - onValueChange = { newValue -> - settingsViewModel.update( - settingsViewModel.settings.value.copy(fontSize = newValue.toInt()) - ) - }, - onDismiss = { onExit() } - ) { currentValue -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(settingsViewModel.settings.value.cornerRadius / 2) - ) - .padding(16.dp) - ) { - Text( - text = stringResource(id = R.string.font_size_preview_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - Text( - text = stringResource(id = R.string.font_size_preview_text), - fontSize = currentValue.toInt().sp, - fontWeight = FontWeight.Normal, - modifier = Modifier.padding(vertical = 8.dp) - ) - } - } - } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsSliderDialog( - title: String, - valueLabelText: String, - initialValue: Float, - valueRange: ClosedFloatingPointRange, - cornerRadius: Int, - onValueChange: (Float) -> Unit, - onDismiss: () -> Unit, - previewContent: @Composable (currentValue: Float) -> Unit -) { - var sliderPosition by remember { mutableFloatStateOf(initialValue) } - - Dialog(onDismissRequest = onDismiss) { - Column( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(cornerRadius / 3) - ) - .fillMaxWidth() - ) { - Text( - text = title, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp, bottom = 8.dp) - ) - - Text( - text = valueLabelText, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) - - previewContent(sliderPosition) - - Slider( - value = sliderPosition, - onValueChange = { - sliderPosition = it - onValueChange(it) - }, - valueRange = valueRange, - steps = (valueRange.endInclusive - valueRange.start).toInt() - 1, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - colors = SliderDefaults.colors(inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHigh) - ) - - // Onay Butonu - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp, top = 8.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(cornerRadius / 2) - ) - .clickable { onDismiss() } - .padding(horizontal = 24.dp, vertical = 12.dp) - ) { - Text( - text = stringResource(id = R.string.agree), - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Crypto.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Crypto.kt deleted file mode 100644 index e1c01e5..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Crypto.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AccountBalanceWallet -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.navigation.NavController -import com.babelsoftware.airnote.constant.SupportConst -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource - -@Composable -fun SupportScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.cryptocurrency), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = "Bitcoin (BTC)", - icon = IconResource.Vector(Icons.Rounded.AccountBalanceWallet), - actionType = ActionType.CLIPBOARD, - radius = shapeManager( - isFirst = true, - radius = settingsViewModel.settings.value.cornerRadius - ), - clipboardText = SupportConst.BITCOIN_ADDRESS - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = "Proton Wallet", - icon = IconResource.Vector(Icons.Rounded.AccountBalanceWallet), - actionType = ActionType.CLIPBOARD, - radius = shapeManager( - isLast = true, - radius = settingsViewModel.settings.value.cornerRadius), - clipboardText = SupportConst.PROTON_WALLET_ADDRESS - ) - } - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/DesktopModeSettingsScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/DesktopModeSettingsScreen.kt deleted file mode 100644 index a18b0bd..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/DesktopModeSettingsScreen.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AutoAwesome -import androidx.compose.material.icons.rounded.DesktopWindows -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.navigation.NavController -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox - -@Composable -fun DesktopModeSettingsScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.dex_mode), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.dex_mode_uı), - description = stringResource(R.string.dex_mode_uı_description), - icon = IconResource.Vector(Icons.Rounded.DesktopWindows), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isFirst = true - ), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.desktopModeEnabled, - switchEnabled = { isEnabled -> - settingsViewModel.update( - settingsViewModel.settings.value.copy( - desktopModeEnabled = isEnabled - ) - ) - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.dex_ai_features), - description = stringResource(R.string.dex_ai_features_description), - icon = IconResource.Vector(Icons.Rounded.AutoAwesome), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isLast = true - ), - actionType = ActionType.SWITCH, - isEnabled = settingsViewModel.settings.value.desktopModeEnabled, - variable = settingsViewModel.settings.value.desktopModeAiEnabled, - switchEnabled = { isEnabled -> - settingsViewModel.update( - settingsViewModel.settings.value.copy( - desktopModeAiEnabled = isEnabled - ) - ) - } - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Language.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Language.kt deleted file mode 100644 index 50d697f..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Language.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import androidx.appcompat.app.AppCompatDelegate -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.FontDownload -import androidx.compose.material.icons.rounded.Translate -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.os.LocaleListCompat -import androidx.navigation.NavController -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ListDialog -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource - -@Composable -fun LanguageScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.language), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.language), - description = stringResource(id = R.string.language_description), - icon = IconResource.Vector(Icons.Rounded.Translate), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isBoth = true - ), - actionType = ActionType.CUSTOM, - customAction = { onExit -> - OnLanguageClicked(settingsViewModel) { onExit() } - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.monospace_font), - icon = IconResource.Vector(Icons.Rounded.FontDownload), - description = stringResource(id = R.string.monospace_font_description), - radius = shapeManager( - radius = settingsViewModel.settings.value.cornerRadius, - isBoth = true - ), - actionType = ActionType.SWITCH, - variable = settingsViewModel.settings.value.useMonoSpaceFont, - switchEnabled = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - useMonoSpaceFont = it - ) - ) - } - ) - } - } - } -} - -@Composable -private fun OnLanguageClicked(settingsViewModel: SettingsViewModel, onExit: () -> Unit) { - val context = LocalContext.current - val languages = settingsViewModel.getSupportedLanguages(context).toList() - ListDialog( - text = stringResource(R.string.language), - list = languages, - settingsViewModel = settingsViewModel, - onExit = onExit, - extractDisplayData = { it }, - initialItem = Pair(context.getString(R.string.system_language), second = ""), - setting = { isFirstItem, isLastItem, displayData -> - SettingsBox( - settingsViewModel = settingsViewModel, - size = 8.dp, - title = displayData.first, - icon = IconResource.Vector(Icons.Rounded.Translate), - radius = shapeManager( - isFirst = isFirstItem, - isLast = isLastItem, - radius = settingsViewModel.settings.value.cornerRadius - ), - actionType = ActionType.RADIOBUTTON, - variable = if (displayData.second.isNotBlank()) { - AppCompatDelegate.getApplicationLocales()[0]?.language == displayData.second - } else { - AppCompatDelegate.getApplicationLocales().isEmpty - }, - switchEnabled = { - if (displayData.second.isNotBlank()) { - AppCompatDelegate.setApplicationLocales( - LocaleListCompat.forLanguageTags( - displayData.second - ) - ) - } else { - AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) - } - } - ) - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Privacy.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Privacy.kt deleted file mode 100644 index 9eb5352..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Privacy.kt +++ /dev/null @@ -1,300 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import android.widget.Toast -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Fingerprint -import androidx.compose.material.icons.filled.Grid3x3 -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Password -import androidx.compose.material.icons.filled.RemoveRedEye -import androidx.compose.material.icons.rounded.Lock -import androidx.compose.material.icons.rounded.Security -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity -import androidx.navigation.NavController -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox - -@Composable -fun PrivacyScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - val settings = settingsViewModel.settings.value - val context = LocalContext.current - - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.privacy), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.screen_protection), - description = stringResource(id = R.string.screen_protection_description), - icon = IconResource.Vector(Icons.Filled.RemoveRedEye), - radius = shapeManager(radius = settings.cornerRadius, isBoth = true), - actionType = ActionType.SWITCH, - variable = settings.screenProtection, - switchEnabled = { settingsViewModel.update(settings.copy(screenProtection = it)) } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.vault), - description = stringResource(id = R.string.vault_description), - icon = IconResource.Vector(Icons.Rounded.Security), - radius = shapeManager(radius = settings.cornerRadius, isFirst = true), - actionType = ActionType.CUSTOM, - customAction = { onDismiss -> - if (settings.vaultSettingEnabled) { - settingsViewModel.update(settings.copy(vaultSettingEnabled = false)) - onDismiss() - } else { - var password by remember { mutableStateOf("") } - var encryptionType by remember { mutableStateOf("AES-256") } - - AlertDialog( - onDismissRequest = { onDismiss() }, - title = { Text(stringResource(R.string.vault_setup)) }, - text = { - Column { - Text(stringResource(R.string.set_a_password_vault)) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.vault_password)) }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true - ) - Spacer(modifier = Modifier.height(16.dp)) - Text(stringResource(R.string.encryption_type)) - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = encryptionType == "AES-256", onClick = { encryptionType = "AES-256" }) - Text("AES-256") - } - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = encryptionType == "ChaCha20", onClick = { encryptionType = "ChaCha20" }) - Text("ChaCha20") - } - } - }, - confirmButton = { - Button(onClick = { - if (password.isNotBlank()) { - settingsViewModel.update(settings.copy( - vaultSettingEnabled = true, - vaultEnabled = true, - vaultEncryptionType = encryptionType - )) - Toast.makeText(context, context.getString(R.string.vault_created), Toast.LENGTH_SHORT).show() - onDismiss() - } else { - Toast.makeText(context, context.getString(R.string.password_not_empty), Toast.LENGTH_SHORT).show() - } - }) { Text(stringResource(R.string.activate)) } - }, - dismissButton = { - TextButton(onClick = { onDismiss() }) { Text(stringResource(R.string.cancel)) } - } - ) - } - } - ) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(R.string.note_lock_password), - description = if (settings.noteLockPassword != null) "********" else stringResource(R.string.not_set), - icon = IconResource.Vector(Icons.Rounded.Lock), - radius = shapeManager(radius = settings.cornerRadius, isLast = true), - actionType = ActionType.CUSTOM, - customAction = { onDismiss -> - var notePass by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { onDismiss() }, - title = { Text(stringResource(R.string.note_lock_password)) }, - text = { - Column { - Text(stringResource(R.string.set_a_password_individual_notes)) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = notePass, - onValueChange = { notePass = it }, - label = { Text(stringResource(R.string.note_password)) }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true - ) - } - }, - confirmButton = { - Button(onClick = { - if (notePass.isNotEmpty()) { - settingsViewModel.update(settings.copy(noteLockPassword = notePass)) - Toast.makeText(context, context.getString(R.string.passoword_updated), Toast.LENGTH_SHORT).show() - onDismiss() - } - }) { Text(stringResource(R.string.save)) } - }, - dismissButton = { - TextButton(onClick = { onDismiss() }) { Text(stringResource(R.string.cancel)) } - } - ) - } - ) - Spacer(modifier = Modifier.height(18.dp)) - } - item { - SettingsBox( - settingsViewModel = settingsViewModel, - radius = shapeManager( - radius = settings.cornerRadius, - isBoth = !settings.appLockEnabled, - isFirst = settings.appLockEnabled - ), - title = stringResource(id = R.string.app_lock), - description = if (settings.appLockEnabled) stringResource(id = R.string.app_lock_description_active) else stringResource(id = R.string.app_lock_description), - icon = IconResource.Vector(Icons.Filled.Lock), - actionType = ActionType.SWITCH, - variable = settings.appLockEnabled, - switchEnabled = { enabled -> - settingsViewModel.update(settings.copy(appLockEnabled = enabled)) - if (!enabled) settingsViewModel.defaultRoute = NavRoutes.Home.route - } - ) - } - item { - AnimatedVisibility( - visible = settings.appLockEnabled, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.passcode), - description = if (!settings.passcode.isNullOrBlank()) stringResource(id = R.string.active) else stringResource(R.string.tap_to_setup), - icon = IconResource.Vector(Icons.Filled.Password), - radius = shapeManager(radius = settings.cornerRadius), - actionType = ActionType.CUSTOM, - customAction = { onDismiss -> - var pass by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { onDismiss() }, - title = { Text(stringResource(R.string.applock_password)) }, - text = { - OutlinedTextField( - value = pass, - onValueChange = { pass = it }, - label = { Text(stringResource(R.string.new_password)) }, - visualTransformation = PasswordVisualTransformation(), - singleLine = true - ) - }, - confirmButton = { - Button(onClick = { - if (pass.length >= 4) { - settingsViewModel.update(settings.copy(passcode = pass)) - settingsViewModel.updateDefaultRoute(NavRoutes.LockScreen.createRoute(null)) - Toast.makeText(context, context.getString(R.string.password_set), Toast.LENGTH_SHORT).show() - onDismiss() - } else { - Toast.makeText(context, context.getString(R.string.enter_least_4), Toast.LENGTH_SHORT).show() - } - }) { Text(stringResource(R.string.save)) } - }, - dismissButton = { - TextButton(onClick = { onDismiss() }) { Text(stringResource(R.string.cancel)) } - } - ) - } - ) - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.fingerprint), - description = if (settings.fingerprint) stringResource(id = R.string.active) else stringResource(R.string.tap_to_activate), - icon = IconResource.Vector(Icons.Filled.Fingerprint), - radius = shapeManager(radius = settings.cornerRadius), - actionType = ActionType.SWITCH, - variable = settings.fingerprint, - switchEnabled = { isChecked -> - if (isChecked) { - checkBiometricSupport(context) { - settingsViewModel.update(settings.copy(fingerprint = true)) - Toast.makeText(context, context.getString(R.string. fingerprint_activate), Toast.LENGTH_SHORT).show() - } - } else { - settingsViewModel.update(settings.copy(fingerprint = false)) - } - } - ) - } - } - } - } - } -} - -private fun checkBiometricSupport(context: android.content.Context, onSuccess: () -> Unit) { - val biometricManager = BiometricManager.from(context) - if (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) { - val activity = context as? FragmentActivity ?: return - val executor = ContextCompat.getMainExecutor(context) - val callback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onSuccess() - } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - Toast.makeText(context, "Error: $errString", Toast.LENGTH_SHORT).show() - } - } - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(context.getString(R.string.fingerprint_verification)) - .setSubtitle(context.getString(R.string.verify_to_enable)) - .setNegativeButtonText(context.getString(R.string.cancel)) - .build() - BiometricPrompt(activity, executor, callback).authenticate(promptInfo) - } else { - Toast.makeText(context, context.getString(R.string.not_support_fingerprint), Toast.LENGTH_LONG).show() - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Tools.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Tools.kt deleted file mode 100644 index 396a0ac..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/Tools.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings - -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Build -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.navigation.NavController -import com.babelsoftware.airnote.presentation.screens.settings.SettingsScaffold -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.widgets.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.widgets.SettingsBox -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource - - -@Composable -fun ToolsScreen(navController: NavController, settingsViewModel: SettingsViewModel) { - settingsViewModel.noteUseCase.observe() - SettingsScaffold( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.tools), - onBackNavClicked = { navController.navigateUp() } - ) { - LazyColumn { - item { - SettingsBox( - settingsViewModel = settingsViewModel, - title = stringResource(id = R.string.notes), - description = settingsViewModel.noteUseCase.notes.size.toString(), - icon = IconResource.Vector(Icons.Rounded.Build), - actionType = ActionType.TEXT, - radius = shapeManager( - isBoth = true, - radius = settingsViewModel.settings.value.cornerRadius - ) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/LockScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/LockScreen.kt deleted file mode 100644 index 88c5d89..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/LockScreen.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (C) 2024 Vexzure - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -package com.babelsoftware.airnote.presentation.screens.settings.settings.lock - -import androidx.compose.runtime.* -import androidx.navigation.NavController -import com.babelsoftware.airnote.presentation.components.NotesScaffold -import com.babelsoftware.airnote.presentation.navigation.ActionType -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.lock.components.FingerprintLock -import com.babelsoftware.airnote.presentation.screens.settings.settings.lock.components.PasscodeLock -import com.babelsoftware.airnote.presentation.screens.settings.settings.lock.components.PatternLock - -@Composable -fun LockScreen( - settingsViewModel: SettingsViewModel, - navController: NavController, - action: ActionType?, -) { - NotesScaffold { - if (action != null) { - when (action) { - ActionType.PASSCODE -> PasscodeLock(settingsViewModel, navController) - ActionType.FINGERPRINT -> FingerprintLock(settingsViewModel = settingsViewModel, navController = navController) - ActionType.PATTERN -> PatternLock(settingsViewModel = settingsViewModel, navController = navController) - } - } else { - when { - settingsViewModel.settings.value.pattern != null -> PatternLock(settingsViewModel = settingsViewModel, navController = navController) - settingsViewModel.settings.value.fingerprint -> FingerprintLock(settingsViewModel = settingsViewModel, navController = navController) - settingsViewModel.settings.value.passcode != null -> PasscodeLock(settingsViewModel, navController) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Fingerprint.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Fingerprint.kt deleted file mode 100644 index 292dbcd..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Fingerprint.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings.lock.components - -import android.content.Context -import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler -import androidx.appcompat.app.AppCompatActivity -import androidx.biometric.BiometricPrompt -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Fingerprint -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat -import androidx.navigation.NavController -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.presentation.popUpToTop -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.R -import java.util.concurrent.Executor - - - -@Composable -fun FingerprintLock( - settingsViewModel: SettingsViewModel, - navController: NavController -) { - val context = LocalContext.current - - BackHandler { - if (settingsViewModel.settings.value.fingerprint) { - (context as? ComponentActivity)?.finish() - } else { - navController.navigateUp() - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background.copy(alpha = 0.1f)), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Rounded.Fingerprint, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.scale(2f) - ) - LaunchedEffect(Unit) { - customizedPrompt(context, settingsViewModel, navController) - } - } -} - -fun customizedPrompt( - context: Context, - settingsViewModel: SettingsViewModel, - navController: NavController -) { - showBiometricPrompt( - context, - context as AppCompatActivity, - onAuthError = { - customizedPrompt(context, settingsViewModel, navController) - }, - onAuthSuccess = { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - passcode = null, - fingerprint = true, - pattern = null, - defaultRouteType = NavRoutes.LockScreen.route - ) - ) - settingsViewModel.updateDefaultRoute(NavRoutes.LockScreen.createRoute(null),) - navController.navigate(NavRoutes.Home.route) { popUpToTop(navController) } - } - ) -} - -fun showBiometricPrompt( - context: Context, - activity: AppCompatActivity, - onAuthSuccess: () -> Unit, - onAuthError: (String) -> Unit -) { - val executor: Executor = ContextCompat.getMainExecutor(activity) - - val biometricPrompt = BiometricPrompt( - activity, - executor, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - onAuthSuccess() - } - - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - onAuthError(errString.toString()) - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - onAuthError("Authentication failed. Please try again.") - } - }) - - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(context.getString(R.string.fingerprint_name)) - .setSubtitle(context.getString(R.string.app_name)) - .setNegativeButtonText(context.getString(R.string.cancel)) - .setConfirmationRequired(true) - .build() - - biometricPrompt.authenticate(promptInfo) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Passcode.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Passcode.kt deleted file mode 100644 index 352ad1c..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Passcode.kt +++ /dev/null @@ -1,277 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings.lock.components - -import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Backspace -import androidx.compose.material.icons.rounded.Fingerprint -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController -import com.babelsoftware.airnote.presentation.screens.settings.settings.lock.viewModel.LockScreenViewModel -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.presentation.popUpToTop -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel - -@Composable -fun PasscodeLock( - settingsViewModel: SettingsViewModel, - navController: NavController, - passcodeLockViewModel: LockScreenViewModel = viewModel(), -) { - val context = LocalContext.current - - BackHandler { - if (settingsViewModel.settings.value.passcode != null) { - (context as? ComponentActivity)?.finish() - } else { - navController.navigateUp() - } - } - - Column( - modifier = Modifier - .padding(horizontal = 32.dp) - .fillMaxSize(), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.SpaceBetween - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.fillMaxHeight(0.2f)) - - val text = if (settingsViewModel.settings.value.passcode.isNullOrBlank()) stringResource(id = R.string.setup_password) else stringResource( - id = R.string.enter_password - ) - - TitleText(text = text) - - PinCodeDisplay( - pinCode = passcodeLockViewModel.pinCode.value, - isPinIncorrect = passcodeLockViewModel.isPinIncorrect.value, - animateError = passcodeLockViewModel.animateError.value - ) - } - - Column { - NumberPad( - numbers = (1..9).toList(), - onNumberClick = { number -> - passcodeLockViewModel.addNumber(settingsViewModel, number) { result -> - if (result) { - navController.navigate(NavRoutes.Home.route) { popUpToTop(navController) } - } - } - } - ) - Spacer(modifier = Modifier.height(24.dp)) - - ActionRow( - onFingerprintClick = { - passcodeLockViewModel.onReset() - }, - onZeroClick = { - passcodeLockViewModel.addNumber(settingsViewModel, 0) { result -> - if (result) { - navController.navigate(NavRoutes.Home.route) { popUpToTop(navController) } - } - } - }, - onBackspaceClick = { - passcodeLockViewModel.removeNumber() - } - ) - - Spacer(modifier = Modifier.height(48.dp)) - } - } -} - -@Composable -fun PinCodeDisplay(pinCode: List, isPinIncorrect: Boolean, animateError: Boolean) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - repeat(6) { index -> - Box( - modifier = Modifier - .size(55.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - .border( - width = 2.dp, - color = getBorderColor(index < pinCode.size, isPinIncorrect, animateError), - shape = RoundedCornerShape(8.dp) - ), - contentAlignment = Alignment.Center - ) { - if (index < pinCode.size) { - PinCodeDot() - } - } - } - } -} - -@Composable -fun PinCodeDot() { - Box( - modifier = Modifier - .size(15.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - ) -} - -@Composable -fun getBorderColor(isFilled: Boolean, isPinIncorrect: Boolean, animateError: Boolean): Color { - val targetColor = if (isPinIncorrect && animateError) MaterialTheme.colorScheme.error else Color.Transparent - return if (isFilled) { - animateColorAsState( - targetValue = targetColor, - animationSpec = tween(durationMillis = 600), label = "" - ).value - } else { - Color.Transparent - } -} - -@Composable -fun ActionRow( - onFingerprintClick: () -> Unit, - onZeroClick: () -> Unit, - onBackspaceClick: () -> Unit -) { - Row( - modifier = Modifier - .padding(horizontal = 48.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Icon( - imageVector = Icons.Rounded.Fingerprint, - contentDescription = "Fingerprint", - modifier = Modifier - .scale(2f) - .clickable(onClick = onFingerprintClick), - tint = MaterialTheme.colorScheme.primary - ) - NumberButton( - number = 0, - ) { - onZeroClick() - } - Icon( - imageVector = Icons.Rounded.Backspace, - contentDescription = "Backspace", - modifier = Modifier - .scale(1.5f) - .clickable(onClick = onBackspaceClick), - tint = MaterialTheme.colorScheme.outline - ) - } -} - -@Composable -fun NumberPad( - numbers: List, - onNumberClick: (Int) -> Unit -) { - Column { - numbers.chunked(3).forEach { rowNumbers -> - NumberRow( - rowNumbers = rowNumbers, - onNumberClick = onNumberClick - ) - } - } -} - -@Composable -fun NumberRow( - rowNumbers: List, - onNumberClick: (Int) -> Unit -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - rowNumbers.forEach { number -> - NumberButton( - modifier = Modifier - .height(84.dp) - .weight(1f), - number = number, - onClick = onNumberClick - ) - } - if (rowNumbers.size < 3) { - repeat(3 - rowNumbers.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } - } -} - -@Composable -fun NumberButton( - modifier: Modifier = Modifier, - number: Int, - onClick: (Int) -> Unit -) { - val interactionSource = remember { MutableInteractionSource() } - - Box( - modifier = modifier - .clip(RoundedCornerShape(50)) - .clickable( - interactionSource = interactionSource, - indication = null - ) { onClick(number) }, - contentAlignment = Alignment.Center - ) { - Text( - text = number.toString(), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - fontSize = 28.sp - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Pattern.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Pattern.kt deleted file mode 100644 index 04a05bb..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/Pattern.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings.lock.components - -import android.view.MotionEvent -import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.input.pointer.pointerInteropFilter -import androidx.compose.ui.unit.dp -import com.babelsoftware.airnote.presentation.screens.settings.settings.lock.viewModel.LockScreenViewModel -import kotlin.math.sqrt -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavController -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.presentation.popUpToTop -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.R - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun PatternLock( - settingsViewModel: SettingsViewModel, - lockScreenViewModel: LockScreenViewModel = viewModel(), - navController: NavController -) { - val context = LocalContext.current - - BackHandler { - if (settingsViewModel.settings.value.pattern != null) { - (context as? ComponentActivity)?.finish() - } else { - navController.navigateUp() - } - } - - val rowCount = 3 - val columnCount = 3 - - val dotColor = MaterialTheme.colorScheme.primary - val pathColor = MaterialTheme.colorScheme.primary - - Column( - modifier = Modifier - .padding(vertical = 64.dp) - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally - ) { - val text = when { - settingsViewModel.settings.value.pattern == null -> stringResource(id = R.string.setup_pattern) - settingsViewModel.settings.value.pattern != null -> stringResource(id = R.string.lock_pattern) - else -> "Unknown action" - } - - TitleText(text = text) - - Canvas( - modifier = Modifier - .width(300.dp) - .height(300.dp) - .background(MaterialTheme.colorScheme.background) - .onSizeChanged { size -> - lockScreenViewModel.canvasSize = Size(size.width.toFloat(), size.height.toFloat()) - } - .pointerInteropFilter { - when (it.action) { - MotionEvent.ACTION_DOWN -> { - lockScreenViewModel.canvasSize - .takeIf { it != Size.Zero } - ?.let { size -> - val cell = getNearestCell(Offset(it.x, it.y), size.width, size.height) - if (cell != null) { - lockScreenViewModel.clearPattern() - lockScreenViewModel.selectedCellsIndexList.add(cell.index) - lockScreenViewModel.selectedCellCenterList.add(cell.center) - lockScreenViewModel.lastCellCenter = cell.center - lockScreenViewModel.path = Path().apply { moveTo(cell.center.x, cell.center.y) } - } - } - } - - MotionEvent.ACTION_MOVE -> { - val touchOffset = Offset(it.x, it.y) - lockScreenViewModel.currentTouchOffset = touchOffset - - lockScreenViewModel.canvasSize - .takeIf { it != Size.Zero } - ?.let { size -> - val cell = getNearestCell(touchOffset, size.width, size.height) - - if (cell != null && cell.index !in lockScreenViewModel.selectedCellsIndexList) { - lockScreenViewModel.selectedCellsIndexList.add(cell.index) - lockScreenViewModel.selectedCellCenterList.add(cell.center) - - lockScreenViewModel.updatePath(cell.center) - } else if (cell == null) { - // Prevent invalid lines from being drawn by skipping path updates - lockScreenViewModel.currentTouchOffset = null - } - } - } - - - MotionEvent.ACTION_UP -> { - println(settingsViewModel.settings.value.pattern) - if (settingsViewModel.settings.value.pattern == null) { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - passcode = null, - pattern = lockScreenViewModel.selectedCellsIndexList.joinToString(""), - fingerprint = false - ) - ) - settingsViewModel.updateDefaultRoute(NavRoutes.LockScreen.createRoute(null)) - } else { - if (settingsViewModel.settings.value.pattern == lockScreenViewModel.selectedCellsIndexList.joinToString("")) { - navController.navigate(NavRoutes.Home.route) { popUpToTop(navController) } - } - } - lockScreenViewModel.clearPattern() - } - } - true - } - ) { - val width = size.width - val height = size.height - - val boxSizeInX = width / columnCount - val boxCenterInX = boxSizeInX / 2 - val boxSizeInY = height / rowCount - val boxCenterInY = boxSizeInY / 2 - - for (row in 0 until rowCount) { - for (column in 0 until columnCount) { - drawCircle( - color = dotColor, - radius = 25f, - center = Offset( - (boxCenterInX + boxSizeInX * column), - (boxCenterInY + boxSizeInY * row) - ) - ) - } - } - - drawPath( - path = lockScreenViewModel.path, - color = pathColor, - style = Stroke( - width = 20f, - cap = StrokeCap.Round - ) - ) - - lockScreenViewModel.currentTouchOffset?.let { offset -> - lockScreenViewModel.lastCellCenter?.let { lastCenter -> - val connectingPath = Path().apply { - moveTo(lastCenter.x, lastCenter.y) - lineTo(offset.x, offset.y) - } - drawPath( - path = connectingPath, - color = pathColor, - style = Stroke( - width = 20f, - cap = StrokeCap.Round - ) - ) - } - } - } - } -} - - -data class CellModel( - val index: Int, - val center: Offset -) - -private fun getNearestCell(offset: Offset, width: Float, height: Float): CellModel? { - val rowCount = 3 - val columnCount = 3 - - val boxSizeInX = width / columnCount - val boxCenterInX = boxSizeInX / 2 - val boxSizeInY = height / rowCount - val boxCenterInY = boxSizeInY / 2 - - val circleRadius = width / 8 - - for (row in 0 until rowCount) { - for (column in 0 until columnCount) { - val cellCenter = Offset( - (boxCenterInX + boxSizeInX * column), - (boxCenterInY + boxSizeInY * row) - ) - val distanceFromCenter = sqrt( - (offset.x - cellCenter.x) * (offset.x - cellCenter.x) + - (offset.y - cellCenter.y) * (offset.y - cellCenter.y) - ) - - if (distanceFromCenter < circleRadius) { - val index = column + 1 + row * columnCount - return CellModel(index, cellCenter) - } - } - } - return null -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/TitleText.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/TitleText.kt deleted file mode 100644 index 5ac075e..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/components/TitleText.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2024 Vexzure - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ - -package com.babelsoftware.airnote.presentation.screens.settings.settings.lock.components - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp - - -@Composable -fun TitleText(text: String) { - Text( - text = text, - modifier = Modifier.padding(16.dp), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/viewModel/LockScreenViewModel.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/viewModel/LockScreenViewModel.kt deleted file mode 100644 index e008228..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/settings/lock/viewModel/LockScreenViewModel.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.settings.lock.viewModel - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Path -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.babelsoftware.airnote.presentation.navigation.NavRoutes -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -class LockScreenViewModel : ViewModel() { - val selectedCellsIndexList = mutableStateListOf() - val selectedCellCenterList = mutableStateListOf() - var canvasSize by mutableStateOf(Size.Zero) - var currentTouchOffset by mutableStateOf(null) - var lastCellCenter by mutableStateOf(null) - var path by mutableStateOf(Path()) - - private val _pinCode : MutableState> = mutableStateOf(emptyList()) - val pinCode: State> = _pinCode - - private val _isPinIncorrect = mutableStateOf(false) - val isPinIncorrect: State = _isPinIncorrect - - private val _animateError = mutableStateOf(false) - val animateError: State = _animateError - - fun updatePath(cellCenter: Offset) { - lastCellCenter?.let { - path.lineTo(it.x, it.y) - } - lastCellCenter = cellCenter - path.lineTo(cellCenter.x, cellCenter.y) - } - - - fun clearPattern() { - selectedCellsIndexList.clear() - selectedCellCenterList.clear() - path = Path() - currentTouchOffset = null - lastCellCenter = null - } - - fun addNumber(settingsViewModel: SettingsViewModel, number: Int, onResult: (Boolean) -> Unit) { - viewModelScope.launch { - if (_pinCode.value.size < 6) { - _pinCode.value += number - if (pinCode.value.size == 6) { - onResult(checkPinCode(settingsViewModel)) - } - } - } - } - - fun removeNumber() { - viewModelScope.launch { - if (_pinCode.value.isNotEmpty()) { - _pinCode.value = _pinCode.value.dropLast(1) - } - } - } - - private fun checkPinCode(settingsViewModel: SettingsViewModel): Boolean { - if (settingsViewModel.settings.value.passcode == null) { - settingsViewModel.update( - settingsViewModel.settings.value.copy( - passcode = _pinCode.value.joinToString(""), - fingerprint = false, - pattern = null - ) - ) - settingsViewModel.updateDefaultRoute(NavRoutes.LockScreen.createRoute(null),) - - return true - } else { - if (_pinCode.value.joinToString("") == settingsViewModel.settings.value.passcode) { - return true - } else { - _isPinIncorrect.value = true - _animateError.value = true - viewModelScope.launch { - delay(500) - _animateError.value = false - onReset() - } - return false - } - } - } - - fun onReset() { - _pinCode.value = emptyList() - _isPinIncorrect.value = false - _animateError.value = false - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/CircleWrapper.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/CircleWrapper.kt deleted file mode 100644 index 70ee659..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/CircleWrapper.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.widgets - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -@Composable -fun CircleWrapper( - color: Color = MaterialTheme.colorScheme.background, - size: Dp = 8.dp, - content: @Composable () -> Unit -) { - Box( - modifier = Modifier - .background( - color = color, - shape = RoundedCornerShape(50) - ) - .padding(size), - ) { - content() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/MaterialText.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/MaterialText.kt deleted file mode 100644 index 342c2cf..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/MaterialText.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.widgets - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.theme.FontUtils - -@Composable -fun MaterialText( - title: String, - description: String? = null, - modifier: Modifier = Modifier, - titleSize: TextUnit = 14.sp, - descriptionSize: TextUnit = 11.sp, - center: Boolean = false, - titleColor: Color = MaterialTheme.colorScheme.onSurface, - descriptionColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - settingsViewModel: SettingsViewModel? = null -) { - Column( - modifier = modifier, - horizontalAlignment = if (center) Alignment.CenterHorizontally else Alignment.Start,) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy( - fontSize = settingsViewModel?.let { - FontUtils.getFontSize(it, baseSize = titleSize.value.toInt()) - } ?: titleSize - ), - color = titleColor, - modifier = Modifier.padding(bottom = 3.dp), - textAlign = if (center) TextAlign.Center else TextAlign.Start - ) - if (description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = settingsViewModel?.let { - FontUtils.getFontSize(it, baseSize = descriptionSize.value.toInt()) - } ?: descriptionSize - ), - color = descriptionColor, - modifier = Modifier.padding(bottom = 3.dp), - maxLines = 6, - overflow = TextOverflow.Ellipsis - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingCategory.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingCategory.kt deleted file mode 100644 index 9d4537c..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingCategory.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.widgets - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.theme.FontUtils - -@Composable -fun SettingCategory( - title: String, - subTitle: String = "", - icon: ImageVector, - shape: RoundedCornerShape, - isLast: Boolean = false, - smallSetting: Boolean = false, - action: () -> Unit = {}, - composableAction: @Composable (() -> Unit) -> Unit = {}, - settingsViewModel: SettingsViewModel? = null -) { - var showCustomAction by remember { mutableStateOf(false) } - if (showCustomAction) composableAction { showCustomAction = !showCustomAction } - - ElevatedCard( - shape = shape, - modifier = Modifier - .clip(shape) - .clickable { - showCustomAction = showCustomAction.not() - action() - }, - colors = CardDefaults.elevatedCardColors( - containerColor = if (smallSetting) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.surfaceContainer // Varsayılan yerine belirli bir renk - } - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), // Gölgeyi 1.dp'ye düşürdük - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clip(shape) - .fillMaxSize() - .padding( - 24.dp, - if (smallSetting) 11.dp else 16.dp, - 14.dp, - if (smallSetting) 11.dp else 16.dp - ), - ) { - if (smallSetting) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - RenderCategoryTitle(title = title, settingsViewModel = settingsViewModel) - Spacer(modifier = Modifier.weight(1f)) - RenderCategoryDescription(subTitle = subTitle, smallSetting = true, settingsViewModel = settingsViewModel) - RenderCategoryIcon(icon, true) - } - } else { - Column { - RenderCategoryTitle(title = title, settingsViewModel = settingsViewModel) - RenderCategoryDescription(subTitle = subTitle, smallSetting = false, settingsViewModel = settingsViewModel) - } - Spacer(modifier = Modifier.weight(1f)) - RenderCategoryIcon(icon, false) - } - } - } - Spacer(modifier = Modifier.height(if (isLast) 26.dp else 2.dp)) -} - -@Composable -private fun RenderCategoryTitle(title: String, settingsViewModel: SettingsViewModel? = null) { - Text( - text = title, - fontSize = settingsViewModel?.let { - FontUtils.getFontSize(it, baseSize = 16) - } ?: 16.sp, - fontWeight = FontWeight.Bold - ) -} - -@Composable -private fun RenderCategoryDescription(subTitle: String, smallSetting: Boolean, settingsViewModel: SettingsViewModel? = null) { - if (subTitle.isNotBlank()) { - Text( - color = if (smallSetting) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.primary, - text = subTitle, - fontSize = settingsViewModel?.let { - FontUtils.getFontSize(it, baseSize = if (smallSetting) 13 else 10) - } ?: if (smallSetting) 13.sp else 10.sp, - modifier = Modifier.padding(if (smallSetting) 7.dp else 0.dp) - ) - } -} - -@Composable -private fun RenderCategoryIcon(icon: ImageVector, reverseColors: Boolean) { - Box( - modifier = Modifier - .background( - color = if (reverseColors) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(50) - ), - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = if (reverseColors) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier - .scale(if (reverseColors) 0.8f else 1f) - .padding(if (reverseColors) 9.dp else 9.dp) - ) - } -} - diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsBox.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsBox.kt deleted file mode 100644 index fb2979d..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsBox.kt +++ /dev/null @@ -1,288 +0,0 @@ -/* - * Copyright (C) 2025 Babel Software - * ... -*/ - -package com.babelsoftware.airnote.presentation.screens.settings.widgets - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos -import androidx.compose.material.icons.automirrored.rounded.OpenInNew -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.rememberAsyncImagePainter -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.presentation.screens.settings.model.IconResource -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.theme.FontUtils - -enum class ActionType { - RADIOBUTTON, - SWITCH, - LINK, - TEXT, - CUSTOM, - CLIPBOARD, - NAVIGATION -} - -@Composable -fun SettingsBox( - radius: RoundedCornerShape? = null, - title: String, - description: String? = null, - icon: IconResource, - size: Dp = 12.dp, - isEnabled: Boolean = true, - isCentered: Boolean = false, - actionType: ActionType, - variable: Boolean? = null, - switchEnabled: (Boolean) -> Unit = {}, - linkClicked: () -> Unit = {}, - customButton: @Composable () -> Unit = { RenderCustomIcon() }, - customAction: @Composable (() -> Unit) -> Unit = {}, - customText: String = "", - clipboardText: String = "", - settingsViewModel: SettingsViewModel? = null, - containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, -) { - val context = LocalContext.current - var showCustomAction by remember { mutableStateOf(false) } - if (showCustomAction) customAction { showCustomAction = !showCustomAction } - - AnimatedVisibility(visible = isEnabled) { - Surface( - modifier = Modifier - .padding(bottom = dimensionResource(id = R.dimen.card_padding_bottom)) - .clip(radius ?: RoundedCornerShape(13.dp)) - .clickable { - handleAction( - context, - actionType, - variable, - switchEnabled, - { showCustomAction = !showCustomAction }, - linkClicked, - clipboardText - ) - }, - color = containerColor, - tonalElevation = 1.dp - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding( - horizontal = dimensionResource(id = R.dimen.card_padding_horizontal), - vertical = size - ) - .fillMaxWidth() - ) { - Row( - Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - CircleWrapper( - size = 12.dp, - color = containerColor - ) { - when (icon) { - is IconResource.Vector -> { - Icon( - imageVector = icon.imageVector, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - is IconResource.Url -> { - Image( - painter = rememberAsyncImagePainter(model = icon.url), - contentDescription = title, - modifier = Modifier - .size(24.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop - ) - } - } - } - Spacer(modifier = Modifier.width(8.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall - ) - if (!description.isNullOrBlank()) { - Text( - text = description, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - } - } - RenderActionComponent( - actionType, - variable, - switchEnabled, - linkClicked, - customText, - customButton, - settingsViewModel - ) - } - } - } -} - -private fun handleAction( - context: Context, - actionType: ActionType, - variable: Boolean?, - onSwitchEnabled: (Boolean) -> Unit, - customAction: () -> Unit, - onLinkClicked: () -> Unit, - clipboardText: String -) { - when (actionType) { - ActionType.RADIOBUTTON -> onSwitchEnabled(variable == false) - ActionType.SWITCH -> onSwitchEnabled(variable == false) - ActionType.LINK -> onLinkClicked() - ActionType.NAVIGATION -> onLinkClicked() - ActionType.CUSTOM -> customAction() - ActionType.CLIPBOARD -> copyToClipboard(context, clipboardText) - ActionType.TEXT -> { /* No action needed */ } - } -} - -@Composable -private fun RenderActionComponent( - actionType: ActionType, - variable: Boolean?, - onSwitchEnabled: (Boolean) -> Unit, - onLinkClicked: () -> Unit, - customText: String, - customButton: @Composable () -> Unit, - settingsViewModel: SettingsViewModel? = null -) { - when (actionType) { - ActionType.RADIOBUTTON -> RenderRadioButton(variable, onSwitchEnabled) - ActionType.SWITCH -> RenderSwitch(variable, onSwitchEnabled) - ActionType.LINK -> RenderLinkIcon(onLinkClicked) - ActionType.NAVIGATION -> RenderCustomIcon() - ActionType.TEXT -> RenderText(customText, settingsViewModel) - ActionType.CLIPBOARD -> RenderClipboardIcon() - ActionType.CUSTOM -> customButton() - } -} - -@Composable -private fun RenderRadioButton(variable: Boolean?, onSwitchEnabled: (Boolean) -> Unit) { - RadioButton(selected = variable == true, onClick = { onSwitchEnabled(true) }) -} - -@Composable -private fun RenderSwitch(variable: Boolean?, onSwitchEnabled: (Boolean) -> Unit) { - val isChecked = variable == true - Switch( - checked = isChecked, - onCheckedChange = onSwitchEnabled, - modifier = Modifier.scale(0.9f), - thumbContent = { - Icon( - imageVector = if (isChecked) Icons.Filled.Check else Icons.Filled.Close, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize) - ) - } - ) -} - -@Composable -private fun RenderLinkIcon(onLinkClicked: () -> Unit) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.OpenInNew, - contentDescription = null, - modifier = Modifier.padding(dimensionResource(id = R.dimen.icon_padding)).clickable { onLinkClicked() }, - tint = MaterialTheme.colorScheme.primary - ) -} - -@Composable -fun RenderCustomIcon() { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowForwardIos, - contentDescription = null, - modifier = Modifier.scale(0.6f).padding(dimensionResource(id = R.dimen.icon_padding)) - ) -} - -@Composable -private fun RenderClipboardIcon() { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = null, - modifier = Modifier.padding(dimensionResource(id = R.dimen.icon_padding)), - tint = MaterialTheme.colorScheme.primary - ) -} - -@Composable -private fun RenderText(customText: String, settingsViewModel: SettingsViewModel? = null) { - Text( - text = customText, - fontSize = settingsViewModel?.let { FontUtils.getFontSize(it, baseSize = 14) } ?: 14.sp, - modifier = Modifier.padding(dimensionResource(id = R.dimen.icon_padding)) - ) -} - -fun copyToClipboard(context: Context, clipboardText: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Copied Text", clipboardText) - clipboard.setPrimaryClip(clip) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsDialog.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsDialog.kt deleted file mode 100644 index 5221343..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsDialog.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.widgets - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.shapeManager - -@Composable -fun ListDialog( - text: String, - list: List, - initialItem: T? = null, - settingsViewModel: SettingsViewModel, - onExit: () -> Unit, - extractDisplayData: (T) -> Pair, - setting: @Composable (Boolean, Boolean, Pair) -> Unit -) { - Dialog( - onDismissRequest = { onExit() }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Column( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = shapeManager( - isBoth = true, - radius = settingsViewModel.settings.value.cornerRadius - ) - ) - .padding(22.dp, 0.dp, 22.dp, 0.dp) - .fillMaxSize(.8f) - ) { - Text( - text = text, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - fontSize = 20.sp, - ) - LazyColumn { - initialItem?.let { initial -> - item { - val displayData = extractDisplayData(initial) - setting(true, list.size == 1, displayData) - } - } - itemsIndexed(list) { index, content -> - val isFirstItem = (initialItem == null && index == 0) - val isLastItem = index == list.lastIndex - val displayData = extractDisplayData(content) - setting(isFirstItem, isLastItem, displayData) - if (isLastItem) Spacer(modifier = Modifier.height(20.dp)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsList.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsList.kt deleted file mode 100644 index 42f8c57..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/widgets/SettingsList.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.settings.widgets - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - - -@Composable -fun ListDialog( - text: String, - list: List, - initialItem: T? = null, - onExit: () -> Unit, - extractDisplayData: (T) -> Pair, - setting: @Composable (Pair) -> Unit -) { - Dialog( - onDismissRequest = { onExit() }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Column( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(32.dp) - ) - .padding(12.dp, 0.dp, 12.dp, 0.dp) - .fillMaxWidth(0.8f) - ) { - Text( - text = text, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - fontSize = 20.sp, - ) - LazyColumn( - modifier = Modifier - .padding(bottom = 12.dp) - .clip(RoundedCornerShape(32.dp)) - .heightIn(max = 600.dp) - ) { - initialItem?.let { initial -> - item { - val displayData = extractDisplayData(initial) - setting(displayData) - } - } - items(list) { content -> - val displayData = extractDisplayData(content) - setting(displayData) - } - } - } - } -} - -@Composable -fun CustomListDialog( - text: String, - onExit: () -> Unit, - content: LazyListScope.() -> Unit -) { - Dialog( - onDismissRequest = { onExit() }, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Column( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.surfaceContainerLow, - shape = RoundedCornerShape(32.dp) - ) - .padding(12.dp, 0.dp, 12.dp, 0.dp) - .fillMaxWidth(0.8f) - ) { - Text( - text = text, - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - fontSize = 20.sp, - ) - LazyColumn( - modifier = Modifier - .padding(bottom = 12.dp) - .clip(RoundedCornerShape(32.dp)) - .heightIn(max = 600.dp) - ) { - content() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/terms/TermsScreen.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/screens/terms/TermsScreen.kt deleted file mode 100644 index 2683999..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/terms/TermsScreen.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.babelsoftware.airnote.presentation.screens.terms - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.babelsoftware.airnote.constant.ConnectionConst -import com.babelsoftware.airnote.constant.SupportConst -import com.babelsoftware.airnote.presentation.components.AgreeButton -import com.babelsoftware.airnote.presentation.components.NotesScaffold -import com.babelsoftware.airnote.presentation.components.markdown.MarkdownText -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.shapeManager -import com.babelsoftware.airnote.R - - -@Composable -fun TermsScreen( - settingsViewModel: SettingsViewModel -) { - NotesScaffold( - floatingActionButton = { - AgreeButton( - text = stringResource(id = R.string.agree), - modifier = Modifier.semantics { - contentDescription = "Agree" - } - ) { - settingsViewModel.update(settingsViewModel.settings.value.copy(termsOfService = true)) - } - }, - content = { - Column( - modifier = Modifier.padding(16.dp) - ) { - Text( - text = stringResource(id = R.string.terms_of_service), - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.padding(0.dp, 16.dp, 16.dp, 16.dp) - ) - Box( - modifier = Modifier - .fillMaxSize() - .clip( - shapeManager( - isBoth = true, - radius = settingsViewModel.settings.value.cornerRadius - ) - ) - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) - ) - .padding(1.dp) - .semantics { - contentDescription = "Terms" - } - ) { - Column(modifier = Modifier.padding(16.dp)) { - MarkdownText( - fontSize = 12.sp, - radius = settingsViewModel.settings.value.cornerRadius, - markdown = getTermsOfService(), - isEnabled = true - ) - } - } - } - } - ) -} - - -@Composable -fun getTermsOfService(): String { - return buildString { - append("### ${stringResource(R.string.terms_acceptance_title)}\n") - append("${stringResource(R.string.terms_acceptance_body)}\n\n") - - append("### ${stringResource(R.string.terms_license_title)}\n") - append("${stringResource(R.string.terms_license_body)}\n\n") - - append("### ${stringResource(R.string.terms_use_title)}\n") - append("${stringResource(R.string.terms_use_body)}\n\n") - - append("### ${stringResource(R.string.terms_intellectual_property_title)}\n") - append("${stringResource(R.string.terms_intellectual_property_body)}\n\n") - - append("### ${stringResource(R.string.terms_disclaimer_title)}\n") - append("${stringResource(R.string.terms_disclaimer_body)}\n\n") - - append("### ${stringResource(R.string.terms_liability_title)}\n") - append("${stringResource(R.string.terms_liability_body)}\n\n") - - append("### ${stringResource(R.string.terms_termination_title)}\n") - append("${stringResource(R.string.terms_termination_body)}\n\n") - - append("### ${stringResource(R.string.terms_changes_title)}\n") - append("${stringResource(R.string.terms_changes_body)}\n\n") - - append("### ${stringResource(R.string.terms_contact_title)}\n") - append("${stringResource(R.string.terms_contact_body)} ${ConnectionConst.SUPPORT_MAIL}.\n\n") - - append("### ${stringResource(R.string.terms_privacy_title)}\n") - append("${stringResource(R.string.terms_privacy_body)} **https://github.com/Kin69/EasyNotes/wiki/Privacy-Policy**.\n\n") - - append("*${stringResource(R.string.terms_effective_date)}: ${SupportConst.TERMS_EFFECTIVE_DATE}\n") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Color.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Color.kt deleted file mode 100644 index 1596069..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Color.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.babelsoftware.airnote.presentation.theme - -import androidx.compose.ui.graphics.Color - -val primaryLight = Color(0xFF725855) -val primaryContainerLight = Color(0xFFFFDFDA) -val onPrimaryContainerLight = Color(0xFF5C4541) -val secondaryLight = Color(0xFF685B59) -val onSecondaryLight = Color(0xFFFFFFFF) -val secondaryContainerLight = Color(0xFFF5E2DF) -val onSecondaryContainerLight = Color(0xFF534746) -val tertiaryLight = Color(0xFF655E48) -val onTertiaryLight = Color(0xFFFFFFFF) -val tertiaryContainerLight = Color(0xFFF1E6CA) -val onTertiaryContainerLight = Color(0xFF514A35) -val errorLight = Color(0xFFBA1A1A) -val onErrorLight = Color(0xFFFFFFFF) -val errorContainerLight = Color(0xFFFFDAD6) -val onErrorContainerLight = Color(0xFF410002) -val backgroundLight = Color(0xFFFFF8F7) -val onBackgroundLight = Color(0xFF1E1B1B) -val surfaceLight = Color(0xFFFFF8F7) -val onSurfaceLight = Color(0xFF1E1B1B) -val surfaceVariantLight = Color(0xFFF0DFDC) -val onSurfaceVariantLight = Color(0xFF4F4443) -val outlineLight = Color(0xFF817472) -val outlineVariantLight = Color(0xFFD3C3C1) -val scrimLight = Color(0xFF000000) -val inverseSurfaceLight = Color(0xFF33302F) -val inverseOnSurfaceLight = Color(0xFFF6EFEE) -val inversePrimaryLight = Color(0xFFE0BFBA) -val surfaceDimLight = Color(0xFFDFD8D7) -val surfaceBrightLight = Color(0xFFFFF8F7) -val surfaceContainerLowestLight = Color(0xFFFFFFFF) -val surfaceContainerLowLight = Color(0xFFF9F2F1) -val surfaceContainerLight = Color(0xFFF4ECEB) -val surfaceContainerHighLight = Color(0xFFEEE7E5) -val surfaceContainerHighestLight = Color(0xFFE8E1E0) - -val primaryDark = Color(0xFFFFFFFF) -val onPrimaryDark = Color(0xFF402B28) -val primaryContainerDark = Color(0xFFEECDC8) -val onPrimaryContainerDark = Color(0xFF503A37) -val secondaryDark = Color(0xFFD4C3C0) -val onSecondaryDark = Color(0xFF392E2C) -val secondaryContainerDark = Color(0xFF463B39) -val onSecondaryContainerDark = Color(0xFFDFCDCA) -val tertiaryDark = Color(0xFFFFFFFF) -val onTertiaryDark = Color(0xFF36301D) -val tertiaryContainerDark = Color(0xFFDED4B8) -val onTertiaryContainerDark = Color(0xFF453F2B) -val errorDark = Color(0xFFFFB4AB) -val onErrorDark = Color(0xFF690005) -val errorContainerDark = Color(0xFF93000A) -val onErrorContainerDark = Color(0xFFFFDAD6) -val backgroundDark = Color(0xFF151312) -val onBackgroundDark = Color(0xFFE8E1E0) -val surfaceDark = Color(0xFF151312) -val onSurfaceDark = Color(0xFFE8E1E0) -val surfaceVariantDark = Color(0xFF4F4443) -val onSurfaceVariantDark = Color(0xFFD3C3C1) -val outlineDark = Color(0xFF9C8E8B) -val outlineVariantDark = Color(0xFF4F4443) -val scrimDark = Color(0xFF000000) -val inverseSurfaceDark = Color(0xFFE8E1E0) -val inverseOnSurfaceDark = Color(0xFF33302F) -val inversePrimaryDark = Color(0xFF725855) -val surfaceDimDark = Color(0xFF151312) -val surfaceBrightDark = Color(0xFF3C3838) -val surfaceContainerLowestDark = Color(0xFF100E0D) -val surfaceContainerLowDark = Color(0xFF1E1B1B) -val surfaceContainerDark = Color(0xFF221F1F) -val surfaceContainerHighDark = Color(0xFF2C2929) -val surfaceContainerHighestDark = Color(0xFF373433) - - -val linkColor = Color(0xFF90D5FF) - -// Custom color palette for our AI theme buttons -object AiButtonColors { - val GeminiContainer = Color(0xFF3B3F8C) // A rich navy blue/purple hue for a dark theme - val GeminiOnContainer = Color(0xFFE0E0FF) // A readable, vibrant text/icon color on this dark background - - // ---> A more standard but harmonious color for the “New Note” button - val SecondaryContainer = Color(0xFF4A4458) - val SecondaryOnContainer = Color(0xFFE8DEF8) - // <--- -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/FontUtils.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/theme/FontUtils.kt deleted file mode 100644 index a2523be..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/FontUtils.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.babelsoftware.airnote.presentation.theme - -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.sp -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel - -/** - * Utility functions for font size management throughout the app - */ -object FontUtils { - /** - * Get the appropriate font size based on the base size and settings - * @param settingsViewModel The settings view model - * @param baseSize The base size to scale from (default is 16sp) - * @param scaleFactor Optional scale factor to apply (e.g., 0.875 for smaller text) - * @return The calculated text unit - */ - @Composable - fun getFontSize( - settingsViewModel: SettingsViewModel, - baseSize: Int = 16, - scaleFactor: Float = 1f - ): TextUnit { - val settingsFontSize = settingsViewModel.settings.value.fontSize - val sizeDifference = settingsFontSize - baseSize - - return ((baseSize + sizeDifference) * scaleFactor).sp - } - - /** - * Get title font size - */ - @Composable - fun getTitleFontSize(settingsViewModel: SettingsViewModel): TextUnit { - return getFontSize(settingsViewModel, baseSize = 18, scaleFactor = 1.1f) - } - - - /** - * Get body font size - */ - @Composable - fun getBodyFontSize(settingsViewModel: SettingsViewModel): TextUnit { - return getFontSize(settingsViewModel) - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Schemes.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Schemes.kt deleted file mode 100644 index afa20e1..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Schemes.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.babelsoftware.airnote.presentation.theme - -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme - -val lightScheme = lightColorScheme( - primary = primaryLight, - onPrimary = surfaceDimLight, - primaryContainer = primaryContainerLight, - onPrimaryContainer = onPrimaryContainerLight, - secondary = secondaryLight, - onSecondary = onSecondaryLight, - secondaryContainer = secondaryContainerLight, - onSecondaryContainer = onSecondaryContainerLight, - tertiary = tertiaryLight, - onTertiary = onTertiaryLight, - tertiaryContainer = tertiaryContainerLight, - onTertiaryContainer = onTertiaryContainerLight, - error = errorLight, - onError = onErrorLight, - errorContainer = errorContainerLight, - onErrorContainer = onErrorContainerLight, - background = backgroundLight, - onBackground = onBackgroundLight, - surface = surfaceLight, - onSurface = onSurfaceLight, - surfaceVariant = surfaceVariantLight, - onSurfaceVariant = onSurfaceVariantLight, - outline = outlineLight, - outlineVariant = outlineVariantLight, - scrim = scrimLight, - inverseSurface = inverseSurfaceLight, - inverseOnSurface = inverseOnSurfaceLight, - inversePrimary = inversePrimaryLight, - surfaceDim = surfaceDimLight, - surfaceBright = surfaceBrightLight, - surfaceContainerLowest = surfaceContainerLowestLight, - surfaceContainerLow = surfaceContainerLowLight, - surfaceContainer = surfaceContainerLight, - surfaceContainerHigh = surfaceContainerHighLight, - surfaceContainerHighest = surfaceContainerHighestLight, -) - -val darkScheme = darkColorScheme( - primary = primaryDark, - onPrimary = onPrimaryDark, - primaryContainer = primaryContainerDark, - onPrimaryContainer = onPrimaryContainerDark, - secondary = secondaryDark, - onSecondary = onSecondaryDark, - secondaryContainer = secondaryContainerDark, - onSecondaryContainer = onSecondaryContainerDark, - tertiary = tertiaryDark, - onTertiary = onTertiaryDark, - tertiaryContainer = tertiaryContainerDark, - onTertiaryContainer = onTertiaryContainerDark, - error = errorDark, - onError = onErrorDark, - errorContainer = errorContainerDark, - onErrorContainer = onErrorContainerDark, - background = backgroundDark, - onBackground = onBackgroundDark, - surface = surfaceDark, - onSurface = onSurfaceDark, - surfaceVariant = surfaceVariantDark, - onSurfaceVariant = onSurfaceVariantDark, - outline = outlineDark, - outlineVariant = outlineVariantDark, - scrim = scrimDark, - inverseSurface = inverseSurfaceDark, - inverseOnSurface = inverseOnSurfaceDark, - inversePrimary = inversePrimaryDark, - surfaceDim = surfaceDimDark, - surfaceBright = surfaceBrightDark, - surfaceContainerLowest = surfaceContainerLowestDark, - surfaceContainerLow = surfaceContainerLowDark, - surfaceContainer = surfaceContainerDark, - surfaceContainerHigh = surfaceContainerHighDark, - surfaceContainerHighest = surfaceContainerHighestDark, -) diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Theme.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Theme.kt deleted file mode 100644 index fc3a767..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Theme.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.babelsoftware.airnote.presentation.theme - -import android.app.Activity -import android.content.Context -import android.os.Build -import android.view.WindowManager -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.core.view.WindowCompat -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel - -private fun getColorScheme(context: Context, isDarkTheme: Boolean, isDynamicTheme: Boolean, isAmoledTheme: Boolean): ColorScheme { - if (isDynamicTheme && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (isDarkTheme) { - if (isAmoledTheme) { - return dynamicDarkColorScheme(context).copy(surfaceContainerLow = Color.Black, surface = Color.Black) - } - - return dynamicDarkColorScheme(context) - } else { - return dynamicLightColorScheme(context) - } - } else if (isDarkTheme) { - if (isAmoledTheme) { - return darkScheme.copy(surfaceContainerLow = Color.Black, surface = Color.Black) - } - - return darkScheme - } else { - return lightScheme - } -} - -@Composable -fun LeafNotesTheme( - settingsModel: SettingsViewModel, - content: @Composable () -> Unit -) { - if (settingsModel.settings.value.automaticTheme) { - settingsModel.update(settingsModel.settings.value.copy(dynamicTheme = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)) - settingsModel.update(settingsModel.settings.value.copy(darkTheme = isSystemInDarkTheme())) - } - - val context = LocalContext.current - val activity = LocalView.current.context as Activity - WindowCompat.getInsetsController(activity.window, activity.window.decorView).apply { - isAppearanceLightStatusBars = !settingsModel.settings.value.darkTheme - } - if (settingsModel.settings.value.screenProtection) { - activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - } else { - activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - } - - MaterialTheme( - colorScheme = getColorScheme(context, settingsModel.settings.value.darkTheme, settingsModel.settings.value.dynamicTheme, settingsModel.settings.value.amoledTheme), - typography = getTypography(settingsModel), - content = content - ) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Type.kt b/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Type.kt deleted file mode 100644 index 7041bd2..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/theme/Type.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.babelsoftware.airnote.presentation.theme - -import androidx.compose.material3.Typography -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel - -/** - * Get a Typography object with font sizes scaled based on user settings - */ -@Composable -fun getTypography(settingsViewModel: SettingsViewModel): Typography { - val baseFontSize = settingsViewModel.settings.value.fontSize - val fontSizeFactor = baseFontSize / 16f - - fun scaledSize(size: Int): Int = (size * fontSizeFactor).toInt() - - return Typography( - displayLarge = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(57)), - ), - displayMedium = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(45)), - ), - displaySmall = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(36)), - ), - headlineLarge = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(32)), - ), - headlineMedium = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(28)), - ), - headlineSmall = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(24)), - ), - titleLarge = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(22)), - ), - titleMedium = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(16)), - ), - titleSmall = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(14)), - ), - bodyLarge = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(16)), - ), - bodyMedium = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(14)), - ), - bodySmall = TextStyle( - fontWeight = FontWeight.Normal, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(12)), - ), - labelLarge = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(14)), - ), - labelMedium = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(12)), - ), - labelSmall = TextStyle( - fontWeight = FontWeight.Medium, - fontSize = FontUtils.getFontSize(settingsViewModel, scaledSize(11)), - ), - ) -} diff --git a/app/src/main/java/com/babelsoftware/airnote/util/UpdateUtils.kt b/app/src/main/java/com/babelsoftware/airnote/util/UpdateUtils.kt deleted file mode 100644 index 719f160..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/util/UpdateUtils.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (c) 2025 Babel Software. - * - * This file and codes created by RRechz - Babel Software - */ - -package com.babelsoftware.airnote.util - -import android.util.Log -import com.google.mlkit.common.model.DownloadConditions -import com.google.mlkit.nl.translate.TranslateLanguage -import com.google.mlkit.nl.translate.Translation -import com.google.mlkit.nl.translate.TranslatorOptions -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.net.URL - -// ---> A better Changelog demonstrator -sealed class ChangelogResult { - data class Success(val body: String) : ChangelogResult() - data class Error(val exception: Exception) : ChangelogResult() -} -// <--- - -// Data class that holds version information (changelog, download url, checksum) together -data class ReleaseInfo( - val changelog: String, - val apkDownloadUrl: String?, - val sha256Checksum: String? -) - -/** - * Pulls the latest version tag (tag_name) from the GitHub API. - * @return Latest version tag or null in case of error. - */ -suspend fun checkForUpdates(): String? = withContext(Dispatchers.IO) { - try { - val url = URL("https://api.github.com/repos/RRechz/AirNote/releases/latest") - val connection = url.openConnection() - connection.connect() - val json = connection.getInputStream().bufferedReader().use { it.readText() } - val jsonObject = JSONObject(json) - return@withContext jsonObject.getString("tag_name") - } catch (e: Exception) { - e.printStackTrace() - return@withContext null - } -} - -/** - * Checks if the remote version is newer than the current version. - * It removes the “v” prefix and suffixes such as “-default”, “-debug” from the version names for comparison. - * @param remoteVersion The version from the remote server (e.g. “v1.2”). - * @param currentVersion The version installed on the device (e.g. “v1.1-default”). - * @return True if the remote version is newer, false otherwise. - */ -fun isNewerVersion(remoteVersion: String, currentVersion: String): Boolean { - val remote = remoteVersion.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } - val current = currentVersion.removePrefix("v").split("-")[0].split(".").map { it.toIntOrNull() ?: 0 } - - for (i in 0 until maxOf(remote.size, current.size)) { - val r = remote.getOrNull(i) ?: 0 - val c = current.getOrNull(i) ?: 0 - if (r > c) return true - if (r < c) return false - } - return false -} - -suspend fun getLatestReleaseInfo(): ReleaseInfo? = withContext(Dispatchers.IO) { - try { - val url = URL("https://api.github.com/repos/RRechz/AirNote/releases/latest") - val connection = url.openConnection() - connection.connect() - val json = connection.getInputStream().bufferedReader().use { it.readText() } - val jsonObject = JSONObject(json) - - val changelog = jsonObject.optString("body", "Değişiklik günlüğü alınamadı.") - val checksum = changelog.lines().find { it.startsWith("SHA-256:") }?.substringAfter(":")?.trim() // Find and extract the checksum from changelog - - // ---> Find APK download link - val assets = jsonObject.getJSONArray("assets") - var apkUrl: String? = null - if (assets.length() > 0) { - val asset = assets.getJSONObject(0) - apkUrl = asset.optString("browser_download_url") - } - // <--- - - ReleaseInfo(changelog, apkUrl, checksum) - } catch (e: Exception) { - e.printStackTrace() - null - } -} - -/** - * Pulls the changelog of the latest version from the GitHub API. - * @return Returns Success(body) if successful or Error(exception) if failed. - */ -suspend fun getChangelogFromGitHub(): ChangelogResult { - return withContext(Dispatchers.IO) { - try { - val url = URL("https://api.github.com/repos/RRechz/AirNote/releases/latest") - val connection = url.openConnection() - connection.connect() - val json = connection.getInputStream().bufferedReader().use { it.readText() } - val jsonObject = JSONObject(json) - val body = jsonObject.optString("body", "") - - // ---> Find the section starting with “## Changelog” and get the text after this section. Also, remove the special characters (** and ##). - val cleanChangelog = body.substringAfter("## Changelog", "") - .substringAfter("\n") // Skip the title line itself - .replace("**", "") - .replace("##", "") - .trim() - - ChangelogResult.Success(cleanChangelog) - // <--- - } catch (e: Exception) { - ChangelogResult.Error(e) - } - } -} - -/** - * Translates the given text to the target language using ML Kit. - * @param textToTranslate The text to be translated (assumed to be in English). - * @param targetLanguage The language code to translate to (e.g., "tr"). - * @return The translated text, or null if translation fails. - */ -suspend fun translateText(textToTranslate: String, targetLanguage: String): String? { - return withContext(Dispatchers.IO) { - if (targetLanguage == "en" || textToTranslate.isBlank()) { - return@withContext textToTranslate - } - try { - val options = TranslatorOptions.Builder() - .setSourceLanguage(TranslateLanguage.ENGLISH) - .setTargetLanguage(targetLanguage) - .build() - val translator = Translation.getClient(options) - - val conditions = DownloadConditions.Builder() - .requireWifi() - .build() - translator.downloadModelIfNeeded(conditions).await() - - val translatedText = translator.translate(textToTranslate).await() - translator.close() - translatedText - } catch (e: Exception) { - Log.e("Translation", "Text translation failed.", e) - null - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/widget/AddNoteReceiver.kt b/app/src/main/java/com/babelsoftware/airnote/widget/AddNoteReceiver.kt deleted file mode 100644 index 7e5bdd4..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/widget/AddNoteReceiver.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.babelsoftware.airnote.widget - -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetReceiver - -class AddNoteReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget: GlanceAppWidget = AddNoteWidget() -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/widget/AddNoteWidget.kt b/app/src/main/java/com/babelsoftware/airnote/widget/AddNoteWidget.kt deleted file mode 100644 index 168fad8..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/widget/AddNoteWidget.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.babelsoftware.airnote.widget - -import android.content.Context -import android.content.Intent -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.GlanceId -import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.action.clickable -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.cornerRadius -import androidx.glance.appwidget.provideContent -import androidx.glance.background -import androidx.glance.layout.Alignment -import androidx.glance.layout.Row -import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.height -import androidx.glance.layout.width -import androidx.glance.text.Text -import androidx.glance.text.TextStyle -import com.babelsoftware.airnote.presentation.MainActivity -import com.babelsoftware.airnote.R - -class AddNoteWidget : GlanceAppWidget() { - override suspend fun provideGlance(context: Context, id: GlanceId) { - provideContent { - GlanceTheme { - val intent = Intent(context, MainActivity::class.java).apply { - putExtra("noteId", 0) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK // <-- Doğru satır - } - Row( - modifier = GlanceModifier - .fillMaxSize() - .height(50.dp) - .width(100.dp) - .background(GlanceTheme.colors.primary) - .clickable { context.startActivity(intent) } - .cornerRadius(20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = context.getString(R.string.new_note), - style = TextStyle( - fontSize = 16.sp, - color = GlanceTheme.colors.background - ), - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidget.kt b/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidget.kt deleted file mode 100644 index 48c04c2..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidget.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.babelsoftware.airnote.widget - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.glance.GlanceId -import androidx.glance.GlanceTheme -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetManager -import androidx.glance.appwidget.provideContent -import androidx.glance.currentState -import androidx.glance.state.GlanceStateDefinition -import com.babelsoftware.airnote.domain.usecase.NoteUseCase -import com.babelsoftware.airnote.widget.ui.SelectedNote -import com.babelsoftware.airnote.widget.ui.ZeroState -import dagger.hilt.EntryPoint -import dagger.hilt.EntryPoints -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import java.io.File - -@EntryPoint -@InstallIn(SingletonComponent::class) -interface WidgetModelRepositoryEntryPoint { - fun noteUseCase() : NoteUseCase -} - -class NotesWidget : GlanceAppWidget() { - override val stateDefinition: GlanceStateDefinition>> - get() = object: GlanceStateDefinition>> { - override suspend fun getDataStore( - context: Context, - fileKey: String - ): DataStore>> { - return NotesDataStore(context) - } - override fun getLocation(context: Context, fileKey: String): File { - throw NotImplementedError("Not implemented") - } - } - - override suspend fun provideGlance(context: Context, id: GlanceId) { - val noteUseCase = getNoteUseCase(context) - val widgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) - - provideContent { - GlanceTheme { - currentState>>().firstOrNull { it.first == widgetId }?.second.let { noteId -> - noteUseCase.observe() - val selectedNote = noteUseCase.notes.filter { it.id == noteId } - when { - selectedNote.isEmpty() -> ZeroState(widgetId = widgetId) - else -> SelectedNote(selectedNote.first(), noteUseCase, widgetId = widgetId) - } - } - } - } - } -} - - -fun getNoteUseCase(applicationContext: Context): NoteUseCase { - var widgetModelRepositoryEntrypoint: WidgetModelRepositoryEntryPoint = EntryPoints.get( - applicationContext, - WidgetModelRepositoryEntryPoint::class.java, - ) - return widgetModelRepositoryEntrypoint.noteUseCase() -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetActivity.kt b/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetActivity.kt deleted file mode 100644 index bf61447..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetActivity.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.babelsoftware.airnote.widget - -import android.appwidget.AppWidgetManager -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.res.stringResource -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.glance.appwidget.updateAll -import androidx.hilt.navigation.compose.hiltViewModel -import com.babelsoftware.airnote.domain.usecase.NoteUseCase -import com.babelsoftware.airnote.domain.repository.SettingsRepository -import com.babelsoftware.airnote.presentation.components.NotesScaffold -import com.babelsoftware.airnote.presentation.screens.home.getContainerColor -import com.babelsoftware.airnote.presentation.screens.home.sorter -import com.babelsoftware.airnote.presentation.screens.home.widgets.NoteFilter -import com.babelsoftware.airnote.presentation.screens.settings.TopBar -import com.babelsoftware.airnote.presentation.screens.settings.model.SettingsViewModel -import com.babelsoftware.airnote.presentation.screens.settings.settings.shapeManager -import com.babelsoftware.airnote.presentation.theme.LeafNotesTheme -import com.babelsoftware.airnote.R -import com.babelsoftware.airnote.domain.usecase.FolderUseCase -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.runBlocking -import javax.inject.Inject - -@AndroidEntryPoint -class NotesWidgetActivity : ComponentActivity() { - @Inject - lateinit var noteUseCase: NoteUseCase - - @Inject - lateinit var folderUseCase: FolderUseCase - - @Inject - lateinit var settingsRepository: SettingsRepository - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - installSplashScreen() - enableEdgeToEdge() - - val appWidgetId = intent?.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID,) ?: AppWidgetManager.INVALID_APPWIDGET_ID - setContent { - val settings = hiltViewModel() - noteUseCase.observe() - val allFolders by folderUseCase.getAllFolders().collectAsState(initial = emptyList()) - - LeafNotesTheme(settingsModel = settings) { - NotesScaffold( - topBar = { - TopBar( - title = stringResource(id = R.string.select_note), - onBackNavClicked = { finish() } - ) - }, - content = { - NoteFilter( - settingsViewModel = settings, - containerColor = getContainerColor(settings), - shape = shapeManager( - radius = settings.settings.value.cornerRadius / 2, - isBoth = true - ), - onNoteClicked = { id -> - runBlocking { - settingsRepository.putInt( - "${NotesWidgetReceiver.WIDGET_PREFERENCE}${appWidgetId}", - id - ) - NotesWidget().updateAll(this@NotesWidgetActivity) - val resultValue = Intent().putExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - appWidgetId - ) - setResult(RESULT_OK, resultValue) - finish() - } - }, - notes = noteUseCase.notes.sortedWith(sorter(settings.settings.value.sortDescending)), - allFolders = allFolders, - viewMode = false, - ) - } - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetReceiver.kt b/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetReceiver.kt deleted file mode 100644 index c8f7d42..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/widget/NotesWidgetReceiver.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.babelsoftware.airnote.widget - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetReceiver -import com.babelsoftware.airnote.data.repository.SettingsRepositoryImpl -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - - -class NotesWidgetReceiver : GlanceAppWidgetReceiver() { - override val glanceAppWidget: GlanceAppWidget = NotesWidget() - - companion object { - const val WIDGET_PREFERENCE = "widgetNote_" - } -} - - -class NotesDataStore(private val context: Context): DataStore>> { - override val data: Flow>> - get() { - val settingsRepository = SettingsRepositoryImpl(context) - return flow { emit(settingsRepository.getEveryNotesWidget()) } - } - - override suspend fun updateData(transform: suspend (t: List>) -> List>): List> { - throw NotImplementedError("Not implemented") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/widget/ui/Note.kt b/app/src/main/java/com/babelsoftware/airnote/widget/ui/Note.kt deleted file mode 100644 index 569edd7..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/widget/ui/Note.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.babelsoftware.airnote.widget.ui - -import android.content.Intent -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.LocalContext -import androidx.glance.action.clickable -import androidx.glance.background -import androidx.glance.layout.Column -import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.padding -import androidx.glance.text.FontWeight -import com.babelsoftware.airnote.domain.model.Note -import com.babelsoftware.airnote.domain.usecase.NoteUseCase -import com.babelsoftware.airnote.presentation.MainActivity -import com.babelsoftware.airnote.presentation.components.markdown.WidgetText -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - - -@Composable -fun SelectedNote(note: Note, noteUseCase: NoteUseCase, widgetId: Int) { - val context = LocalContext.current - val glanceModifier = GlanceModifier.clickable { - val intent = Intent(context, MainActivity::class.java).apply { - putExtra("noteId", note.id) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK // <-- Doğru satır - } - context.startActivity(intent) - } - - - Column( - modifier = glanceModifier - .background(GlanceTheme.colors.background) - .fillMaxSize() - .padding(6.dp) - ) { - if(note.name.isNotBlank()) { - WidgetText( - modifier = glanceModifier, - markdown = note.name, - weight = FontWeight.Bold, - fontSize = 24.sp, - color = GlanceTheme.colors.primary, - onContentChange = { - CoroutineScope(Dispatchers.IO).launch { - noteUseCase.addNote(note.copy(name = it)) - noteUseCase.observe() - } - } - ) - } - if(note.description.isNotBlank()) { - WidgetText( - modifier = glanceModifier.fillMaxWidth(), - markdown = note.description, - weight = FontWeight.Normal, - fontSize = 12.sp, - color = GlanceTheme.colors.primary, - onContentChange = { - CoroutineScope(Dispatchers.IO).launch { - noteUseCase.addNote(note.copy(description = it)) - noteUseCase.observe() - } - } - ) - } - } -} diff --git a/app/src/main/java/com/babelsoftware/airnote/widget/ui/ZeroState.kt b/app/src/main/java/com/babelsoftware/airnote/widget/ui/ZeroState.kt deleted file mode 100644 index 6a20421..0000000 --- a/app/src/main/java/com/babelsoftware/airnote/widget/ui/ZeroState.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.babelsoftware.airnote.widget.ui - -import android.appwidget.AppWidgetManager -import androidx.compose.runtime.Composable -import androidx.glance.Button -import androidx.glance.GlanceModifier -import androidx.glance.GlanceTheme -import androidx.glance.LocalContext -import androidx.glance.action.ActionParameters -import androidx.glance.action.actionParametersOf -import androidx.glance.action.actionStartActivity -import androidx.glance.background -import androidx.glance.layout.Alignment -import androidx.glance.layout.Box -import androidx.glance.layout.fillMaxSize -import com.babelsoftware.airnote.widget.NotesWidgetActivity -import com.babelsoftware.airnote.R - -@Composable -fun ZeroState(widgetId: Int) { - val widgetIdKey = ActionParameters.Key(AppWidgetManager.EXTRA_APPWIDGET_ID) - val context = LocalContext.current - - Box( - modifier = GlanceModifier - .background(GlanceTheme.colors.background) - .fillMaxSize(), - contentAlignment = Alignment.Center) { - Button( - text = context.getString(R.string.select_note), - onClick = actionStartActivity( - parameters = actionParametersOf(widgetIdKey to widgetId), - ), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/App.kt b/app/src/main/java/com/babelsoftware/loudly/App.kt new file mode 100644 index 0000000..38b9d32 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/App.kt @@ -0,0 +1,151 @@ +package com.babelsoftware.loudly + +import android.annotation.SuppressLint +import android.app.Application +import android.os.Build +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.datastore.preferences.core.edit +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import coil.request.CachePolicy +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.constants.ContentCountryKey +import com.babelsoftware.loudly.constants.ContentLanguageKey +import com.babelsoftware.loudly.constants.CountryCodeToName +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.LanguageCodeToName +import com.babelsoftware.loudly.constants.MaxImageCacheSizeKey +import com.babelsoftware.loudly.constants.ProxyEnabledKey +import com.babelsoftware.loudly.constants.ProxyTypeKey +import com.babelsoftware.loudly.constants.ProxyUrlKey +import com.babelsoftware.loudly.constants.SYSTEM_DEFAULT +import com.babelsoftware.loudly.constants.UseLoginForBrowse +import com.babelsoftware.loudly.constants.VisitorDataKey +import com.babelsoftware.loudly.extensions.toEnum +import com.babelsoftware.loudly.extensions.toInetSocketAddress +import com.babelsoftware.innertube.models.YouTubeLocale +import com.babelsoftware.kugou.KuGou +import com.babelsoftware.loudly.background.setupModernAwarenessSystem +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber +import java.lang.Double.min +import java.net.Proxy +import java.util.Locale +import kotlin.collections.contains + +@HiltAndroidApp +class App : Application(), ImageLoaderFactory { + @OptIn(DelicateCoroutinesApi::class) + override fun onCreate() { + super.onCreate() + Timber.plant(Timber.DebugTree()) + + val locale = Locale.getDefault() + val languageTag = locale.toLanguageTag().replace("-Hant", "") + YouTube.locale = YouTubeLocale( + gl = dataStore[ContentCountryKey]?.takeIf { it != SYSTEM_DEFAULT } + ?: locale.country.takeIf { it in CountryCodeToName } + ?: "US", + hl = dataStore[ContentLanguageKey]?.takeIf { it != SYSTEM_DEFAULT } + ?: locale.language.takeIf { it in LanguageCodeToName } + ?: languageTag.takeIf { it in LanguageCodeToName } + ?: "en" + ) + if (languageTag == "zh-TW") { + KuGou.useTraditionalChinese = true + } + + if (dataStore[ProxyEnabledKey] == true) { + try { + YouTube.proxy = Proxy( + dataStore[ProxyTypeKey].toEnum(defaultValue = Proxy.Type.HTTP), + dataStore[ProxyUrlKey]!!.toInetSocketAddress() + ) + } catch (e: Exception) { + Toast.makeText(this, "Failed to parse proxy url.", LENGTH_SHORT).show() + reportException(e) + } + } + + if (dataStore[UseLoginForBrowse] == true) { + YouTube.useLoginForBrowse = true + } + + GlobalScope.launch { + dataStore.data + .map { it[VisitorDataKey] } + .distinctUntilChanged() + .collect { visitorData -> + YouTube.visitorData = visitorData + ?.takeIf { it != "null" } + ?: YouTube.visitorData().getOrNull()?.also { newVisitorData -> + dataStore.edit { settings -> + settings[VisitorDataKey] = newVisitorData + } + } ?: YouTube.DEFAULT_VISITOR_DATA + } + } + GlobalScope.launch { + dataStore.data + .map { it[InnerTubeCookieKey] } + .distinctUntilChanged() + .collect { rawCookie -> + val isLoggedIn: Boolean = rawCookie?.contains("SAPISID") ?: false + val cookie = if (isLoggedIn) rawCookie else null + try { + YouTube.cookie = cookie + } catch (e: Exception) { + Timber.e("Could not parse cookie. Clearing existing cookie. %s", e.message) + dataStore.edit { settings -> + settings[InnerTubeCookieKey] = "" + } + } + } + } + setupContextualNotifications() + } + + private fun setupContextualNotifications() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setupModernAwarenessSystem(this) + } + } + + @SuppressLint("UsableSpace") + override fun newImageLoader(): ImageLoader { + val cacheSize = dataStore[MaxImageCacheSizeKey] + + return if (cacheSize == 0) { + ImageLoader.Builder(this).crossfade(true).respectCacheHeaders(false) + .allowHardware(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + .diskCachePolicy(CachePolicy.DISABLED).build() + } else { + val maxSize = when { + cacheSize == -1 -> { + val cacheDir = cacheDir.resolve("coil") + val usableSpace = cacheDir.usableSpace + min(usableSpace * 0.9, (2L * 1024 * 1024 * 1024).toDouble()).toLong() + } + else -> (cacheSize ?: 512) * 1024 * 1024L + } + + ImageLoader.Builder(this).crossfade(true).respectCacheHeaders(false) + .allowHardware(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P).diskCache( + DiskCache.Builder() + .directory(cacheDir.resolve("coil")) + .maxSizeBytes(maxSize) + .build() + ).build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/MainActivity.kt b/app/src/main/java/com/babelsoftware/loudly/MainActivity.kt new file mode 100644 index 0000000..bcb21a7 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/MainActivity.kt @@ -0,0 +1,1232 @@ +package com.babelsoftware.loudly + +import android.Manifest +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import androidx.core.util.Consumer +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import coil.imageLoader +import coil.request.ImageRequest +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.loudly.background.ContextualWorker +import com.babelsoftware.loudly.constants.AppBarHeight +import com.babelsoftware.loudly.constants.AppDesignVariantKey +import com.babelsoftware.loudly.constants.AppDesignVariantType +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.DarkModeKey +import com.babelsoftware.loudly.constants.DefaultOpenTabKey +import com.babelsoftware.loudly.constants.DefaultOpenTabOldKey +import com.babelsoftware.loudly.constants.DisableScreenshotKey +import com.babelsoftware.loudly.constants.DynamicThemeKey +import com.babelsoftware.loudly.constants.FirstSetupPassed +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.constants.MiniPlayerHeight +import com.babelsoftware.loudly.constants.PauseSearchHistoryKey +import com.babelsoftware.loudly.constants.PlayerStyle +import com.babelsoftware.loudly.constants.PlayerStyleKey +import com.babelsoftware.loudly.constants.PureBlackKey +import com.babelsoftware.loudly.constants.SearchSource +import com.babelsoftware.loudly.constants.SearchSourceKey +import com.babelsoftware.loudly.constants.SongSortType +import com.babelsoftware.loudly.constants.StopMusicOnTaskClearKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.SearchHistory +import com.babelsoftware.loudly.extensions.toEnum +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.DownloadUtil +import com.babelsoftware.loudly.playback.MusicService +import com.babelsoftware.loudly.playback.MusicService.MusicBinder +import com.babelsoftware.loudly.playback.PlayerConnection +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.ActionChipButton +import com.babelsoftware.loudly.ui.component.BottomSheetMenu +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.LoudlyBottomNavBar +import com.babelsoftware.loudly.ui.component.TopSearch +import com.babelsoftware.loudly.ui.component.rememberBottomSheetState +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerTheme +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.ui.player.BottomSheetPlayer +import com.babelsoftware.loudly.ui.screens.Screens +import com.babelsoftware.loudly.ui.screens.navigationBuilder +import com.babelsoftware.loudly.ui.screens.search.LocalSearchScreen +import com.babelsoftware.loudly.ui.screens.search.OnlineSearchScreen +import com.babelsoftware.loudly.ui.screens.settings.DarkMode +import com.babelsoftware.loudly.ui.screens.settings.NavigationTab +import com.babelsoftware.loudly.ui.screens.settings.NavigationTabOld +import com.babelsoftware.loudly.ui.screens.settings.updateLanguage +import com.babelsoftware.loudly.ui.theme.ColorSaver +import com.babelsoftware.loudly.ui.theme.DefaultThemeColor +import com.babelsoftware.loudly.ui.theme.LoudlyTheme +import com.babelsoftware.loudly.ui.theme.extractThemeColor +import com.babelsoftware.loudly.ui.utils.appBarScrollBehavior +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.ui.utils.imageCache +import com.babelsoftware.loudly.ui.utils.resetHeightOffset +import com.babelsoftware.loudly.utils.SyncUtils +import com.babelsoftware.loudly.utils.Updater +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.utils.urlEncode +import com.google.android.datatransport.BuildConfig +import com.valentinilk.shimmer.LocalShimmerTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.URL +import java.net.URLDecoder +import java.util.Locale +import javax.inject.Inject +import kotlin.time.Duration.Companion.days + +@Suppress("NAME_SHADOWING") +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @Inject + lateinit var database: MusicDatabase + + @Inject + lateinit var downloadUtil: DownloadUtil + + @Inject + lateinit var syncUtils: SyncUtils + + private var playerConnection by mutableStateOf(null) + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service is MusicBinder) { + playerConnection = PlayerConnection(this@MainActivity, service, database, lifecycleScope) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + playerConnection?.dispose() + playerConnection = null + } + } + + + // storage permission helpers + private val mediaPermissionLevel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE + + private val nearbyDevicesPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_ADVERTISE, + Manifest.permission.BLUETOOTH_CONNECT + ) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } + private val permissionsLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {} + + private var latestVersionName by mutableStateOf(BuildConfig.VERSION_NAME) + + override fun onStart() { + super.onStart() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startService(Intent(this, MusicService::class.java)) + } else { + startService(Intent(this, MusicService::class.java)) + } + bindService(Intent(this, MusicService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) + } + + override fun onStop() { + unbindService(serviceConnection) + super.onStop() + } + + override fun onDestroy() { + super.onDestroy() + if (dataStore.get(StopMusicOnTaskClearKey, false) && playerConnection?.isPlaying?.value == true && isFinishing) { + stopService(Intent(this, MusicService::class.java)) + unbindService(serviceConnection) + playerConnection = null + } + } + + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "ObsoleteSdkInt", "UnusedBoxWithConstraintsScope") + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + handleIncomingIntent(intent) + + lifecycleScope.launch { + dataStore.data + .map { it[DisableScreenshotKey] ?: false } + .distinctUntilChanged() + .collectLatest { + if (it) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + + val sharedPreferences = getSharedPreferences("app_settings", Context.MODE_PRIVATE) + val savedLanguage = sharedPreferences.getString("app_language", Locale.getDefault().language) ?: "en" + updateLanguage(this, savedLanguage) + + setContent { + LaunchedEffect(Unit) { + if (System.currentTimeMillis() - Updater.lastCheckTime > 1.days.inWholeMilliseconds) { + Updater.getLatestVersionName().onSuccess { latestVersionName = it } + } + } + + + val snackbarHostState = remember { SnackbarHostState() } + val (appDesignVariant) = rememberEnumPreference(AppDesignVariantKey, defaultValue = AppDesignVariantType.NEW) + val (defaultOpenTabOld) = rememberEnumPreference(DefaultOpenTabOldKey, defaultValue = NavigationTabOld.HOME) + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val inSelectMode = navBackStackEntry?.savedStateHandle?.getStateFlow("inSelectMode", false)?.collectAsState() + val enableDynamicTheme by rememberPreference(DynamicThemeKey, defaultValue = true) + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + LaunchedEffect(useDarkTheme) { + setSystemBarAppearance(useDarkTheme) + } + var themeColor by rememberSaveable(stateSaver = ColorSaver) { + mutableStateOf(DefaultThemeColor) + } + + LaunchedEffect(playerConnection, enableDynamicTheme, isSystemInDarkTheme) { + val playerConnection = playerConnection + if (!enableDynamicTheme || playerConnection == null) { + themeColor = DefaultThemeColor + return@LaunchedEffect + } + playerConnection.service.currentMediaMetadata.collectLatest { song -> + themeColor = if (song != null) { + withContext(Dispatchers.IO) { + if (!song.isLocal) { + val result = imageLoader.execute( + ImageRequest.Builder(this@MainActivity) + .data(song.thumbnailUrl) + .allowHardware(false) + .build() + ) + (result.drawable as? BitmapDrawable)?.bitmap?.extractThemeColor() ?: DefaultThemeColor + } else { + imageCache.getLocalThumbnail(song.localPath)?.extractThemeColor() + ?: DefaultThemeColor + } + } + } else DefaultThemeColor + } + } + if (checkSelfPermission(mediaPermissionLevel) == PackageManager.PERMISSION_DENIED) { + permissionsLauncher.launch(arrayOf(mediaPermissionLevel)) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (nearbyDevicesPermission.any { + checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + }) { + permissionsLauncher.launch(nearbyDevicesPermission) + } + } else { + if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + permissionsLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)) + } + } + + LoudlyTheme( + darkTheme = useDarkTheme, + pureBlack = pureBlack, + themeColor = themeColor + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + val focusManager = LocalFocusManager.current + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } + + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + + val navigationItems = remember(appDesignVariant) { + if (appDesignVariant == AppDesignVariantType.NEW) { + Screens.MainScreens + } else { + Screens.MainScreensOld + } + } + val defaultOpenTab = remember { + if (appDesignVariant == AppDesignVariantType.NEW) dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) + else dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) + } + val tabOpenedFromShortcut = remember { + when (intent?.action) { + ACTION_SONGS -> if (appDesignVariant == AppDesignVariantType.NEW) NavigationTab.LIBRARY else NavigationTabOld.SONGS + ACTION_ARTISTS -> if (appDesignVariant == AppDesignVariantType.NEW) NavigationTab.LIBRARY else NavigationTabOld.ARTISTS + ACTION_ALBUMS -> if (appDesignVariant == AppDesignVariantType.NEW) NavigationTab.LIBRARY else NavigationTabOld.ALBUMS + ACTION_PLAYLISTS -> if (appDesignVariant == AppDesignVariantType.NEW) NavigationTab.LIBRARY else NavigationTabOld.PLAYLISTS + else -> null + } + } + if (tabOpenedFromShortcut != null && appDesignVariant == AppDesignVariantType.NEW) { + var filter by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) + filter = when (intent?.action) { + ACTION_SONGS -> LibraryFilter.SONGS + ACTION_ARTISTS -> LibraryFilter.ARTISTS + ACTION_ALBUMS -> LibraryFilter.ALBUMS + ACTION_PLAYLISTS -> LibraryFilter.PLAYLISTS + else -> LibraryFilter.LIBRARY + } + } + val topLevelScreens = listOf( + Screens.Home.route, + Screens.Explore.route, + Screens.Library.route, + Screens.Songs.route, + Screens.Artists.route, + Screens.Albums.route, + Screens.Playlists.route, + "settings" + ) + + val (query, onQueryChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + var active by rememberSaveable { + mutableStateOf(false) + } + val onActiveChange: (Boolean) -> Unit = { newActive -> + active = newActive + if (!newActive) { + focusManager.clearFocus() + if (navigationItems.any { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) + } + } + } + var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) + + val searchBarFocusRequester = remember { FocusRequester() } + + val onSearch: (String) -> Unit = { + if (it.isNotEmpty()) { + onActiveChange(false) + navController.navigate("search/${it.urlEncode()}") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = it)) + } + } + } + } + + var openSearchImmediately: Boolean by remember { + mutableStateOf(intent?.action == ACTION_SEARCH) + } + + val (playerStyle) = rememberEnumPreference(PlayerStyleKey, defaultValue = PlayerStyle.UI_2_0) + + val shouldShowSearchBar = remember(active, navBackStackEntry, inSelectMode?.value) { + (active || + navigationItems.any { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true) && + inSelectMode?.value != true + } + val shouldShowNavigationBar = remember(navBackStackEntry, active) { + navBackStackEntry?.destination?.route == null || + navigationItems.any { it.route == navBackStackEntry?.destination?.route } && !active + } + // ==================== KEYS & ADDICTIONS ==================== + val miniPlayerMargin = if (playerStyle == PlayerStyle.UI_2_0) 8.dp else 0.dp + val collapsedBound = bottomInset + (if (shouldShowNavigationBar) 80.dp else 0.dp) + MiniPlayerHeight + miniPlayerMargin + + val maxHeightInDp = with(density) { constraints.maxHeight.toDp() } + val playerBottomSheetState = rememberBottomSheetState( + dismissedBound = 0.dp, + collapsedBound = collapsedBound, + expandedBound = maxHeightInDp, + ) + + LaunchedEffect(shouldShowNavigationBar, playerBottomSheetState.isDismissed) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + + val currentSong by playerConnection?.service?.currentMediaMetadata?.collectAsState(initial = null) + ?: remember { mutableStateOf(null) } + + LaunchedEffect(currentSong) { + if (currentSong == null) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.dismiss() + } + } else { + if (playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + } + + val playerAwareWindowInsets = remember(bottomInset, shouldShowNavigationBar, playerBottomSheetState.isDismissed, miniPlayerMargin) { + var bottom = bottomInset + if (shouldShowNavigationBar) bottom += 80.dp + if (!playerBottomSheetState.isDismissed) { + bottom += MiniPlayerHeight + bottom += miniPlayerMargin + } + windowsInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets(top = AppBarHeight, bottom = bottom)) + } + + val searchBarScrollBehavior = appBarScrollBehavior( + canScroll = { + navBackStackEntry?.destination?.route?.startsWith("search/") == false && + (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) + } + ) + val topAppBarScrollBehavior = appBarScrollBehavior( + canScroll = { + navBackStackEntry?.destination?.route?.startsWith("search/") == false && + (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) + } + ) + // ======================================================================== + val isScrolled by remember { + derivedStateOf { + searchBarScrollBehavior.state.heightOffset < -5f + } + } + + + val (firstSetupPassed) = rememberPreference(FirstSetupPassed, defaultValue = false) + + LaunchedEffect(Unit) { + if (!firstSetupPassed) { + navController.navigate("setup_wizard") + } + } + + LaunchedEffect(navBackStackEntry) { + if (navBackStackEntry?.destination?.route?.startsWith("search/") == true) { + val searchQuery = + withContext(Dispatchers.IO) { + if (navBackStackEntry + ?.arguments + ?.getString( + "query", + )!! + .contains( + "%", + ) + ) { + navBackStackEntry?.arguments?.getString( + "query", + )!! + } else { + URLDecoder.decode(navBackStackEntry?.arguments?.getString("query")!!, "UTF-8") + } + } + onQueryChange(TextFieldValue(searchQuery, TextRange(searchQuery.length))) + } else if (navigationItems.any { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) + } + searchBarScrollBehavior.state.resetHeightOffset() + topAppBarScrollBehavior.state.resetHeightOffset() + } + LaunchedEffect(active) { + if (active) { + searchBarScrollBehavior.state.resetHeightOffset() + topAppBarScrollBehavior.state.resetHeightOffset() + } + } + + LaunchedEffect(playerConnection) { + val player = playerConnection?.player ?: return@LaunchedEffect + if (player.currentMediaItem == null) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.dismiss() + } + } else { + if (playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + } + + DisposableEffect(playerConnection, playerBottomSheetState) { + val player = playerConnection?.player ?: return@DisposableEffect onDispose { } + val listener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null && playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + } + player.addListener(listener) + onDispose { + player.removeListener(listener) + } + } + + val coroutineScope = rememberCoroutineScope() + var sharedSong: SongItem? by remember { + mutableStateOf(null) + } + DisposableEffect(Unit) { + val listener = Consumer { intent -> + handleIncomingIntent(intent) + val uri = intent.data ?: intent.extras?.getString(Intent.EXTRA_TEXT)?.toUri() ?: return@Consumer + when (val path = uri.pathSegments.firstOrNull()) { + "playlist" -> uri.getQueryParameter("list")?.let { playlistId -> + if (playlistId.startsWith("OLAK5uy_")) { + coroutineScope.launch { + YouTube.albumSongs(playlistId).onSuccess { songs -> + songs.firstOrNull()?.album?.id?.let { browseId -> + navController.navigate("album/$browseId") + } + }.onFailure { + reportException(it) + } + } + } else { + navController.navigate("online_playlist/$playlistId") + } + } + + "channel", "c" -> uri.lastPathSegment?.let { artistId -> + navController.navigate("artist/$artistId") + } + + else -> when { + path == "watch" -> uri.getQueryParameter("v") + uri.host == "youtu.be" -> path + else -> null + }?.let { videoId -> + coroutineScope.launch { + withContext(Dispatchers.IO) { + YouTube.queue(listOf(videoId)) + }.onSuccess { + playerConnection?.playQueue(YouTubeQueue(WatchEndpoint(videoId = it.firstOrNull()?.id), it.firstOrNull()?.toMediaMetadata())) + }.onFailure { + reportException(it) + } + } + } + } + } + + addOnNewIntentListener(listener) + onDispose { removeOnNewIntentListener(listener) } + } + + val updateAvailable = remember(latestVersionName) { + isNewerVersion(latestVersionName, BuildConfig.VERSION_NAME) + } + + CompositionLocalProvider( + LocalDatabase provides database, + LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.surface), + LocalPlayerConnection provides playerConnection, + LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, + LocalDownloadUtil provides downloadUtil, + LocalShimmerTheme provides ShimmerTheme, + LocalSyncUtils provides syncUtils, + LocalSnackbarHostState provides snackbarHostState + ) { + if (appDesignVariant == AppDesignVariantType.NEW) { + NavHost( + navController = navController, + startDestination = when (tabOpenedFromShortcut ?: defaultOpenTab) { + NavigationTab.HOME -> Screens.Home + NavigationTab.EXPLORE -> Screens.Explore + NavigationTabOld.SONGS -> Screens.Songs + NavigationTabOld.ARTISTS -> Screens.Artists + NavigationTabOld.ALBUMS -> Screens.Albums + NavigationTabOld.PLAYLISTS -> Screens.Playlists + else -> Screens.Library + }.route, + enterTransition = { + if (initialState.destination.route in topLevelScreens && targetState.destination.route in topLevelScreens) { + fadeIn(tween(250)) + } else { + fadeIn(tween(250)) + slideInHorizontally { it / 2 } + } + }, + exitTransition = { + if (initialState.destination.route in topLevelScreens && targetState.destination.route in topLevelScreens) { + fadeOut(tween(200)) + } else { + fadeOut(tween(200)) + slideOutHorizontally { -it / 2 } + } + }, + popEnterTransition = { + if ((initialState.destination.route in topLevelScreens || initialState.destination.route?.startsWith( + "search/" + ) == true) && targetState.destination.route in topLevelScreens + ) { + fadeIn(tween(250)) + } else { + fadeIn(tween(250)) + slideInHorizontally { -it / 2 } + } + }, + popExitTransition = { + if ((initialState.destination.route in topLevelScreens || initialState.destination.route?.startsWith( + "search/" + ) == true) && targetState.destination.route in topLevelScreens + ) { + fadeOut(tween(200)) + } else { + fadeOut(tween(200)) + slideOutHorizontally { it / 2 } + } + }, + modifier = Modifier + .nestedScroll( + if (navigationItems.any { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true) { + searchBarScrollBehavior.nestedScrollConnection + } else { + topAppBarScrollBehavior.nestedScrollConnection + } + ) + ) { + navigationBuilder( + navController, + topAppBarScrollBehavior, + updateAvailable, + playerStyle + ) + } + } else { + NavHost( + navController = navController, + startDestination = when (tabOpenedFromShortcut + ?: defaultOpenTabOld) { + NavigationTabOld.HOME -> Screens.Home + NavigationTabOld.EXPLORE -> Screens.Explore + NavigationTabOld.SONGS -> Screens.Songs + NavigationTabOld.ARTISTS -> Screens.Artists + NavigationTabOld.ALBUMS -> Screens.Albums + NavigationTabOld.PLAYLISTS -> Screens.Playlists + else -> Screens.Library + }.route, + enterTransition = { + if (initialState.destination.route in topLevelScreens && targetState.destination.route in topLevelScreens) { + fadeIn(tween(250)) + } else { + fadeIn(tween(250)) + slideInHorizontally { it / 2 } + } + }, + exitTransition = { + if (initialState.destination.route in topLevelScreens && targetState.destination.route in topLevelScreens) { + fadeOut(tween(200)) + } else { + fadeOut(tween(200)) + slideOutHorizontally { -it / 2 } + } + }, + popEnterTransition = { + if ((initialState.destination.route in topLevelScreens || initialState.destination.route?.startsWith( + "search/" + ) == true) && targetState.destination.route in topLevelScreens + ) { + fadeIn(tween(250)) + } else { + fadeIn(tween(250)) + slideInHorizontally { -it / 2 } + } + }, + popExitTransition = { + if ((initialState.destination.route in topLevelScreens || initialState.destination.route?.startsWith( + "search/" + ) == true) && targetState.destination.route in topLevelScreens + ) { + fadeOut(tween(200)) + } else { + fadeOut(tween(200)) + slideOutHorizontally { it / 2 } + } + }, + modifier = Modifier + .nestedScroll( + if (navigationItems.any { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true) { + searchBarScrollBehavior.nestedScrollConnection + } else { + topAppBarScrollBehavior.nestedScrollConnection + } + ) + ) { + navigationBuilder( + navController, + topAppBarScrollBehavior, + updateAvailable, + playerStyle + ) + } + } + + val currentTitle = remember(navBackStackEntry) { + when (navBackStackEntry?.destination?.route) { + Screens.Home.route -> R.string.home + Screens.Explore.route -> R.string.explore + Screens.Library.route -> R.string.filter_library + Screens.Songs.route -> R.string.songs + Screens.Artists.route -> R.string.artists + Screens.Albums.route -> R.string.albums + Screens.Playlists.route -> R.string.playlists + else -> null + } + } + + if (!active && navBackStackEntry?.destination?.route in topLevelScreens && navBackStackEntry?.destination?.route != "settings") { + TopAppBar( + title = { + Text( + text = currentTitle?.let { stringResource(it) } ?: "", + style = MaterialTheme.typography.titleLarge, + ) + }, + actions = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(end = 8.dp) + ) { + ActionChipButton( + onClick = { navController.navigate("stats") }, + icon = { + Icon( + painter = painterResource(R.drawable.trending_up), + contentDescription = stringResource(R.string.stats) + ) + }, + text = stringResource(R.string.stats) + ) + ActionChipButton( + onClick = { navController.navigate("history") }, + icon = { + Icon( + painter = painterResource(R.drawable.history), + contentDescription = stringResource(R.string.history) + ) + } + ) + ActionChipButton( + onClick = { navController.navigate("settings") }, + icon = { + BadgedBox( + badge = { + if (latestVersionName != BuildConfig.VERSION_NAME) { + Badge() + } + } + ) { + Icon( + painterResource(R.drawable.settings), + contentDescription = stringResource(R.string.settings) + ) + } + } + ) + } + }, + scrollBehavior = searchBarScrollBehavior + ) + } else if (active || navBackStackEntry?.destination?.route?.startsWith("search/") == true) { + TopSearch( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + scrollBehavior = searchBarScrollBehavior, + placeholder = { + Text( + text = stringResource( + when (searchSource) { + SearchSource.LOCAL -> R.string.search_library + SearchSource.ONLINE -> R.string.search_yt_music + } + ) + ) + }, + leadingIcon = { + IconButton( + onClick = { + when { + active -> onActiveChange(false) + !navigationItems.any { it.route == navBackStackEntry?.destination?.route } -> { + navController.navigateUp() + } + + else -> onActiveChange(true) + } + }, + onLongClick = { + when { + active -> {} + !navigationItems.any { it.route == navBackStackEntry?.destination?.route } -> { + navController.backToMain() + } + + else -> {} + } + } + ) { + Icon( + painterResource( + if (active || !navigationItems.any { it.route == navBackStackEntry?.destination?.route }) { + R.drawable.arrow_back + } else { + R.drawable.search + } + ), + contentDescription = null + ) + } + }, + trailingIcon = { + if (active) { + if (query.text.isNotEmpty()) { + IconButton( + onClick = { onQueryChange(TextFieldValue("")) } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + IconButton( + onClick = { + searchSource = searchSource.toggle() + } + ) { + Icon( + painter = painterResource( + when (searchSource) { + SearchSource.LOCAL -> R.drawable.library_music + SearchSource.ONLINE -> R.drawable.language + } + ), + contentDescription = null + ) + } + } else if (navBackStackEntry?.destination?.route in topLevelScreens) { + SettingsIconWithUpdateBadge( + currentVersion = BuildConfig.VERSION_NAME, + onSettingsClick = { navController.navigate("settings") } + ) + } + }, + modifier = + Modifier + .focusRequester(searchBarFocusRequester) + .align(Alignment.TopCenter), + focusRequester = searchBarFocusRequester + ) { + Crossfade( + targetState = searchSource, + label = "", + modifier = Modifier + .fillMaxSize() + .padding(bottom = if (!playerBottomSheetState.isDismissed) MiniPlayerHeight else 0.dp) + .navigationBarsPadding() + ) { searchSource -> + when (searchSource) { + SearchSource.LOCAL -> LocalSearchScreen( + query = query.text, + navController = navController, + onDismiss = { onActiveChange(false) } + ) + + SearchSource.ONLINE -> OnlineSearchScreen( + query = query.text, + onQueryChange = onQueryChange, + navController = navController, + onSearch = { + navController.navigate("search/${it.urlEncode()}") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = it)) + } + } + }, + onDismiss = { onActiveChange(false) } + ) + } + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + + if (firstSetupPassed) { + BottomSheetPlayer( + state = playerBottomSheetState, + navController = navController, + isScrolled = isScrolled + ) + } + val isPlayerExpanded = + playerBottomSheetState.progress > 0.9f + + if (shouldShowNavigationBar && !isPlayerExpanded) { + Box(modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + ) { + LoudlyBottomNavBar( + navController = navController, + currentDestination = navBackStackEntry?.destination, + isScrolled = isScrolled, + onSearchClick = { + onActiveChange(true) + coroutineScope.launch { + delay(100) + searchBarFocusRequester.requestFocus() + } + } + ) + } + } + + BottomSheetMenu( + state = LocalMenuState.current, + modifier = Modifier.align(Alignment.BottomCenter) + ) + sharedSong?.let { song -> + playerConnection?.let { _ -> + Dialog( + onDismissRequest = { sharedSong = null }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = RoundedCornerShape(16.dp), + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = { sharedSong = null } + ) + } + } + } + } + } + } + } + + LaunchedEffect(shouldShowSearchBar, openSearchImmediately) { + if (shouldShowSearchBar && openSearchImmediately) { + onActiveChange(true) + try { + delay(100) + searchBarFocusRequester.requestFocus() + } catch (_: Exception) { + } + openSearchImmediately = false + } + } + } + } + } + } + + private fun setSystemBarAppearance(isDark: Boolean) { + WindowCompat.getInsetsController(window, window.decorView.rootView).apply { + isAppearanceLightStatusBars = !isDark + isAppearanceLightNavigationBars = !isDark + } + } + + private fun handleIncomingIntent(intent: Intent) { + lifecycleScope.launch { + while (playerConnection == null) { + delay(100) + } + + val pc = playerConnection ?: return@launch + + when (intent.action) { + "ACTION_PLAY_SONG" -> { + val songId = intent.getStringExtra("SONG_ID") + if (songId != null) { + val song = withContext(Dispatchers.IO) { + database.song(songId).firstOrNull() + } + if (song != null) { + pc.playQueue(YouTubeQueue.Companion.radio(song.toMediaMetadata())) + } + } + } + + "ACTION_PLAY_CONTEXTUAL_PLAYLIST" -> { + val playlistId = + intent.getStringExtra("PAYLOAD") + if (playlistId != null) { + if (playlistId == ContextualWorker.LIKED_SONGS_FALLBACK_ID) { + val likedSongs = withContext(Dispatchers.IO) { + database.likedSongs(SongSortType.CREATE_DATE, true).firstOrNull() + } + if (!likedSongs.isNullOrEmpty()) { + pc.player.shuffleModeEnabled = true + pc.playQueue( + ListQueue( + title = "Beğenilenler Karışık", + items = likedSongs.map { it.toMediaItem() } + )) + } + } else { + val playlistEntity = withContext(Dispatchers.IO) { + database.getPlaylist(playlistId).firstOrNull() + } + if (playlistEntity != null) { + val songsInPlaylist = withContext(Dispatchers.IO) { + database.playlistSongs(playlistId).firstOrNull() + } + + if (!songsInPlaylist.isNullOrEmpty()) { + pc.player.shuffleModeEnabled = false + pc.playQueue( + ListQueue( + title = playlistEntity.name, + items = songsInPlaylist.map { it.song.toMediaItem() } + )) + } + } + } + } + } + } + } + } + + companion object { + const val ACTION_SEARCH = "com.babelsoftware.loudly.action.SEARCH" + const val ACTION_SONGS = "com.babelsoftware.loudly.action.SONGS" + const val ACTION_ARTISTS = "com.babelsoftware.loudly.action.ARTISTS" + const val ACTION_ALBUMS = "com.babelsoftware.loudly.action.ALBUMS" + const val ACTION_PLAYLISTS = "com.babelsoftware.loudly.action.PLAYLISTS" + } +} + +val LocalDatabase = staticCompositionLocalOf { error("No database provided") } +val LocalPlayerConnection = staticCompositionLocalOf { error("No PlayerConnection provided") } +val LocalPlayerAwareWindowInsets = compositionLocalOf { error("No WindowInsets provided") } +val LocalDownloadUtil = staticCompositionLocalOf { error("No DownloadUtil provided") } +val LocalSyncUtils = staticCompositionLocalOf { error("No SyncUtils provided") } +val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } +@Composable +fun SwitchPreference( + title: @Composable () -> Unit, + icon: @Composable () -> Unit, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.size(24.dp)) { + icon() + } + Spacer(Modifier.width(16.dp)) + Box(Modifier.weight(1f)) { + title() + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } + } +} +@Composable +fun SettingsIconWithUpdateBadge( + currentVersion: String, + onSettingsClick: () -> Unit +) { + var showUpdateBadge by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + val latestVersion = checkForUpdates() + if (latestVersion != null) { + showUpdateBadge = isNewerVersion(latestVersion, currentVersion) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable(onClick = onSettingsClick) + ) { + BadgedBox( + badge = { + if (showUpdateBadge) { + Badge() + } + } + ) { + Icon( + painter = painterResource(R.drawable.settings), + contentDescription = "Configuración" + ) + } + } +} +suspend fun checkForUpdates(): String? = withContext(Dispatchers.IO) { + try { + val url = URL("https://api.github.com/repos/RRechz/Loudly/releases/latest") + val connection = url.openConnection() + connection.connect() + val json = connection.getInputStream().bufferedReader().use { it.readText() } + val jsonObject = JSONObject(json) + return@withContext jsonObject.getString("tag_name") + } catch (e: Exception) { + e.printStackTrace() + return@withContext null + } +} +fun isNewerVersion(remoteVersion: String, currentVersion: String): Boolean { + val remote = remoteVersion.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + val current = currentVersion.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(remote.size, current.size)) { + val r = remote.getOrNull(i) ?: 0 + val c = current.getOrNull(i) ?: 0 + if (r > c) return true + if (r < c) return false + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/MusicWidget.kt b/app/src/main/java/com/babelsoftware/loudly/MusicWidget.kt new file mode 100644 index 0000000..2e1ee31 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/MusicWidget.kt @@ -0,0 +1,169 @@ +package com.babelsoftware.loudly + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.widget.RemoteViews +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ImageRequest +import com.babelsoftware.loudly.playback.PlayerConnection +import com.babelsoftware.loudly.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MusicWidget : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + appWidgetIds.forEach { appWidgetId -> + updateWidget(context, appWidgetManager, appWidgetId) + } + } + + override fun onEnabled(context: Context) { + manageProgressUpdater(context) + } + + override fun onDisabled(context: Context) { + stopProgressUpdater() + } + + // ---> This function is triggered when widget buttons are pressed or a status change is reported. + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + val player = PlayerConnection.instance + + when (intent.action) { + ACTION_PLAY_PAUSE -> player?.togglePlayPause() + ACTION_PREV -> player?.seekToPrevious() + ACTION_NEXT -> player?.seekToNext() + ACTION_STATE_CHANGED -> { /* Only updates are triggered */ } + } + updateAllWidgets(context) + manageProgressUpdater(context) + } + // <--- + + companion object { + private const val ACTION_PLAY_PAUSE = "com.babelsoftware.loudly.ACTION_PLAY_PAUSE" + private const val ACTION_PREV = "com.babelsoftware.loudly.ACTION_PREV" + private const val ACTION_NEXT = "com.babelsoftware.loudly.ACTION_NEXT" + const val ACTION_STATE_CHANGED = "com.babelsoftware.loudly.ACTION_STATE_CHANGED" + + private val handler = Handler(Looper.getMainLooper()) + private var progressUpdater: Runnable? = null + + // ---> Updates all music widgets on the screen. + fun updateAllWidgets(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, MusicWidget::class.java) + appWidgetManager.getAppWidgetIds(componentName)?.forEach { appWidgetId -> + updateWidget(context, appWidgetManager, appWidgetId) + } + } + // <--- + + // ---> The main function that updates a specific widget. + private fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + val views = RemoteViews(context.packageName, R.layout.widget_music) + val player = PlayerConnection.instance?.player + + if (player == null || player.currentMediaItem == null) { + views.setTextViewText(R.id.widget_track_title, "Music Not Playing") + views.setTextViewText(R.id.widget_artist, "") + views.setImageViewResource(R.id.widget_play_pause, R.drawable.play) + views.setImageViewResource(R.id.widget_album_art, R.drawable.widget_album_art_background) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + // 1. Set up information such as text, buttons, and progress that will be updated instantly. + views.setTextViewText(R.id.widget_track_title, player.mediaMetadata.title) + views.setTextViewText(R.id.widget_artist, player.mediaMetadata.artist) + + val playPauseIcon = if (player.isPlaying) R.drawable.pause else R.drawable.play + views.setImageViewResource(R.id.widget_play_pause, playPauseIcon) + + val progress = if (player.duration > 0) (player.currentPosition * 100 / player.duration).toInt() else 0 + views.setProgressBar(R.id.widget_progress, 100, progress, false) + + // 2. Enable the rounded corners feature + views.setBoolean(R.id.widget_album_art, "setClipToOutline", true) + + // 3. Upload the album cover + val imageUrl = player.mediaMetadata.artworkUri + if (imageUrl != null) { + CoroutineScope(Dispatchers.IO).launch { + val request = ImageRequest.Builder(context).data(imageUrl).build() + val bitmap = try { ImageLoader(context).execute(request).drawable?.toBitmap() } catch (e: Exception) { null } + + withContext(Dispatchers.Main) { + if (bitmap != null) { + views.setImageViewBitmap(R.id.widget_album_art, bitmap) + } else { + views.setImageViewResource(R.id.widget_album_art, R.drawable.widget_album_art_background) + } + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } + } else { + views.setImageViewResource(R.id.widget_album_art, R.drawable.widget_album_art_background) + } + + // 4. Set up click events (PendingIntent) + views.setOnClickPendingIntent(R.id.widget_play_pause, getBroadcastPendingIntent(context, ACTION_PLAY_PAUSE)) + views.setOnClickPendingIntent(R.id.widget_prev, getBroadcastPendingIntent(context, ACTION_PREV)) + views.setOnClickPendingIntent(R.id.widget_next, getBroadcastPendingIntent(context, ACTION_NEXT)) + + val openAppIntent = Intent(context, MainActivity::class.java) + val openAppPendingIntent = PendingIntent.getActivity(context, 0, openAppIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + views.setOnClickPendingIntent(R.id.widget_container, openAppPendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, views) // 5. UPDATE the widget + } + // <--- + + private fun getBroadcastPendingIntent(context: Context, action: String): PendingIntent { + val intent = Intent(context, MusicWidget::class.java).apply { this.action = action } + return PendingIntent.getBroadcast( + context, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + // ---> Manages the mechanism that updates the progress bar only when music is playing. + private fun manageProgressUpdater(context: Context) { + stopProgressUpdater() + if (PlayerConnection.instance?.player?.isPlaying == true) { + progressUpdater = Runnable { + updateAllWidgets(context) + if (PlayerConnection.instance?.player?.isPlaying == true) { + handler.postDelayed(progressUpdater!!, 1000) + } + }.also { + handler.post(it) + } + } + } + + private fun stopProgressUpdater() { + progressUpdater?.let { handler.removeCallbacks(it) } + progressUpdater = null + } + // <--- + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/background/ActivityTransitionReceiver.kt b/app/src/main/java/com/babelsoftware/loudly/background/ActivityTransitionReceiver.kt new file mode 100644 index 0000000..bfbb607 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/background/ActivityTransitionReceiver.kt @@ -0,0 +1,30 @@ +/** + * Babel Software 2025 + * + * The concept and features of "Comrade" are wholly owned by Babel Software. + * Are made available for use under an open source license. + */ + +package com.babelsoftware.loudly.background + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.google.android.gms.location.ActivityTransitionResult + +class ActivityTransitionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (ActivityTransitionResult.hasResult(intent)) { + val result = ActivityTransitionResult.extractResult(intent) + result?.transitionEvents?.forEach { event -> + val workRequest = OneTimeWorkRequestBuilder() + .setInputData(workDataOf("ACTIVITY_TYPE" to event.activityType)) + .build() + WorkManager.getInstance(context).enqueue(workRequest) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/background/AwarenessSystem.kt b/app/src/main/java/com/babelsoftware/loudly/background/AwarenessSystem.kt new file mode 100644 index 0000000..1c8b6a5 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/background/AwarenessSystem.kt @@ -0,0 +1,60 @@ +/** + * Babel Software 2025 + * + * The concept and features of "Comrade" are wholly owned by Babel Software. + * Are made available for use under an open source license. + */ + +package com.babelsoftware.loudly.background + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.util.Log +import androidx.core.content.ContextCompat +import com.google.android.gms.location.ActivityRecognition +import com.google.android.gms.location.ActivityTransition +import com.google.android.gms.location.ActivityTransitionRequest +import com.google.android.gms.location.DetectedActivity + +fun setupModernAwarenessSystem(context: Context) { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACTIVITY_RECOGNITION + ) != PackageManager.PERMISSION_GRANTED + ) { + Log.w("ComradeChip", "Activity recognition permission has not been granted. The system could not be started.") + return + } + + // We determine which activities we want to listen to + val transitions = mutableListOf() + + transitions += ActivityTransition.Builder() + .setActivityType(DetectedActivity.IN_VEHICLE) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) + .build() + + transitions += ActivityTransition.Builder() + .setActivityType(DetectedActivity.RUNNING) + .setActivityTransition(ActivityTransition.ACTIVITY_TRANSITION_ENTER) + .build() + + // ... Other activities can be added (WALKING, ON_BICYCLE) + + val request = ActivityTransitionRequest(transitions) + val intent = Intent(context, ActivityTransitionReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + // We are sending our request to Google Play Services + val task = ActivityRecognition.getClient(context).requestActivityTransitionUpdates(request, pendingIntent) + + task.addOnSuccessListener { + Log.d("ComradeChip", "The modern environmental awareness system has been successfully established.") + } + task.addOnFailureListener { e -> + Log.e("ComradeChip", "Modern system could not be set up.", e) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/background/ContextualWorker.kt b/app/src/main/java/com/babelsoftware/loudly/background/ContextualWorker.kt new file mode 100644 index 0000000..8bb9c09 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/background/ContextualWorker.kt @@ -0,0 +1,63 @@ +/** + * Babel Software 2025 + * + * The concept and features of "Comrade" are wholly owned by Babel Software. + * Are made available for use under an open source license. + */ + +package com.babelsoftware.loudly.background + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.utils.NotificationHelper +import com.google.android.gms.location.DetectedActivity +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.firstOrNull + +@HiltWorker +class ContextualWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val database: MusicDatabase +) : CoroutineWorker(context, workerParams) { + + companion object { + const val LIKED_SONGS_FALLBACK_ID = "LIKED_SONGS_SHUFFLE_FALLBACK" + } + + override suspend fun doWork(): Result { + val activityType = inputData.getInt("ACTIVITY_TYPE", -1) + + when (activityType) { + DetectedActivity.IN_VEHICLE -> { + val title = context.getString(R.string.notification_vehicle_title) + val text = context.getString(R.string.notification_vehicle_text) + val playlist = database.findPlaylistByName("%Yol%").firstOrNull() + ?: database.findPlaylistByName("%Drive%").firstOrNull() + + val payloadId = playlist?.id ?: LIKED_SONGS_FALLBACK_ID + NotificationHelper.showContextualNotification( + context, title, text, "ACTION_PLAY_CONTEXTUAL_PLAYLIST", payloadId + ) + } + DetectedActivity.RUNNING -> { + val title = context.getString(R.string.notification_running_title) + val text = context.getString(R.string.notification_running_text) + val playlist = database.findPlaylistByName("%Koşu%").firstOrNull() + ?: database.findPlaylistByName("%Run%").firstOrNull() + + val payloadId = playlist?.id ?: LIKED_SONGS_FALLBACK_ID + NotificationHelper.showContextualNotification( + context, title, text, "ACTION_PLAY_CONTEXTUAL_PLAYLIST", payloadId + ) + } + } + + return Result.success() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/background/HeadsetPlugReceiver.kt b/app/src/main/java/com/babelsoftware/loudly/background/HeadsetPlugReceiver.kt new file mode 100644 index 0000000..24205ef --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/background/HeadsetPlugReceiver.kt @@ -0,0 +1,53 @@ +/** + * Babel Software 2025 + * + * The concept and features of "Comrade" are wholly owned by Babel Software. + * Are made available for use under an open source license. + */ + +package com.babelsoftware.loudly.background + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.utils.NotificationHelper +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +class HeadsetPlugReceiver : BroadcastReceiver() { + @EntryPoint + @InstallIn(SingletonComponent::class) + interface HeadsetPlugReceiverEntryPoint { + fun database(): MusicDatabase + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_HEADSET_PLUG) return + + val state = intent.getIntExtra("state", -1) + if (state == 1) { + val hiltEntryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + HeadsetPlugReceiverEntryPoint::class.java + ) + val database = hiltEntryPoint.database() + + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + val lastPlayedEntry = database.getLatestHistoryEntry().firstOrNull() + NotificationHelper.showHeadsetPlugNotification(context, lastPlayedEntry?.song) + } finally { + pendingResult.finish() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/constants/Dimensions.kt b/app/src/main/java/com/babelsoftware/loudly/constants/Dimensions.kt new file mode 100644 index 0000000..d641f05 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/constants/Dimensions.kt @@ -0,0 +1,35 @@ +package com.babelsoftware.loudly.constants + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +const val CONTENT_TYPE_HEADER = 0 +const val CONTENT_TYPE_LIST = 1 +const val CONTENT_TYPE_SONG = 2 +const val CONTENT_TYPE_ARTIST = 3 +const val CONTENT_TYPE_ALBUM = 4 +const val CONTENT_TYPE_PLAYLIST = 5 + +val NavigationBarHeight = 78.dp +val MiniPlayerHeight = 66.dp +val QueuePeekHeight = 64.dp +val AppBarHeight = 64.dp + +val ListItemHeight = 64.dp +val SuggestionItemHeight = 56.dp +val SearchFilterHeight = 48.dp +val ListThumbnailSize = 48.dp +val GridThumbnailHeight = 128.dp +val SmallGridThumbnailHeight = 92.dp + +val AlbumThumbnailSize = 234.dp + +val ThumbnailCornerRadius = 6.dp + +val PlayerHorizontalPadding = 32.dp + +val NavigationBarAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) + +val DialogCornerRadius = 32.dp diff --git a/app/src/main/java/com/babelsoftware/loudly/constants/HistorySource.kt b/app/src/main/java/com/babelsoftware/loudly/constants/HistorySource.kt new file mode 100644 index 0000000..8e2d4d6 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/constants/HistorySource.kt @@ -0,0 +1,5 @@ +package com.babelsoftware.loudly.constants + +enum class HistorySource { + LOCAL, REMOTE +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/constants/MediaSessionConstants.kt b/app/src/main/java/com/babelsoftware/loudly/constants/MediaSessionConstants.kt new file mode 100644 index 0000000..c007ffa --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/constants/MediaSessionConstants.kt @@ -0,0 +1,17 @@ +package com.babelsoftware.loudly.constants + +import android.os.Bundle +import androidx.media3.session.SessionCommand + +object MediaSessionConstants { + const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY" + const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE" + const val ACTION_TOGGLE_START_RADIO = "TOGGLE_START_RADIO" + const val ACTION_TOGGLE_SHUFFLE = "TOGGLE_SHUFFLE" + const val ACTION_TOGGLE_REPEAT_MODE = "TOGGLE_REPEAT_MODE" + val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY) + val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) + val CommandToggleStartRadio = SessionCommand(ACTION_TOGGLE_START_RADIO, Bundle.EMPTY) + val CommandToggleShuffle = SessionCommand(ACTION_TOGGLE_SHUFFLE, Bundle.EMPTY) + val CommandToggleRepeatMode = SessionCommand(ACTION_TOGGLE_REPEAT_MODE, Bundle.EMPTY) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/constants/PreferenceKeys.kt b/app/src/main/java/com/babelsoftware/loudly/constants/PreferenceKeys.kt new file mode 100644 index 0000000..6a9c574 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/constants/PreferenceKeys.kt @@ -0,0 +1,504 @@ +package com.babelsoftware.loudly.constants + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import java.time.LocalDateTime +import java.time.ZoneOffset + +val AppDesignVariantKey = stringPreferencesKey("appDesignVariant") +val DynamicThemeKey = booleanPreferencesKey("dynamicTheme") +val DarkModeKey = stringPreferencesKey("darkMode") +val PureBlackKey = booleanPreferencesKey("pureBlack") +val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") +val LyricFontSizeKey = intPreferencesKey("lyrics_font_size") +val LyricsBackgroundDimKey = intPreferencesKey("lyrics_background_dim") +val SliderStyleKey = stringPreferencesKey("sliderStyle") +val SwipeThumbnailKey = booleanPreferencesKey("swipeThumbnail") +val minPlaybackDurKey = intPreferencesKey("minPlaybackDur") +val ThumbnailCornerRadiusV2Key = intPreferencesKey("cornerRadius") + +val fullScreenLyricsKey = booleanPreferencesKey("fullScreenLyrics") + +val showNoInternetDialogKey = booleanPreferencesKey("showNoInternetDialog") + +val ShowContentFilterKey = booleanPreferencesKey("showContentFilter") +val ShowRecentActivityKey = booleanPreferencesKey("showRecentActivity") + +val HeaderImageKey = stringPreferencesKey("header_image_key") + +enum class AppDesignVariantType { + NEW, OLD +} +val UiPresetKey = stringPreferencesKey("ui_preset_selection") + +enum class SliderStyle { + DEFAULT, SQUIGGLY, COMPOSE +} + +val AutoPlaylistsCustomizationKey = booleanPreferencesKey("autoPlaylistsCustomization") +val AutoPlaylistLikedShowKey = booleanPreferencesKey("autoPlaylistLikedShow") +val AutoPlaylistDownloadShowKey = booleanPreferencesKey("autoPlaylistDownloadShow") +val AutoPlaylistTopPlaylistShowKey = booleanPreferencesKey("autoPlaylistTopPlaylistShow") +val AutoPlaylistCachedPlaylistShowKey = booleanPreferencesKey("autoPlaylistCachedPlaylistShow") +val AutoPlaylistLocalPlaylistShowKey = booleanPreferencesKey("autoPlaylistLocalPlaylistShow") +val SwipeSongToDismissKey = booleanPreferencesKey("swipe_song_to_dismiss") +val DefaultOpenTabOldKey = stringPreferencesKey("defaultOpenTabOld") +val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab") +val GridCellSizeKey = stringPreferencesKey("gridCellSize") + +enum class GridCellSize { + SMALL, BIG +} + +const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT" +val LikedAutoDownloadKey = stringPreferencesKey("likedAutoDownloadKey") +val ContentLanguageKey = stringPreferencesKey("contentLanguage") +val ContentCountryKey = stringPreferencesKey("contentCountry") +val SelectedLanguageKey = stringPreferencesKey("selectedLanguage") +val EnableKugouKey = booleanPreferencesKey("enableKugou") +val MultilineLrcKey = booleanPreferencesKey("multilineLrc") +val LyricTrimKey = booleanPreferencesKey("lyricTrim") +val EnableLrcLibKey = booleanPreferencesKey("enableLrcLib") +val HideExplicitKey = booleanPreferencesKey("hideExplicit") +val ProxyEnabledKey = booleanPreferencesKey("proxyEnabled") +val ProxyUrlKey = stringPreferencesKey("proxyUrl") +val ProxyTypeKey = stringPreferencesKey("proxyType") +val YtmSyncKey = booleanPreferencesKey("ytmSync") +val ShowHeaderCardTextKey = booleanPreferencesKey("show_header_card_text") +val HeaderCardAlignmentKey = stringPreferencesKey("header_card_alignment") +val HeaderCardGradientIntensityKey = floatPreferencesKey("header_card_gradient_intensity") +val HeaderCardCornerRadiusKey = intPreferencesKey("header_card_corner_radius") +val HeaderCardProfilePictureUriKey = stringPreferencesKey("header_card_profile_picture_uri") + +val AudioQualityKey = stringPreferencesKey("audioQuality") + +enum class AudioQuality { + AUTO, MAX, HIGH, LOW +} + +val AudioOffload = booleanPreferencesKey("enableOffload") + +val PersistentQueueKey = booleanPreferencesKey("persistentQueue") +val SkipSilenceKey = booleanPreferencesKey("skipSilence") +val AudioNormalizationKey = booleanPreferencesKey("audioNormalization") +val AutoPlaySongWhenBluetoothDeviceConnectedKey = booleanPreferencesKey("autoPlaySongWhenBluetoothDeviceConnected") +val StopPlayingSongWhenMinimumVolumeKey = booleanPreferencesKey("stopPlayingSongWhenMinimumVolume") +val AddingPlayedSongsToYTMHistoryKey = booleanPreferencesKey("addingPlayedSongsToYTMHistory") +val AutoSkipNextOnErrorKey = booleanPreferencesKey("autoSkipNextOnError") +val AutoLoadMoreKey = booleanPreferencesKey("autoLoadMore") +val StopMusicOnTaskClearKey = booleanPreferencesKey("stopMusicOnTaskClear") +val KeepAliveKey = booleanPreferencesKey("keepAlive") +val SlimNavBarKey = booleanPreferencesKey("slimNavBar") + +val MaxImageCacheSizeKey = intPreferencesKey("maxImageCacheSize") +val MaxSongCacheSizeKey = intPreferencesKey("maxSongCacheSize") + +val PauseListenHistoryKey = booleanPreferencesKey("pauseListenHistory") +val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory") +val UseLoginForBrowse = booleanPreferencesKey("useLoginForBrowse") +val DisableScreenshotKey = booleanPreferencesKey("disableScreenshot") + +val DiscordTokenKey = stringPreferencesKey("discordToken") +val DiscordUsernameKey = stringPreferencesKey("discordUsername") +val DiscordNameKey = stringPreferencesKey("discordName") +val EnableDiscordRPCKey = booleanPreferencesKey("discordRPCEnable") + +val ChipSortTypeKey = stringPreferencesKey("chipSortType") +val SongSortTypeKey = stringPreferencesKey("songSortType") +val SongSortDescendingKey = booleanPreferencesKey("songSortDescending") +val PlaylistSongSortTypeKey = stringPreferencesKey("playlistSongSortType") +val PlaylistSongSortDescendingKey = booleanPreferencesKey("playlistSongSortDescending") +val ArtistSortTypeKey = stringPreferencesKey("artistSortType") +val ArtistSortDescendingKey = booleanPreferencesKey("artistSortDescending") +val AlbumSortTypeKey = stringPreferencesKey("albumSortType") +val AlbumSortDescendingKey = booleanPreferencesKey("albumSortDescending") +val PlaylistSortTypeKey = stringPreferencesKey("playlistSortType") +val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") +val ArtistSongSortTypeKey = stringPreferencesKey("artistSongSortType") +val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescending") +val MixSortTypeKey = stringPreferencesKey("mixSortType") +val MixSortDescendingKey = booleanPreferencesKey("albumSortDescending") +val MixViewTypeKey = stringPreferencesKey("mixViewType") + +val SongFilterKey = stringPreferencesKey("songFilter") +val ArtistFilterKey = stringPreferencesKey("artistFilter") +val ArtistViewTypeKey = stringPreferencesKey("artistViewType") +val AlbumFilterKey = stringPreferencesKey("albumFilter") +val AlbumViewTypeKey = stringPreferencesKey("albumViewType") +val PlaylistViewTypeKey = stringPreferencesKey("playlistViewType") +val PreferredLyricsProviderKey = stringPreferencesKey("lyricsProvider") + +val FirstSetupPassed = booleanPreferencesKey("firstSetupPassed") + +val PlaylistEditLockKey = booleanPreferencesKey("playlistEditLock") + +enum class LibraryViewType { + LIST, GRID; + + fun toggle() = when (this) { + LIST -> GRID + GRID -> LIST + } +} + +enum class MixViewType { + LIST, GRID; + + fun toggle() = when (this) { + LIST -> GRID + GRID -> LIST + } +} + +enum class MixSortType { + CREATE_DATE, + NAME, + LAST_UPDATED, +} + +enum class PreferredLyricsProvider { + KUGOU, LRCLIB +} + +enum class SongSortType { + CREATE_DATE, NAME, ARTIST, PLAY_TIME +} + +enum class PlaylistSongSortType { + CUSTOM, CREATE_DATE, NAME, ARTIST, PLAY_TIME +} + +enum class ArtistSortType { + CREATE_DATE, NAME, SONG_COUNT, PLAY_TIME +} + +enum class ArtistSongSortType { + CREATE_DATE, NAME, PLAY_TIME +} + +enum class AlbumSortType { + CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH, PLAY_TIME +} + +enum class PlaylistSortType { + CREATE_DATE, NAME, SONG_COUNT +} + +enum class SongFilter { + LIBRARY, LIKED, DOWNLOADED, CACHED +} + +enum class ArtistFilter { + LIBRARY, LIKED +} + +enum class AlbumFilter { + LIBRARY, LIKED +} + +enum class LibraryFilter { + SONGS, ARTISTS, ALBUMS, PLAYLISTS , LIBRARY +} + +enum class MyTopFilter { + ALL_TIME, + DAY, + WEEK, + MONTH, + YEAR, + ; + + fun toTimeMillis(): Long = + when (this) { + DAY -> + LocalDateTime + .now() + .minusDays(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + WEEK -> + LocalDateTime + .now() + .minusWeeks(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + MONTH -> + LocalDateTime + .now() + .minusMonths(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + YEAR -> + LocalDateTime + .now() + .minusMonths(12) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + ALL_TIME -> 0 + } +} + +enum class PlayerStyle { + OLD, + NEW, + UI_2_0 +} + +val MiniPlayerActionButtonKey = stringPreferencesKey("miniPlayerActionButton") +enum class MiniPlayerAction { + Like, + Next, + Previous, + Download, + None +} + +enum class PlayerBackgroundStyle { + DEFAULT, + GRADIENT, + BLUR, + BLURMOV +} + +val PlayerBackgroundStyleKey = stringPreferencesKey("playerBackgroundStyle") +val PlayerStyleKey = stringPreferencesKey("playerStyle") +val ShowLyricsKey = booleanPreferencesKey("showLyrics") +val TranslateLyricsKey = booleanPreferencesKey("translateLyrics") +val LockQueueKey = booleanPreferencesKey("lockQueue") + +val ComradeNotificationsEnabledKey = booleanPreferencesKey("comrade_notifications_enabled") +val ComradeVehicleDetectionEnabledKey = booleanPreferencesKey("comrade_vehicle_detection_enabled") +val ComradeActivityDetectionEnabledKey = booleanPreferencesKey("comrade_activity_detection_enabled") + +val PlayerVolumeKey = floatPreferencesKey("playerVolume") +val RepeatModeKey = intPreferencesKey("repeatMode") + +val SearchSourceKey = stringPreferencesKey("searchSource") + +enum class SearchSource { + LOCAL, ONLINE; + + fun toggle() = when (this) { + LOCAL -> ONLINE + ONLINE -> LOCAL + } +} + +enum class LikedAutodownloadMode { + OFF, ON, WIFI_ONLY +} + +val VisitorDataKey = stringPreferencesKey("visitorData") +val InnerTubeCookieKey = stringPreferencesKey("innerTubeCookie") +val AccountNameKey = stringPreferencesKey("accountName") +val AccountEmailKey = stringPreferencesKey("accountEmail") +val AccountChannelHandleKey = stringPreferencesKey("accountChannelHandle") + +val ScannerSensitivityKey = stringPreferencesKey("scannerSensitivity") +val AutoSyncLocalSongsKey = booleanPreferencesKey("autosynclocalsongs") +val FlatSubfoldersKey = booleanPreferencesKey("flatSubfolders") + +/** + * Specify how strict the metadata scanner should be + */ +enum class ScannerSensitivity { + LEVEL_1, + LEVEL_2, + LEVEL_3, +} + +val ScannerStrictExtKey = booleanPreferencesKey("scannerStrictExt") + +val LanguageCodeToName = mapOf( + "af" to "Afrikaans", + "az" to "Azərbaycan", + "id" to "Bahasa Indonesia", + "ms" to "Bahasa Malaysia", + "ca" to "Català", + "cs" to "Čeština", + "da" to "Dansk", + "de" to "Deutsch", + "et" to "Eesti", + "en-GB" to "English (UK)", + "en" to "English (US)", + "es" to "Español (España)", + "es-419" to "Español (Latinoamérica)", + "eu" to "Euskara", + "fil" to "Filipino", + "fr" to "Français", + "fr-CA" to "Français (Canada)", + "gl" to "Galego", + "hr" to "Hrvatski", + "zu" to "IsiZulu", + "is" to "Íslenska", + "it" to "Italiano", + "sw" to "Kiswahili", + "lt" to "Lietuvių", + "hu" to "Magyar", + "nl" to "Nederlands", + "no" to "Norsk", + "or" to "Odia", + "uz" to "O‘zbe", + "pl" to "Polski", + "pt-PT" to "Português", + "pt" to "Português (Brasil)", + "ro" to "Română", + "sq" to "Shqip", + "sk" to "Slovenčina", + "sl" to "Slovenščina", + "fi" to "Suomi", + "sv" to "Svenska", + "bo" to "Tibetan བོད་སྐད།", + "vi" to "Tiếng Việt", + "tr" to "Türkçe", + "bg" to "Български", + "ky" to "Кыргызча", + "kk" to "Қазақ Тілі", + "mk" to "Македонски", + "mn" to "Монгол", + "ru" to "Русский", + "sr" to "Српски", + "uk" to "Українська", + "el" to "Ελληνικά", + "hy" to "Հայերեն", + "iw" to "עברית", + "ur" to "اردو", + "ar" to "العربية", + "fa" to "فارسی", + "ne" to "नेपाली", + "mr" to "मराठी", + "hi" to "हिन्दी", + "bn" to "বাংলা", + "pa" to "ਪੰਜਾਬੀ", + "gu" to "ગુજરાતી", + "ta" to "தமிழ்", + "te" to "తెలుగు", + "kn" to "ಕನ್ನಡ", + "ml" to "മലയാളം", + "si" to "සිංහල", + "th" to "ภาษาไทย", + "lo" to "ລາວ", + "my" to "ဗမာ", + "ka" to "ქართული", + "am" to "አማርኛ", + "km" to "ខ្មែរ", + "zh-CN" to "中文 (简体)", + "zh-TW" to "中文 (繁體)", + "zh-HK" to "中文 (香港)", + "ja" to "日本語", + "ko" to "한국어", +) + +val CountryCodeToName = mapOf( + "DZ" to "Algeria", + "AR" to "Argentina", + "AU" to "Australia", + "AT" to "Austria", + "AZ" to "Azerbaijan", + "BH" to "Bahrain", + "BD" to "Bangladesh", + "BY" to "Belarus", + "BE" to "Belgium", + "BO" to "Bolivia", + "BA" to "Bosnia and Herzegovina", + "BR" to "Brazil", + "BG" to "Bulgaria", + "KH" to "Cambodia", + "CA" to "Canada", + "CL" to "Chile", + "HK" to "Hong Kong", + "CO" to "Colombia", + "CR" to "Costa Rica", + "HR" to "Croatia", + "CY" to "Cyprus", + "CZ" to "Czech Republic", + "DK" to "Denmark", + "DO" to "Dominican Republic", + "EC" to "Ecuador", + "EG" to "Egypt", + "SV" to "El Salvador", + "EE" to "Estonia", + "FI" to "Finland", + "FR" to "France", + "GE" to "Georgia", + "DE" to "Germany", + "GH" to "Ghana", + "GR" to "Greece", + "GT" to "Guatemala", + "HN" to "Honduras", + "HU" to "Hungary", + "IS" to "Iceland", + "IN" to "India", + "ID" to "Indonesia", + "IQ" to "Iraq", + "IE" to "Ireland", + "IL" to "Israel", + "IT" to "Italy", + "JM" to "Jamaica", + "JP" to "Japan", + "JO" to "Jordan", + "KZ" to "Kazakhstan", + "KE" to "Kenya", + "KR" to "South Korea", + "KW" to "Kuwait", + "LA" to "Lao", + "LV" to "Latvia", + "LB" to "Lebanon", + "LY" to "Libya", + "LI" to "Liechtenstein", + "LT" to "Lithuania", + "LU" to "Luxembourg", + "MK" to "Macedonia", + "MY" to "Malaysia", + "MT" to "Malta", + "MX" to "Mexico", + "ME" to "Montenegro", + "MA" to "Morocco", + "NP" to "Nepal", + "NL" to "Netherlands", + "NZ" to "New Zealand", + "NI" to "Nicaragua", + "NG" to "Nigeria", + "NO" to "Norway", + "OM" to "Oman", + "PK" to "Pakistan", + "PA" to "Panama", + "PG" to "Papua New Guinea", + "PY" to "Paraguay", + "PE" to "Peru", + "PH" to "Philippines", + "PL" to "Poland", + "PT" to "Portugal", + "PR" to "Puerto Rico", + "QA" to "Qatar", + "RO" to "Romania", + "RU" to "Russian Federation", + "SA" to "Saudi Arabia", + "SN" to "Senegal", + "RS" to "Serbia", + "SG" to "Singapore", + "SK" to "Slovakia", + "SI" to "Slovenia", + "ZA" to "South Africa", + "ES" to "Spain", + "LK" to "Sri Lanka", + "SE" to "Sweden", + "CH" to "Switzerland", + "TW" to "Taiwan", + "TZ" to "Tanzania", + "TH" to "Thailand", + "TN" to "Tunisia", + "TR" to "Turkey", + "UG" to "Uganda", + "UA" to "Ukraine", + "AE" to "United Arab Emirates", + "GB" to "United Kingdom", + "US" to "United States", + "UY" to "Uruguay", + "VE" to "Venezuela (Bolivarian Republic)", + "VN" to "Vietnam", + "YE" to "Yemen", + "ZW" to "Zimbabwe", +) diff --git a/app/src/main/java/com/babelsoftware/loudly/constants/StatPeriod.kt b/app/src/main/java/com/babelsoftware/loudly/constants/StatPeriod.kt new file mode 100644 index 0000000..90c08ef --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/constants/StatPeriod.kt @@ -0,0 +1,18 @@ +package com.babelsoftware.loudly.constants + +import java.time.LocalDateTime +import java.time.ZoneOffset + +enum class StatPeriod { + `1_WEEK`, `1_MONTH`, `3_MONTH`, `6_MONTH`, `1_YEAR`, ALL; + + fun toTimeMillis(): Long = + when (this) { + `1_WEEK` -> LocalDateTime.now().minusWeeks(1).toInstant(ZoneOffset.UTC).toEpochMilli() + `1_MONTH` -> LocalDateTime.now().minusMonths(1).toInstant(ZoneOffset.UTC).toEpochMilli() + `3_MONTH` -> LocalDateTime.now().minusMonths(3).toInstant(ZoneOffset.UTC).toEpochMilli() + `6_MONTH` -> LocalDateTime.now().minusMonths(6).toInstant(ZoneOffset.UTC).toEpochMilli() + `1_YEAR` -> LocalDateTime.now().minusMonths(12).toInstant(ZoneOffset.UTC).toEpochMilli() + ALL -> 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/Converters.kt b/app/src/main/java/com/babelsoftware/loudly/db/Converters.kt new file mode 100644 index 0000000..aac46cd --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/Converters.kt @@ -0,0 +1,17 @@ +package com.babelsoftware.loudly.db + +import androidx.room.TypeConverter +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +class Converters { + @TypeConverter + fun fromTimestamp(value: Long?): LocalDateTime? = + if (value != null) LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC) + else null + + @TypeConverter + fun dateToTimestamp(date: LocalDateTime?): Long? = + date?.atZone(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/DatabaseDao.kt b/app/src/main/java/com/babelsoftware/loudly/db/DatabaseDao.kt new file mode 100644 index 0000000..f72b0be --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/DatabaseDao.kt @@ -0,0 +1,964 @@ +package com.babelsoftware.loudly.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Transaction +import androidx.room.Update +import androidx.room.Upsert +import androidx.sqlite.db.SupportSQLiteQuery +import com.babelsoftware.loudly.constants.AlbumSortType +import com.babelsoftware.loudly.constants.ArtistSongSortType +import com.babelsoftware.loudly.constants.ArtistSortType +import com.babelsoftware.loudly.constants.PlaylistSortType +import com.babelsoftware.loudly.constants.SongSortType +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.AlbumArtistMap +import com.babelsoftware.loudly.db.entities.AlbumEntity +import com.babelsoftware.loudly.db.entities.AlbumWithSongs +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.db.entities.Event +import com.babelsoftware.loudly.db.entities.EventWithSong +import com.babelsoftware.loudly.db.entities.FormatEntity +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.db.entities.PlaylistSong +import com.babelsoftware.loudly.db.entities.PlaylistSongMap +import com.babelsoftware.loudly.db.entities.RecentActivityEntity +import com.babelsoftware.loudly.db.entities.RecentActivityType +import com.babelsoftware.loudly.db.entities.RelatedSongMap +import com.babelsoftware.loudly.db.entities.SearchHistory +import com.babelsoftware.loudly.db.entities.SetVideoIdEntity +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.db.entities.SongAlbumMap +import com.babelsoftware.loudly.db.entities.SongArtistMap +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.extensions.reversed +import com.babelsoftware.loudly.extensions.toSQLiteQuery +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.ui.utils.resize +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.YTItem +import com.babelsoftware.innertube.pages.AlbumPage +import com.babelsoftware.innertube.pages.ArtistPage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.time.LocalDateTime +import kotlin.collections.forEach + +@Dao +interface DatabaseDao { + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY rowId") + fun songsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY inLibrary") + fun songsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY title") + fun songsByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY totalPlayTime") + fun songsByPlayTimeAsc(): Flow> + + fun songs(sortType: SongSortType, descending: Boolean) = + when (sortType) { + SongSortType.CREATE_DATE -> songsByCreateDateAsc() + SongSortType.NAME -> songsByNameAsc() + SongSortType.ARTIST -> songsByRowIdAsc().map { songs -> + songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + } + + SongSortType.PLAY_TIME -> songsByPlayTimeAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY rowId") + fun likedSongsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY inLibrary") + fun likedSongsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY title") + fun likedSongsByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY totalPlayTime") + fun likedSongsByPlayTimeAsc(): Flow> + + fun likedSongs(sortType: SongSortType, descending: Boolean) = + when (sortType) { + SongSortType.CREATE_DATE -> likedSongsByCreateDateAsc() + SongSortType.NAME -> likedSongsByNameAsc() + SongSortType.ARTIST -> likedSongsByRowIdAsc().map { songs -> + songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + } + + SongSortType.PLAY_TIME -> likedSongsByPlayTimeAsc() + }.map { it.reversed(descending) } + + @Query("SELECT COUNT(1) FROM song WHERE liked") + fun likedSongsCount(): Flow + + @Transaction + @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId") + fun albumSongs(albumId: String): Flow> + + @Transaction + @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId ORDER BY position") + fun playlistSongs(playlistId: String): Flow> + + @Transaction + @Query("DELETE FROM playlist WHERE browseId = :browseId") + fun deletePlaylistById(browseId: String) + + @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position") + fun songMapsToPlaylist(playlistId: String, from: Int): List + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY inLibrary") + fun artistSongsByCreateDateAsc(artistId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY title") + fun artistSongsByNameAsc(artistId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY totalPlayTime") + fun artistSongsByPlayTimeAsc(artistId: String): Flow> + + fun artistSongs(artistId: String, sortType: ArtistSongSortType, descending: Boolean) = + when (sortType) { + ArtistSongSortType.CREATE_DATE -> artistSongsByCreateDateAsc(artistId) + ArtistSongSortType.NAME -> artistSongsByNameAsc(artistId) + ArtistSongSortType.PLAY_TIME -> artistSongsByPlayTimeAsc(artistId) + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL LIMIT :previewSize") + fun artistSongsPreview(artistId: String, previewSize: Int = 3): Flow> + + @Transaction + @Query( + """ + SELECT song.* + FROM (SELECT *, COUNT(1) AS referredCount + FROM related_song_map + GROUP BY relatedSongId) map + JOIN song ON song.id = map.relatedSongId + WHERE songId IN (SELECT songId + FROM (SELECT songId + FROM event + ORDER BY ROWID DESC + LIMIT 5) + UNION + SELECT songId + FROM (SELECT songId + FROM event + WHERE timestamp > :now - 86400000 * 7 + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT 5) + UNION + SELECT id + FROM (SELECT id + FROM song + ORDER BY totalPlayTime DESC + LIMIT 10)) + ORDER BY referredCount DESC + LIMIT 100 + """ + ) + fun quickPicks(now: Long = System.currentTimeMillis()): Flow> + + @Transaction + @Query( + """ + SELECT song.* + FROM (SELECT n.songId AS eid, + SUM(playTime) AS oldPlayTime, + newPlayTime + FROM event + JOIN + (SELECT songId, SUM(playTime) AS newPlayTime + FROM event + WHERE timestamp > (:now - 86400000 * 30 * 1) + GROUP BY songId + ORDER BY newPlayTime) as n + ON event.songId = n.songId + WHERE timestamp < (:now - 86400000 * 30 * 1) + GROUP BY n.songId + ORDER BY oldPlayTime) AS t + JOIN song on song.id = t.eid + WHERE 0.2 * t.oldPlayTime > t.newPlayTime + LIMIT 100 + """ + ) + fun forgottenFavorites(now: Long = System.currentTimeMillis()): Flow> + + @Transaction + @Query(""" + SELECT s.*, COUNT(e.songId) as playCount + FROM song as s + JOIN event as e ON s.id = e.songId + WHERE e.timestamp > :fromTimeStamp + GROUP BY s.id + ORDER BY playCount DESC + LIMIT :limit +""") + fun mostPlayedSongsWithPlayCount(fromTimeStamp: Long, limit: Int): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN song ON song_artist_map.songId = song.id + WHERE artistId = artist.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM artist + JOIN(SELECT artistId, SUM(songTotalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN (SELECT songId, SUM(playTime) AS songTotalPlayTime + FROM event + WHERE timestamp > :fromTimeStamp + GROUP BY songId) AS e + ON song_artist_map.songId = e.songId + GROUP BY artistId + ORDER BY totalPlayTime DESC + LIMIT :limit) + ON artist.id = artistId + """ + ) + fun mostPlayedArtists(fromTimeStamp: Long, limit: Int = 6): Flow> + + @Transaction + @Query( + """ + SELECT * + FROM album + WHERE id IN (SELECT song.albumId + FROM event + JOIN + song + ON event.songId = song.id + WHERE event.timestamp > :fromTimeStamp + GROUP BY song.albumId + HAVING song.albumId IS NOT NULL + ORDER BY sum(event.playTime) DESC + LIMIT :limit OFFSET :offset) + """ + ) + fun mostPlayedAlbums(fromTimeStamp: Long, limit: Int = 6, offset: Int = 0): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE id = :songId") + fun song(songId: String?): Flow + + @Query("SELECT * FROM song_artist_map WHERE songId = :songId") + fun songArtistMap(songId: String): List + + @Transaction + @Query("SELECT * FROM song WHERE id IN (:songIds)") + suspend fun getSongsByIds(songIds: List): List + + @Transaction + @Query("SELECT * FROM song WHERE liked AND dateDownload IS NULL") + fun likedSongsNotDownloaded(): Flow> + + @Transaction + @Query("SELECT * FROM song") + fun allSongs(): Flow> + + + @Transaction + @Query("SELECT * FROM song WHERE isLocal = 1") + fun allLocalSongsData(): Flow> + + @Query("SELECT * FROM set_video_id WHERE videoId = :videoId") + suspend fun getSetVideoId(videoId: String): SetVideoIdEntity? + + @Transaction + @Query("SELECT * FROM format WHERE id = :id") + fun format(id: String?): Flow + + @Query("SELECT * FROM lyrics WHERE id = :id") + fun lyrics(id: String?): Flow + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY rowId") + fun artistsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY name") + fun artistsByNameAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY songCount") + fun artistsBySongCountAsc(): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN song ON song_artist_map.songId = song.id + WHERE artistId = artist.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM artist + JOIN(SELECT artistId, SUM(totalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN song + ON song_artist_map.songId = song.id + GROUP BY artistId + ORDER BY totalPlayTime) + ON artist.id = artistId + WHERE songCount > 0 + """ + ) + fun artistsByPlayTimeAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt") + fun artistsBookmarkedByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY name") + fun artistsBookmarkedByNameAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") + fun artistsBookmarkedBySongCountAsc(): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN song ON song_artist_map.songId = song.id + WHERE artistId = artist.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM artist + JOIN(SELECT artistId, SUM(totalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN song + ON song_artist_map.songId = song.id + GROUP BY artistId + ORDER BY totalPlayTime) + ON artist.id = artistId + WHERE bookmarkedAt IS NOT NULL + """ + ) + fun artistsBookmarkedByPlayTimeAsc(): Flow> + + fun artists(sortType: ArtistSortType, descending: Boolean) = + when (sortType) { + ArtistSortType.CREATE_DATE -> artistsByCreateDateAsc() + ArtistSortType.NAME -> artistsByNameAsc() + ArtistSortType.SONG_COUNT -> artistsBySongCountAsc() + ArtistSortType.PLAY_TIME -> artistsByPlayTimeAsc() + }.map { artists -> + artists + .filter { it.artist.isYouTubeArtist } + .reversed(descending) + } + + fun artistsBookmarked(sortType: ArtistSortType, descending: Boolean) = + when (sortType) { + ArtistSortType.CREATE_DATE -> artistsBookmarkedByCreateDateAsc() + ArtistSortType.NAME -> artistsBookmarkedByNameAsc() + ArtistSortType.SONG_COUNT -> artistsBookmarkedBySongCountAsc() + ArtistSortType.PLAY_TIME -> artistsBookmarkedByPlayTimeAsc() + }.map { artists -> + artists + .filter { it.artist.isYouTubeArtist } + .reversed(descending) + } + + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE id = :id") + fun artist(id: String): Flow + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY rowId") + fun albumsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY title") + fun albumsByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY year") + fun albumsByYearAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY songCount") + fun albumsBySongCountAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY duration") + fun albumsByLengthAsc(): Flow> + + @Transaction + @Query( + """ + SELECT album.* + FROM album + JOIN song + ON song.albumId = album.id + WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) + GROUP BY album.id + ORDER BY SUM(song.totalPlayTime) + """ + ) + fun albumsByPlayTimeAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY rowId") + fun albumsLikedByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY title") + fun albumsLikedByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY year") + fun albumsLikedByYearAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") + fun albumsLikedBySongCountAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY duration") + fun albumsLikedByLengthAsc(): Flow> + + @Transaction + @Query( + """ + SELECT album.* + FROM album + JOIN song + ON song.albumId = album.id + WHERE bookmarkedAt IS NOT NULL + GROUP BY album.id + ORDER BY SUM(song.totalPlayTime) + """ + ) + fun albumsLikedByPlayTimeAsc(): Flow> + + fun albums(sortType: AlbumSortType, descending: Boolean) = + when (sortType) { + AlbumSortType.CREATE_DATE -> albumsByCreateDateAsc() + AlbumSortType.NAME -> albumsByNameAsc() + AlbumSortType.ARTIST -> albumsByCreateDateAsc().map { albums -> + albums.sortedBy { album -> + album.artists.joinToString(separator = "") { it.name } + } + } + + AlbumSortType.YEAR -> albumsByYearAsc() + AlbumSortType.SONG_COUNT -> albumsBySongCountAsc() + AlbumSortType.LENGTH -> albumsByLengthAsc() + AlbumSortType.PLAY_TIME -> albumsByPlayTimeAsc() + }.map { it.reversed(descending) } + + fun albumsLiked(sortType: AlbumSortType, descending: Boolean) = + when (sortType) { + AlbumSortType.CREATE_DATE -> albumsLikedByCreateDateAsc() + AlbumSortType.NAME -> albumsLikedByNameAsc() + AlbumSortType.ARTIST -> albumsLikedByCreateDateAsc().map { albums -> + albums.sortedBy { album -> + album.artists.joinToString(separator = "") { it.name } + } + } + + AlbumSortType.YEAR -> albumsLikedByYearAsc() + AlbumSortType.SONG_COUNT -> albumsLikedBySongCountAsc() + AlbumSortType.LENGTH -> albumsLikedByLengthAsc() + AlbumSortType.PLAY_TIME -> albumsLikedByPlayTimeAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT * FROM album WHERE id = :id") + fun album(id: String): Flow + + @Transaction + @Query("SELECT * FROM album WHERE id = :albumId") + fun albumWithSongs(albumId: String): Flow + + @Query("SELECT * FROM album_artist_map WHERE albumId = :albumId") + fun albumArtistMaps(albumId: String): List + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE bookmarkedAt IS NOT NULL ORDER BY rowId") + fun playlistsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE bookmarkedAt IS NOT NULL ORDER BY name") + fun playlistsByNameAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") + fun playlistsBySongCountAsc(): Flow> + + fun playlists(sortType: PlaylistSortType, descending: Boolean) = + when (sortType) { + PlaylistSortType.CREATE_DATE -> playlistsByCreateDateAsc() + PlaylistSortType.NAME -> playlistsByNameAsc() + PlaylistSortType.SONG_COUNT -> playlistsBySongCountAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE id = :playlistId") + fun playlist(playlistId: String): Flow + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE browseId = :browseId") + fun playlistByBrowseId(browseId: String): Flow + + @Query("SELECT songId from playlist_song_map WHERE playlistId = :playlistId AND songId IN (:songIds)") + fun playlistDuplicates( + playlistId: String, + songIds: List, + ): List + + @Transaction + fun addSongToPlaylist(playlist: Playlist, songIds: List) { + var position = playlist.songCount + songIds.forEach { id -> + insert( + PlaylistSongMap( + songId = id, + playlistId = playlist.id, + position = position++ + ) + ) + } + } + + @Update + fun update(playlistEntity: PlaylistEntity, playlistItem: PlaylistItem) { + update( + playlistEntity.copy( + name = playlistItem.title, + browseId = playlistItem.id, + thumbnailUrl = playlistItem.thumbnail, + isEditable = playlistItem.isEditable, + remoteSongCount = playlistItem.songCountText?.let { Regex("""\d+""").find(it)?.value?.toIntOrNull() }, + playEndpointParams = playlistItem.playEndpoint?.params, + shuffleEndpointParams = playlistItem.shuffleEndpoint.params, + radioEndpointParams = playlistItem.radioEndpoint?.params + ) + ) + } + + @Transaction + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND inLibrary IS NOT NULL LIMIT :previewSize") + fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' AND songCount > 0 LIMIT :previewSize") + fun searchArtists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' AND EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) LIMIT :previewSize") + fun searchAlbums(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchPlaylists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT * FROM event ORDER BY rowId DESC") + fun events(): Flow> + + @Query("DELETE FROM event") + fun clearListenHistory() + + @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") + fun searchHistory(query: String = ""): Flow> + + @Query("DELETE FROM search_history") + fun clearSearchHistory() + + @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") + fun incrementTotalPlayTime(songId: String, playTime: Long) + + @Query("UPDATE song SET inLibrary = :inLibrary WHERE id = :songId AND (inLibrary IS NULL OR :inLibrary IS NULL)") + fun inLibrary(songId: String, inLibrary: LocalDateTime?) + + @Query("UPDATE song SET liked = 1 WHERE id = :songId") + suspend fun toggleLikedToTrue(songId: String) + + @Query("SELECT COUNT(1) FROM related_song_map WHERE songId = :songId LIMIT 1") + fun hasRelatedSongs(songId: String): Boolean + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE isEditable AND bookmarkedAt IS NOT NULL ORDER BY rowId") + fun editablePlaylistsByCreateDateAsc(): Flow> + + @Query( + """ + UPDATE playlist_song_map SET position = + CASE + WHEN position < :fromPosition THEN position + 1 + WHEN position > :fromPosition THEN position - 1 + ELSE :toPosition + END + WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition, :toPosition) AND MAX(:fromPosition, :toPosition) + """ + ) + fun move(playlistId: String, fromPosition: Int, toPosition: Int) + + @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId") + fun clearPlaylist(playlistId: String) + + @Query("SELECT * FROM artist WHERE name = :name") + fun artistByName(name: String): ArtistEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(song: SongEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(artist: ArtistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(album: AlbumEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(playlist: PlaylistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongAlbumMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: AlbumArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: PlaylistSongMap) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(searchHistory: SearchHistory) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(event: Event) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: RelatedSongMap) + + @Transaction + fun insert(mediaMetadata: MediaMetadata, block: (SongEntity) -> SongEntity = { it }) { + if (insert(mediaMetadata.toSongEntity().let(block)) == -1L) return + mediaMetadata.artists.forEachIndexed { index, artist -> + val artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.Companion.generateArtistId() + insert( + ArtistEntity( + id = artistId, + name = artist.name + ) + ) + insert( + SongArtistMap( + songId = mediaMetadata.id, + artistId = artistId, + position = index + ) + ) + } + } + + @Transaction + fun insert(albumPage: AlbumPage) { + if (insert( + AlbumEntity( + id = albumPage.album.browseId, + playlistId = albumPage.album.playlistId, + title = albumPage.album.title, + year = albumPage.album.year, + thumbnailUrl = albumPage.album.thumbnail, + songCount = albumPage.songs.size, + duration = albumPage.songs.sumOf { it.duration ?: 0 } + )) == -1L + ) return + albumPage.songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = albumPage.album.browseId, + index = index + ) + } + .forEach(::upsert) + albumPage.album.artists + ?.map { artist -> + ArtistEntity( + id = artist.id ?: artistByName(artist.name)?.id + ?: ArtistEntity.Companion.generateArtistId(), + name = artist.name + ) + } + ?.onEach(::insert) + ?.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumPage.album.browseId, + artistId = artist.id, + order = index + ) + } + ?.forEach(::insert) + } + + @Transaction + fun update(song: Song, mediaMetadata: MediaMetadata) { + update( + song.song.copy( + title = mediaMetadata.title, + duration = mediaMetadata.duration, + thumbnailUrl = mediaMetadata.thumbnailUrl, + albumId = mediaMetadata.album?.id, + albumName = mediaMetadata.album?.title, + ) + ) + songArtistMap(song.id).forEach(::delete) + mediaMetadata.artists.forEachIndexed { index, artist -> + val artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.Companion.generateArtistId() + insert( + ArtistEntity( + id = artistId, + name = artist.name + ) + ) + insert( + SongArtistMap( + songId = song.id, + artistId = artistId, + position = index + ) + ) + } + } + + @Update + fun update(song: SongEntity) + + @Update + fun update(artist: ArtistEntity) + + @Update + fun update(album: AlbumEntity) + + @Update + fun update(playlist: PlaylistEntity) + + @Update + fun update(map: PlaylistSongMap) + + fun update(artist: ArtistEntity, artistPage: ArtistPage) { + update( + artist.copy( + name = artistPage.artist.title, + thumbnailUrl = artistPage.artist.thumbnail.resize(544, 544), + lastUpdateTime = LocalDateTime.now() + ) + ) + } + + @Transaction + fun update(album: AlbumEntity, albumPage: AlbumPage) { + update( + album.copy( + id = albumPage.album.browseId, + playlistId = albumPage.album.playlistId, + title = albumPage.album.title, + year = albumPage.album.year, + thumbnailUrl = albumPage.album.thumbnail, + songCount = albumPage.songs.size, + duration = albumPage.songs.sumOf { it.duration ?: 0 } + ) + ) + albumPage.songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = albumPage.album.browseId, + index = index + ) + } + .forEach(::upsert) + + albumPage.album.artists?.let { artists -> + albumArtistMaps(album.id).forEach(::delete) + artists + .map { artist -> + ArtistEntity( + id = artist.id ?: artistByName(artist.name)?.id + ?: ArtistEntity.Companion.generateArtistId(), + name = artist.name + ) + } + .onEach(::insert) + .mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumPage.album.browseId, + artistId = artist.id, + order = index + ) + } + .forEach(::insert) + } + } + + @Transaction + @Query("SELECT * FROM event ORDER BY timestamp DESC LIMIT 1") + fun getLatestHistoryEntry(): Flow + + @Transaction + @Query("SELECT * FROM playlist WHERE name LIKE :query LIMIT 1") + fun findPlaylistByName(query: String): Flow + + @Query("SELECT * FROM playlist WHERE id = :id LIMIT 1") + fun getPlaylist(id: String): Flow + + @Upsert + fun upsert(map: SongAlbumMap) + + @Upsert + fun upsert(lyrics: LyricsEntity) + + @Upsert + fun upsert(format: FormatEntity) + + @Delete + fun delete(song: SongEntity) + + @Delete + fun delete(songArtistMap: SongArtistMap) + + @Delete + fun delete(artist: ArtistEntity) + + @Delete + fun delete(album: AlbumEntity) + + @Delete + fun delete(albumArtistMap: AlbumArtistMap) + + @Delete + fun delete(playlist: PlaylistEntity) + + @Delete + fun delete(playlistSongMap: PlaylistSongMap) + + @Delete + fun delete(lyrics: LyricsEntity) + + @Delete + fun delete(searchHistory: SearchHistory) + + @Delete + fun delete(event: Event) + + @Query("SELECT EXISTS(SELECT 1 FROM artist WHERE id = :artistId LIMIT 1)") + suspend fun artistIdExists(artistId: String):Boolean + + @RawQuery + fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int + + fun checkpoint() { + raw("PRAGMA wal_checkpoint(FULL)".toSQLiteQuery()) + } + @Query("SELECT COUNT(*) FROM song WHERE isLocal = 1") + fun localSongsCount(): Flow + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(item: RecentActivityEntity) + + @Delete + fun delete(item: RecentActivityEntity) + + @Query("DELETE FROM recent_activity") + fun clearRecentActivity() + + @Transaction + fun insertRecentActivityItem(item: YTItem) { + when (item) { + is AlbumItem -> { + insert( + RecentActivityEntity( + id = item.browseId, + title = item.title, + thumbnail = item.thumbnail, + explicit = item.explicit, + shareLink = item.shareLink, + type = RecentActivityType.ALBUM, + playlistId = item.playlistId, + radioPlaylistId = null, + shufflePlaylistId = null + ) + ) + } + + is PlaylistItem -> { + insert( + RecentActivityEntity( + id = item.id, + title = item.title, + thumbnail = item.thumbnail, + explicit = item.explicit, + shareLink = item.shareLink, + type = RecentActivityType.PLAYLIST, + playlistId = item.id, + radioPlaylistId = item.radioEndpoint?.playlistId, + shufflePlaylistId = item.shuffleEndpoint.playlistId + ) + ) + } + + is ArtistItem -> { + insert( + RecentActivityEntity( + id = item.id, + title = item.title, + thumbnail = item.thumbnail, + explicit = item.explicit, + shareLink = item.shareLink, + type = RecentActivityType.ARTIST, + playlistId = item.playEndpoint?.playlistId, + radioPlaylistId = item.radioEndpoint?.playlistId, + shufflePlaylistId = item.shuffleEndpoint?.playlistId + ) + ) + } + else -> {} + } + } + + @Query("SELECT * FROM recent_activity ORDER BY date DESC") + fun recentActivity(): Flow> +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/MusicDatabase.kt b/app/src/main/java/com/babelsoftware/loudly/db/MusicDatabase.kt new file mode 100644 index 0000000..2bfa39f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/MusicDatabase.kt @@ -0,0 +1,444 @@ +package com.babelsoftware.loudly.db + +import android.annotation.SuppressLint +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.core.content.contentValuesOf +import androidx.room.* +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import com.babelsoftware.loudly.db.entities.AlbumArtistMap +import com.babelsoftware.loudly.db.entities.AlbumEntity +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.db.entities.Event +import com.babelsoftware.loudly.db.entities.FormatEntity +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.db.entities.PlaylistSongMap +import com.babelsoftware.loudly.db.entities.PlaylistSongMapPreview +import com.babelsoftware.loudly.db.entities.RecentActivityEntity +import com.babelsoftware.loudly.db.entities.RelatedSongMap +import com.babelsoftware.loudly.db.entities.SearchHistory +import com.babelsoftware.loudly.db.entities.SetVideoIdEntity +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.db.entities.SongAlbumMap +import com.babelsoftware.loudly.db.entities.SongArtistMap +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.db.entities.SortedSongAlbumMap +import com.babelsoftware.loudly.db.entities.SortedSongArtistMap +import com.babelsoftware.loudly.extensions.toSQLiteQuery +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.* + +class MusicDatabase( + private val delegate: InternalDatabase, +) : DatabaseDao by delegate.dao { + val openHelper: SupportSQLiteOpenHelper + get() = delegate.openHelper + + fun query(block: MusicDatabase.() -> Unit) = with(delegate) { + queryExecutor.execute { + block(this@MusicDatabase) + } + } + + fun transaction(block: MusicDatabase.() -> Unit) = with(delegate) { + transactionExecutor.execute { + runInTransaction { + block(this@MusicDatabase) + } + } + } + + fun close() = delegate.close() +} + +@Database( + entities = [ + SongEntity::class, + ArtistEntity::class, + AlbumEntity::class, + PlaylistEntity::class, + SongArtistMap::class, + SongAlbumMap::class, + AlbumArtistMap::class, + PlaylistSongMap::class, + SearchHistory::class, + FormatEntity::class, + LyricsEntity::class, + Event::class, + RelatedSongMap::class, + SetVideoIdEntity::class, + RecentActivityEntity::class + ], + views = [ + SortedSongArtistMap::class, + SortedSongAlbumMap::class, + PlaylistSongMapPreview::class + ], + version = 13, + exportSchema = true, + autoMigrations = [ + AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6, spec = Migration5To6::class), + AutoMigration(from = 6, to = 7, spec = Migration6To7::class), + AutoMigration(from = 7, to = 8, spec = Migration7To8::class), + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10, spec = Migration9To10::class), + AutoMigration(from = 10, to = 11, spec = Migration10To11::class), + AutoMigration(from = 11, to = 12, spec = Migration11To12::class), + AutoMigration(from = 12, to = 13, spec = Migration12To13::class), + AutoMigration(from = 13, to = 14, spec = Migration13To14::class), + AutoMigration(from = 14, to = 15), + AutoMigration(from = 15, to = 16), + AutoMigration(from = 16, to = 17, spec = Migration16To17::class), + AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19, spec = Migration18To19::class), + ] +) +@TypeConverters(Converters::class) +abstract class InternalDatabase : RoomDatabase() { + abstract val dao: DatabaseDao + + companion object { + const val DB_NAME = "song.db" + + fun newInstance(context: Context): MusicDatabase = + MusicDatabase( + delegate = Room.databaseBuilder(context, InternalDatabase::class.java, DB_NAME) + .addMigrations(MIGRATION_1_2) + .build() + ) + } +} + +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + data class OldSongEntity( + val id: String, + val title: String, + val duration: Int = -1, + val thumbnailUrl: String? = null, + val albumId: String? = null, + val albumName: String? = null, + val liked: Boolean = false, + val totalPlayTime: Long = 0, + val downloadState: Int = 0, + val createDate: LocalDateTime = LocalDateTime.now(), + val modifyDate: LocalDateTime = LocalDateTime.now(), + ) + + val converters = Converters() + val artistMap = mutableMapOf() + val artists = mutableListOf() + database.query("SELECT * FROM artist".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + val oldId = cursor.getInt(0) + val newId = ArtistEntity.Companion.generateArtistId() + artistMap[oldId] = newId + artists.add( + ArtistEntity( + id = newId, + name = cursor.getString(1) + ) + ) + } + } + + val playlistMap = mutableMapOf() + val playlists = mutableListOf() + database.query("SELECT * FROM playlist".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + val oldId = cursor.getInt(0) + val newId = PlaylistEntity.Companion.generatePlaylistId() + playlistMap[oldId] = newId + playlists.add( + PlaylistEntity( + id = newId, + name = cursor.getString(1) + ) + ) + } + } + val playlistSongMaps = mutableListOf() + database.query("SELECT * FROM playlist_song".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + playlistSongMaps.add( + PlaylistSongMap( + playlistId = playlistMap[cursor.getInt(1)]!!, + songId = cursor.getString(2), + position = cursor.getInt(3) + ) + ) + } + } + playlistSongMaps.sortBy { it.position } + val playlistSongCount = mutableMapOf() + playlistSongMaps.map { map -> + if (map.playlistId !in playlistSongCount) playlistSongCount[map.playlistId] = 0 + map.copy(position = playlistSongCount[map.playlistId]!!).also { + playlistSongCount[map.playlistId] = playlistSongCount[map.playlistId]!! + 1 + } + } + val songs = mutableListOf() + val songArtistMaps = mutableListOf() + database.query("SELECT * FROM song".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + val songId = cursor.getString(0) + songs.add( + OldSongEntity( + id = songId, + title = cursor.getString(1), + duration = cursor.getInt(3), + liked = cursor.getInt(4) == 1, + createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time).atZone(ZoneOffset.UTC).toLocalDateTime(), + modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time).atZone(ZoneOffset.UTC).toLocalDateTime() + ) + ) + songArtistMaps.add( + SongArtistMap( + songId = songId, + artistId = artistMap[cursor.getInt(2)]!!, + position = 0 + ) + ) + } + } + database.execSQL("DROP TABLE IF EXISTS song") + database.execSQL("DROP TABLE IF EXISTS artist") + database.execSQL("DROP TABLE IF EXISTS playlist") + database.execSQL("DROP TABLE IF EXISTS playlist_song") + database.execSQL("CREATE TABLE IF NOT EXISTS `song` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `artist` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `album` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `playlist` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `song_artist_map` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `song_artist_map` (`songId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `song_artist_map` (`artistId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `song_album_map` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `song_album_map` (`songId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `song_album_map` (`albumId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `album_artist_map` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `album_artist_map` (`albumId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `album_artist_map` (`artistId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_song_map` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `playlist_song_map` (`playlistId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `playlist_song_map` (`songId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `download` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))") + database.execSQL("CREATE TABLE IF NOT EXISTS `search_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `search_history` (`query`)") + database.execSQL("CREATE VIEW `sorted_song_artist_map` AS SELECT * FROM song_artist_map ORDER BY position") + database.execSQL("CREATE VIEW `playlist_song_map_preview` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position") + artists.forEach { artist -> + database.insert( + "artist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to artist.id, + "name" to artist.name, + "createDate" to converters.dateToTimestamp(artist.lastUpdateTime), + "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime) + ) + ) + } + songs.forEach { song -> + database.insert( + "song", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to song.id, + "title" to song.title, + "duration" to song.duration, + "liked" to song.liked, + "totalPlayTime" to song.totalPlayTime, + "isTrash" to false, + "download_state" to song.downloadState, + "create_date" to converters.dateToTimestamp(song.createDate), + "modify_date" to converters.dateToTimestamp(song.modifyDate) + ) + ) + } + songArtistMaps.forEach { songArtistMap -> + database.insert( + "song_artist_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "songId" to songArtistMap.songId, + "artistId" to songArtistMap.artistId, + "position" to songArtistMap.position + ) + ) + } + playlists.forEach { playlist -> + database.insert( + "playlist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to playlist.id, + "name" to playlist.name, + "createDate" to converters.dateToTimestamp(LocalDateTime.now()), + "lastUpdateTime" to converters.dateToTimestamp(LocalDateTime.now()) + ) + ) + } + playlistSongMaps.forEach { playlistSongMap -> + database.insert( + "playlist_song_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "playlistId" to playlistSongMap.playlistId, + "songId" to playlistSongMap.songId, + "position" to playlistSongMap.position + ) + ) + } + } +} + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "isTrash"), + DeleteColumn(tableName = "playlist", columnName = "author"), + DeleteColumn(tableName = "playlist", columnName = "authorId"), + DeleteColumn(tableName = "playlist", columnName = "year"), + DeleteColumn(tableName = "playlist", columnName = "thumbnailUrl"), + DeleteColumn(tableName = "playlist", columnName = "createDate"), + DeleteColumn(tableName = "playlist", columnName = "lastUpdateTime") +) +@RenameColumn.Entries( + RenameColumn(tableName = "song", fromColumnName = "download_state", toColumnName = "downloadState"), + RenameColumn(tableName = "song", fromColumnName = "create_date", toColumnName = "createDate"), + RenameColumn(tableName = "song", fromColumnName = "modify_date", toColumnName = "modifyDate") +) +class Migration5To6 : AutoMigrationSpec { + override fun onPostMigrate(database: SupportSQLiteDatabase) { + database.query("SELECT id FROM playlist WHERE id NOT LIKE 'LP%'").use { cursor -> + while (cursor.moveToNext()) { + database.execSQL("UPDATE playlist SET browseID = '${cursor.getString(0)}' WHERE id = '${cursor.getString(0)}'") + } + } + } +} + +class Migration6To7 : AutoMigrationSpec { + override fun onPostMigrate(database: SupportSQLiteDatabase) { + database.query("SELECT id, createDate FROM song").use { cursor -> + while (cursor.moveToNext()) { + database.execSQL("UPDATE song SET inLibrary = ${cursor.getLong(1)} WHERE id = '${cursor.getString(0)}'") + } + } + } +} + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "createDate"), + DeleteColumn(tableName = "song", columnName = "modifyDate") +) +class Migration7To8 : AutoMigrationSpec + +@DeleteTable.Entries( + DeleteTable(tableName = "download") +) +class Migration9To10 : AutoMigrationSpec + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "downloadState"), + DeleteColumn(tableName = "artist", columnName = "bannerUrl"), + DeleteColumn(tableName = "artist", columnName = "description"), + DeleteColumn(tableName = "artist", columnName = "createDate") +) +class Migration10To11 : AutoMigrationSpec + +@DeleteColumn.Entries( + DeleteColumn(tableName = "album", columnName = "createDate") +) +class Migration11To12 : AutoMigrationSpec { + override fun onPostMigrate(database: SupportSQLiteDatabase) { + database.execSQL("UPDATE album SET bookmarkedAt = lastUpdateTime") + database.query("SELECT DISTINCT albumId, albumName FROM song").use { cursor -> + while (cursor.moveToNext()) { + val albumId = cursor.getString(0) + val albumName = cursor.getString(1) + database.insert( + table = "album", + conflictAlgorithm = SQLiteDatabase.CONFLICT_IGNORE, + values = contentValuesOf( + "id" to albumId, + "title" to albumName, + "songCount" to 0, + "duration" to 0, + "lastUpdateTime" to 0 + ) + ) + } + } + database.query("CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `song` (`albumId`)") + } +} + +class Migration12To13 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + } +} + +@DeleteTable.Entries( + DeleteTable(tableName = "set_video_id"), + DeleteTable(tableName = "recent_activity") +) +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "dateDownload"), + DeleteColumn(tableName = "song", columnName = "artistName"), + DeleteColumn(tableName = "song", columnName = "isLocal"), + DeleteColumn(tableName = "song", columnName = "localPath"), + DeleteColumn(tableName = "artist", columnName = "channelId"), + DeleteColumn(tableName = "album", columnName = "playlistId"), + DeleteColumn(tableName = "playlist", columnName = "isEditable"), + DeleteColumn(tableName = "playlist", columnName = "bookmarkedAt"), + DeleteColumn(tableName = "playlist", columnName = "remoteSongCount"), + DeleteColumn(tableName = "playlist", columnName = "playEndpointParams"), + DeleteColumn(tableName = "playlist", columnName = "shuffleEndpointParams"), + DeleteColumn(tableName = "playlist", columnName = "radioEndpointParams"), + DeleteColumn(tableName = "playlist", columnName = "thumbnailUrl"), + DeleteColumn(tableName = "playlist", columnName = "isLocal"), + DeleteColumn(tableName = "playlist_song_map" , columnName = "setVideoId"), + DeleteColumn(tableName = "format", columnName = "playbackUrl") +) +class Migration13To14 : AutoMigrationSpec { + @SuppressLint("Range") + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE playlist SET createdAt = '${Converters().dateToTimestamp(LocalDateTime.now())}'") + db.execSQL( + "UPDATE playlist SET lastUpdateTime = '${ + Converters().dateToTimestamp( + LocalDateTime.now() + ) + }'" + ) + } +} + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "isLocal"), + DeleteColumn(tableName = "song", columnName = "localPath"), + DeleteColumn(tableName = "artist", columnName = "isLocal"), + DeleteColumn(tableName = "playlist", columnName = "isLocal"), + DeleteColumn(tableName = "song", columnName = "dateDownload"), +) +class Migration16To17 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE playlist SET bookmarkedAt = lastUpdateTime") + db.execSQL("UPDATE playlist SET isEditable = 1 WHERE browseId IS NOT NULL") + } +} + +class Migration18To19 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE song SET explicit = 0 WHERE explicit IS NULL") + } +} + +data class HistoryEntry( + @Embedded + val event: Event, + @Relation( + parentColumn = "songId", + entityColumn = "id", + entity = SongEntity::class + ) + val song: Song, +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/Album.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/Album.kt new file mode 100644 index 0000000..1151257 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/Album.kt @@ -0,0 +1,30 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class Album( + @Embedded + val album: AlbumEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = AlbumArtistMap::class, + parentColumn = "albumId", + entityColumn = "artistId" + ) + ) + val artists: List, +) : LocalItem() { + override val id: String + get() = album.id + override val title: String + get() = album.title + override val thumbnailUrl: String? + get() = album.thumbnailUrl +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumArtistMap.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumArtistMap.kt new file mode 100644 index 0000000..38fa70f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumArtistMap.kt @@ -0,0 +1,28 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "album_artist_map", + primaryKeys = ["albumId", "artistId"], + foreignKeys = [ + ForeignKey( + entity = AlbumEntity::class, + parentColumns = ["id"], + childColumns = ["albumId"], + onDelete = ForeignKey.CASCADE), + ForeignKey( + entity = ArtistEntity::class, + parentColumns = ["id"], + childColumns = ["artistId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class AlbumArtistMap( + @ColumnInfo(index = true) val albumId: String, + @ColumnInfo(index = true) val artistId: String, + val order: Int, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumEntity.kt new file mode 100644 index 0000000..a63f2aa --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumEntity.kt @@ -0,0 +1,37 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.babelsoftware.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Immutable +@Entity(tableName = "album") +data class AlbumEntity( + @PrimaryKey val id: String, + val playlistId: String? = null, + val title: String, + val year: Int? = null, + val thumbnailUrl: String? = null, + val themeColor: Int? = null, + val songCount: Int, + val duration: Int, + val lastUpdateTime: LocalDateTime = LocalDateTime.now(), + val bookmarkedAt: LocalDateTime? = null, +) { + fun localToggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() + ) + fun toggleLike() = localToggleLike().also { + CoroutineScope(Dispatchers.IO).launch { + if (playlistId != null) + YouTube.likePlaylist(playlistId, bookmarkedAt == null) + this.cancel() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumWithSongs.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumWithSongs.kt new file mode 100644 index 0000000..ed2820f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/AlbumWithSongs.kt @@ -0,0 +1,34 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class AlbumWithSongs( + @Embedded + val album: AlbumEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = AlbumArtistMap::class, + parentColumn = "albumId", + entityColumn = "artistId" + ) + ) + val artists: List, + @Relation( + entity = SongEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = SortedSongAlbumMap::class, + parentColumn = "albumId", + entityColumn = "songId" + ) + ) + val songs: List, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/Artist.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/Artist.kt new file mode 100644 index 0000000..d1213fb --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/Artist.kt @@ -0,0 +1,18 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded + +@Immutable +data class Artist( + @Embedded + val artist: ArtistEntity, + val songCount: Int, +) : LocalItem() { + override val id: String + get() = artist.id + override val title: String + get() = artist.name + override val thumbnailUrl: String? + get() = artist.thumbnailUrl +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/ArtistEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/ArtistEntity.kt new file mode 100644 index 0000000..42fb83d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/ArtistEntity.kt @@ -0,0 +1,45 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.apache.commons.lang3.RandomStringUtils +import java.time.LocalDateTime +import com.babelsoftware.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +@Suppress("DEPRECATION") +@Immutable +@Entity(tableName = "artist") +data class ArtistEntity( + @PrimaryKey val id: String, + val name: String, + val thumbnailUrl: String? = null, + val channelId: String? = null, + val lastUpdateTime: LocalDateTime = LocalDateTime.now(), + val bookmarkedAt: LocalDateTime? = null, +) { + + val isYouTubeArtist: Boolean + get() = id.startsWith("UC") + + fun localToggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now(), + ) + fun toggleLike() = localToggleLike().also { + CoroutineScope(Dispatchers.IO).launch { + if (channelId == null) + YouTube.subscribeChannel(YouTube.getChannelId(id), bookmarkedAt == null) + else + YouTube.subscribeChannel(channelId, bookmarkedAt == null) + this.cancel() + } + } + + companion object { + fun generateArtistId() = "LA" + RandomStringUtils.random(8, true, false) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/Event.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/Event.kt new file mode 100644 index 0000000..84dd244 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/Event.kt @@ -0,0 +1,27 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import java.time.LocalDateTime + +@Immutable +@Entity( + tableName = "event", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class Event( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + val timestamp: LocalDateTime, + val playTime: Long, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/EventWithSong.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/EventWithSong.kt new file mode 100644 index 0000000..b83845a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/EventWithSong.kt @@ -0,0 +1,17 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Relation + +@Immutable +data class EventWithSong( + @Embedded + val event: Event, + @Relation( + entity = SongEntity::class, + parentColumn = "songId", + entityColumn = "id" + ) + val song: Song, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/FormatEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/FormatEntity.kt new file mode 100644 index 0000000..92ea8a2 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/FormatEntity.kt @@ -0,0 +1,17 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "format") +data class FormatEntity( + @PrimaryKey val id: String, + val itag: Int, + val mimeType: String, + val codecs: String, + val bitrate: Int, + val sampleRate: Int?, + val contentLength: Long, + val loudnessDb: Double?, + val playbackUrl: String? +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/LocalItem.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/LocalItem.kt new file mode 100644 index 0000000..9996757 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/LocalItem.kt @@ -0,0 +1,7 @@ +package com.babelsoftware.loudly.db.entities + +sealed class LocalItem { + abstract val id: String + abstract val title: String + abstract val thumbnailUrl: String? +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/LyricsEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/LyricsEntity.kt new file mode 100644 index 0000000..b68752f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/LyricsEntity.kt @@ -0,0 +1,14 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "lyrics") +data class LyricsEntity( + @PrimaryKey val id: String, + val lyrics: String, +) { + companion object { + const val LYRICS_NOT_FOUND = "LYRICS_NOT_FOUND" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/Playlist.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/Playlist.kt new file mode 100644 index 0000000..9b18d25 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/Playlist.kt @@ -0,0 +1,38 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class Playlist( + @Embedded + val playlist: PlaylistEntity, + val songCount: Int, + @Relation( + entity = SongEntity::class, + entityColumn = "id", + parentColumn = "id", + projection = ["thumbnailUrl"], + associateBy = Junction( + value = PlaylistSongMapPreview::class, + parentColumn = "playlistId", + entityColumn = "songId" + ) + ) + val songThumbnails: List, +) : LocalItem() { + override val id: String + get() = playlist.id + override val title: String + get() = playlist.name + override val thumbnailUrl: String? + get() = null + val thumbnails: List + get() { + return if (playlist.thumbnailUrl != null) + listOf(playlist.thumbnailUrl) + else songThumbnails.filterNotNull() + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistEntity.kt new file mode 100644 index 0000000..62342bc --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistEntity.kt @@ -0,0 +1,51 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.babelsoftware.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.apache.commons.lang3.RandomStringUtils +import java.time.LocalDateTime + +@Suppress("DEPRECATION") +@Immutable +@Entity(tableName = "playlist") +data class PlaylistEntity( + @PrimaryKey val id: String = generatePlaylistId(), + val name: String, + val browseId: String? = null, + val createdAt: LocalDateTime? = LocalDateTime.now(), + val lastUpdateTime: LocalDateTime? = LocalDateTime.now(), + @ColumnInfo(name = "isEditable", defaultValue = true.toString()) + val isEditable: Boolean = true, + val bookmarkedAt: LocalDateTime? = null, + val remoteSongCount: Int? = null, + val playEndpointParams: String? = null, + val thumbnailUrl: String? = null, + val shuffleEndpointParams: String? = null, + val radioEndpointParams: String? = null, + @ColumnInfo(name = "isLocal", defaultValue = false.toString()) + val isLocal: Boolean = false, +) { + companion object { + const val LIKED_PLAYLIST_ID = "LP_LIKED" + const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" + + fun generatePlaylistId() = "LP" + RandomStringUtils.random(8, true, false) + } + fun localToggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() + ) + fun toggleLike() = localToggleLike().also { + CoroutineScope(Dispatchers.IO).launch { + if (browseId != null) + YouTube.likePlaylist(browseId, bookmarkedAt == null) + this.cancel() + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSong.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSong.kt new file mode 100644 index 0000000..4137590 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSong.kt @@ -0,0 +1,14 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.Embedded +import androidx.room.Relation + +data class PlaylistSong( + @Embedded val map: PlaylistSongMap, + @Relation( + parentColumn = "songId", + entityColumn = "id", + entity = SongEntity::class + ) + val song: Song, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMap.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMap.kt new file mode 100644 index 0000000..c474573 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMap.kt @@ -0,0 +1,30 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "playlist_song_map", + foreignKeys = [ + ForeignKey( + entity = PlaylistEntity::class, + parentColumns = ["id"], + childColumns = ["playlistId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE) + ] +) +data class PlaylistSongMap( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + @ColumnInfo(index = true) val playlistId: String, + @ColumnInfo(index = true) val songId: String, + val position: Int = 0, + val setVideoId: String? = null, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMapPreview.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMapPreview.kt new file mode 100644 index 0000000..67b7774 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/PlaylistSongMapPreview.kt @@ -0,0 +1,13 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "playlist_song_map_preview", + value = "SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position") +data class PlaylistSongMapPreview( + @ColumnInfo(index = true) val playlistId: String, + @ColumnInfo(index = true) val songId: String, + val idInPlaylist: Int = 0, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/RecentActivityEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/RecentActivityEntity.kt new file mode 100644 index 0000000..c1c9c33 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/RecentActivityEntity.kt @@ -0,0 +1,26 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.LocalDateTime + +@Immutable +enum class RecentActivityType { + PLAYLIST, ALBUM, ARTIST +} + +@Entity(tableName = "recent_activity") +@Immutable +data class RecentActivityEntity( + @PrimaryKey val id: String, + val title: String, + val thumbnail: String?, + val explicit: Boolean, + val shareLink: String, + val type: RecentActivityType, + val playlistId: String?, + val radioPlaylistId: String?, + val shufflePlaylistId: String?, + val date: LocalDateTime = LocalDateTime.now() +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/RelatedSongMap.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/RelatedSongMap.kt new file mode 100644 index 0000000..f5cb1f1 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/RelatedSongMap.kt @@ -0,0 +1,29 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "related_song_map", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["relatedSongId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class RelatedSongMap( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val relatedSongId: String, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/SearchHistory.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/SearchHistory.kt new file mode 100644 index 0000000..45f1cb5 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/SearchHistory.kt @@ -0,0 +1,17 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "search_history", + indices = [Index( + value = ["query"], + unique = true + )] +) +data class SearchHistory( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val query: String, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/SetVideoIdEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/SetVideoIdEntity.kt new file mode 100644 index 0000000..c6fd3dc --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/SetVideoIdEntity.kt @@ -0,0 +1,10 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +@Entity(tableName = "set_video_id") +data class SetVideoIdEntity( + @PrimaryKey(autoGenerate = false) + val videoId: String = "", + val setVideoId: String? = null, +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/Song.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/Song.kt new file mode 100644 index 0000000..b684222 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/Song.kt @@ -0,0 +1,52 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class Song +@JvmOverloads +constructor( + @Embedded val song: SongEntity, + + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = + Junction( + value = SortedSongArtistMap::class, + parentColumn = "songId", + entityColumn = "artistId", + ), + ) + val artists: List, + + @Relation( + entity = AlbumEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = + Junction( + value = SongAlbumMap::class, + parentColumn = "songId", + entityColumn = "albumId", + ), + ) + val album: AlbumEntity? = null, + + @Relation( + parentColumn = "id", + entityColumn = "id" + ) + val format: FormatEntity? = null, +) : LocalItem() { + override val id: String + get() = song.id + override val title: String + get() = song.title + override val thumbnailUrl: String? + get() = song.thumbnailUrl +} diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/SongAlbumMap.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/SongAlbumMap.kt new file mode 100644 index 0000000..aa80588 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/SongAlbumMap.kt @@ -0,0 +1,28 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "song_album_map", + primaryKeys = ["songId", "albumId"], + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE), + ForeignKey( + entity = AlbumEntity::class, + parentColumns = ["id"], + childColumns = ["albumId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class SongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val index: Int, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/SongArtistMap.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/SongArtistMap.kt new file mode 100644 index 0000000..c72e0ac --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/SongArtistMap.kt @@ -0,0 +1,28 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "song_artist_map", + primaryKeys = ["songId", "artistId"], + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE), + ForeignKey( + entity = ArtistEntity::class, + parentColumns = ["id"], + childColumns = ["artistId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class SongArtistMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val artistId: String, + val position: Int, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/SongEntity.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/SongEntity.kt new file mode 100644 index 0000000..a877c92 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/SongEntity.kt @@ -0,0 +1,61 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.babelsoftware.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Immutable +@Entity( + tableName = "song", + indices = [ + Index( + value = ["albumId"] + ) + ] +) +data class SongEntity( + @PrimaryKey val id: String, + val title: String, + val duration: Int = -1, + val thumbnailUrl: String? = null, + val albumId: String? = null, + val albumName: String? = null, + val liked: Boolean = false, + val totalPlayTime: Long = 0, + val inLibrary: LocalDateTime? = null, + val dateDownload: LocalDateTime? = null, + val artistName: String? = null, + @ColumnInfo(name = "isLocal", defaultValue = "false") val isLocal: Boolean = false, + val localPath: String?, +) { + + val isLocalSong: Boolean + get() = id.startsWith("LA") + + fun localToggleLike() = copy( + liked = !liked, + inLibrary = if (!liked) inLibrary ?: LocalDateTime.now() else inLibrary + ).also { + CoroutineScope(Dispatchers.IO).launch() { + YouTube.likeVideo(id, !liked) + this.cancel() + } + } + + fun toggleLike() = localToggleLike().also { + CoroutineScope(Dispatchers.IO).launch() { + YouTube.likeVideo(id, !liked) + this.cancel() + } + } + + fun toggleLibrary() = copy(inLibrary = if (inLibrary == null) LocalDateTime.now() else null) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongAlbumMap.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongAlbumMap.kt new file mode 100644 index 0000000..086073e --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongAlbumMap.kt @@ -0,0 +1,13 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "sorted_song_album_map", + value = "SELECT * FROM song_album_map ORDER BY `index`") +data class SortedSongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val index: Int, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongArtistMap.kt b/app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongArtistMap.kt new file mode 100644 index 0000000..e6ce239 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/db/entities/SortedSongArtistMap.kt @@ -0,0 +1,13 @@ +package com.babelsoftware.loudly.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "sorted_song_artist_map", + value = "SELECT * FROM song_artist_map ORDER BY position") +data class SortedSongArtistMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val artistId: String, + val position: Int, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/di/AppModule.kt b/app/src/main/java/com/babelsoftware/loudly/di/AppModule.kt new file mode 100644 index 0000000..6bb9996 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/di/AppModule.kt @@ -0,0 +1,98 @@ +package com.babelsoftware.loudly.di + +import android.content.Context +import androidx.media3.database.DatabaseProvider +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import com.babelsoftware.loudly.constants.MaxSongCacheSizeKey +import com.babelsoftware.loudly.db.InternalDatabase +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.utils.LmImageCacheMgr +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ImageCache + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class PlayerCache + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DownloadCache + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Singleton + @Provides + fun provideDatabase(@ApplicationContext context: Context): MusicDatabase = + InternalDatabase.Companion.newInstance(context) + + @Provides + @Singleton + @ImageCache + fun provideImageCache(): LmImageCacheMgr = LmImageCacheMgr() + + @Singleton + @Provides + fun provideDatabaseProvider(@ApplicationContext context: Context): DatabaseProvider = + StandaloneDatabaseProvider(context) + + @Singleton + @Provides + @PlayerCache + fun providePlayerCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache { + val constructor = { + SimpleCache( + context.filesDir.resolve("exoplayer"), + when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) { + -1 -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) + }, + databaseProvider + ) + } + constructor().release() + return constructor() + } + + @Singleton + @Provides + @DownloadCache + fun provideDownloadCache(@ApplicationContext context: Context, databaseProvider: DatabaseProvider): SimpleCache { + val constructor = { + SimpleCache(context.filesDir.resolve("download"), NoOpCacheEvictor(), databaseProvider) + } + constructor().release() + return constructor() + } + @Provides + @Singleton + fun provideHttpClient(): HttpClient { + return HttpClient(OkHttp) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + coerceInputValues = true + }) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/CoroutineExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/CoroutineExt.kt new file mode 100644 index 0000000..cc95877 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/CoroutineExt.kt @@ -0,0 +1,30 @@ +package com.babelsoftware.loudly.extensions + +import android.content.Context +import com.babelsoftware.loudly.constants.LikedAutoDownloadKey +import com.babelsoftware.loudly.constants.LikedAutodownloadMode +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +fun Flow.collect(scope: CoroutineScope, action: suspend (value: T) -> Unit) { + scope.launch { + collect(action) + } +} + +fun Flow.collectLatest(scope: CoroutineScope, action: suspend (value: T) -> Unit) { + scope.launch { + collectLatest(action) + } +} + +fun Context.getLikeAutoDownload(): LikedAutodownloadMode { + return dataStore[LikedAutoDownloadKey].toEnum(LikedAutodownloadMode.OFF) +} + +val SilentHandler = CoroutineExceptionHandler { _, _ -> } diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/FileExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/FileExt.kt new file mode 100644 index 0000000..84d1c4f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/FileExt.kt @@ -0,0 +1,12 @@ +package com.babelsoftware.loudly.extensions + +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +operator fun File.div(child: String): File = File(this, child) + +fun InputStream.zipInputStream(): ZipInputStream = ZipInputStream(this) +fun OutputStream.zipOutputStream(): ZipOutputStream = ZipOutputStream(this) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/ListExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/ListExt.kt new file mode 100644 index 0000000..f561c46 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/ListExt.kt @@ -0,0 +1,8 @@ +package com.babelsoftware.loudly.extensions + +fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this + +fun MutableList.move(fromIndex: Int, toIndex: Int): MutableList { + add(toIndex, removeAt(fromIndex)) + return this +} diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/MediaItemExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/MediaItemExt.kt new file mode 100644 index 0000000..3e3bc87 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/MediaItemExt.kt @@ -0,0 +1,63 @@ +package com.babelsoftware.loudly.extensions + +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.innertube.models.SongItem + +val MediaItem.metadata: MediaMetadata? + get() = localConfiguration?.tag as? MediaMetadata + +fun Song.toMediaItem() = MediaItem.Builder() + .setMediaId(song.id) + .setUri(song.id) + .setCustomCacheKey(song.id) + .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setAlbumTitle(song.albumName) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) + .build() + +fun SongItem.toMediaItem() = MediaItem.Builder() + .setMediaId(id) + .setUri(id) + .setCustomCacheKey(id) + .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnail.toUri()) + .setAlbumTitle(album?.name) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) + .build() + +fun MediaMetadata.toMediaItem() = MediaItem.Builder() + .setMediaId(id) + .setUri(id) + .setCustomCacheKey(id) + .setTag(this) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnailUrl?.toUri()) + .setAlbumTitle(album?.title) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) + .build() \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/NavControllerExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/NavControllerExt.kt new file mode 100644 index 0000000..2834166 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/NavControllerExt.kt @@ -0,0 +1,21 @@ +package com.babelsoftware.loudly.extensions + +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination + +fun NavController.backToMain(route: String) { + navigate(route) { + popUpTo(graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +fun NavController.backToMain() { + navigate(graph.startDestinationRoute!!) { + popUpTo(graph.findStartDestination().id) + launchSingleTop = true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/PlayerExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/PlayerExt.kt new file mode 100644 index 0000000..2a4b850 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/PlayerExt.kt @@ -0,0 +1,126 @@ +package com.babelsoftware.loudly.extensions + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Timeline +import androidx.media3.common.TrackSelectionParameters +import com.babelsoftware.loudly.models.MediaMetadata +import java.util.ArrayDeque + +fun Player.togglePlayPause() { + if (!playWhenReady && playbackState == Player.STATE_IDLE) { + prepare() + } + playWhenReady = !playWhenReady +} + +fun Player.toggleRepeatMode() { + repeatMode = when (repeatMode) { + REPEAT_MODE_OFF -> REPEAT_MODE_ALL + REPEAT_MODE_ALL -> REPEAT_MODE_ONE + REPEAT_MODE_ONE -> REPEAT_MODE_OFF + else -> throw IllegalStateException() + } +} + +fun Player.toggleShuffleMode() { + shuffleModeEnabled = !shuffleModeEnabled +} + +fun Player.getQueueWindows(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = ArrayDeque() + val queueSize = timeline.windowCount + + val currentMediaItemIndex: Int = currentMediaItemIndex + queue.add(timeline.getWindow(currentMediaItemIndex, Timeline.Window())) + + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && queue.size < queueSize) { + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = timeline.getNextWindowIndex(lastMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(timeline.getWindow(lastMediaItemIndex, Timeline.Window())) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) { + firstMediaItemIndex = timeline.getPreviousWindowIndex(firstMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window())) + } + } + } + return queue.toList() +} + +fun Player.getCurrentQueueIndex(): Int { + if (currentTimeline.isEmpty) { + return -1 + } + var index = 0 + var currentMediaItemIndex = currentMediaItemIndex + while (currentMediaItemIndex != C.INDEX_UNSET) { + currentMediaItemIndex = currentTimeline.getPreviousWindowIndex(currentMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (currentMediaItemIndex != C.INDEX_UNSET) { + index++ + } + } + return index +} + +val Player.currentMetadata: MediaMetadata? + get() = currentMediaItem?.metadata + +val Player.mediaItems: List + get() = object : AbstractList() { + override val size: Int + get() = mediaItemCount + + override fun get(index: Int): MediaItem = getMediaItemAt(index) + } + +fun Player.findNextMediaItemById(mediaId: String): MediaItem? { + for (i in currentMediaItemIndex until mediaItemCount) { + if (getMediaItemAt(i).mediaId == mediaId) { + return getMediaItemAt(i) + } + } + return null +} + +fun Player.setOffloadEnabled(enabled: Boolean) { + trackSelectionParameters = trackSelectionParameters.buildUpon() + .setAudioOffloadPreferences( + TrackSelectionParameters.AudioOffloadPreferences + .Builder() + .setAudioOffloadMode( + if (enabled) { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } else { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } + ) + .build() + ).build() +} + +/** + * Oynatıcıyı 'playWhenReady' durumunu değiştirerek çalmaya zorlar. + * Bu, oynatıcının STATE_READY durumunda takılıp kalmasını engeller. + */ +fun Player.forceResume() { + // Sadece oynatıcı hazır durumdaysa ve çalmıyorsa bu işlemi yap + if (this.playbackState == Player.STATE_READY && !this.isPlaying) { + this.playWhenReady = false + this.playWhenReady = true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/StringExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/StringExt.kt new file mode 100644 index 0000000..348db5f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/StringExt.kt @@ -0,0 +1,20 @@ +package com.babelsoftware.loudly.extensions + +import androidx.sqlite.db.SimpleSQLiteQuery +import java.net.InetSocketAddress +import java.net.InetSocketAddress.createUnresolved + +inline fun > String?.toEnum(defaultValue: T): T = + if (this == null) defaultValue + else try { + enumValueOf(this) + } catch (e: IllegalArgumentException) { + defaultValue + } + +fun String.toSQLiteQuery(): SimpleSQLiteQuery = SimpleSQLiteQuery(this) + +fun String.toInetSocketAddress(): InetSocketAddress { + val (host, port) = split(":") + return createUnresolved(host, port.toInt()) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/extensions/UtilExt.kt b/app/src/main/java/com/babelsoftware/loudly/extensions/UtilExt.kt new file mode 100644 index 0000000..9945bac --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/extensions/UtilExt.kt @@ -0,0 +1,8 @@ +package com.babelsoftware.loudly.extensions + +fun tryOrNull(block: () -> T): T? = + try { + block() + } catch (e: Exception) { + null + } \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/KuGouLyricsProvider.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/KuGouLyricsProvider.kt new file mode 100644 index 0000000..8bcde9e --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/KuGouLyricsProvider.kt @@ -0,0 +1,20 @@ +package com.babelsoftware.loudly.lyrics + +import android.content.Context +import com.babelsoftware.loudly.constants.EnableKugouKey +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.kugou.KuGou + +object KuGouLyricsProvider : LyricsProvider { + override val name = "Kugou" + override fun isEnabled(context: Context): Boolean = + context.dataStore[EnableKugouKey] ?: true + + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = + KuGou.getLyrics(title, artist, duration) + + override suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) { + KuGou.getAllLyrics(title, artist, duration, callback) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/LocalLyricsProvider.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/LocalLyricsProvider.kt new file mode 100644 index 0000000..823d944 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/LocalLyricsProvider.kt @@ -0,0 +1,37 @@ +package com.babelsoftware.loudly.lyrics + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import java.nio.file.Files +import java.nio.file.Paths + + +object LocalLyricsProvider : LyricsProvider { + override val name = "Local LRC" + override fun isEnabled(context: Context) = true + + /** + * This function is "hot-wired" to adapted to the + * interface design. As a result, title is actually the file path. + * The lrc file is assumed to be in the same directory as the song. + * All the other fields serve no purpose. + * + * @param title file path of the song, NOT the song title + */ + @RequiresApi(Build.VERSION_CODES.O) + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + ): Result = runCatching { + if (title.isBlank()) { + throw IllegalArgumentException("Local lyrics path cannot be empty.") + } + // ex .../music/song.ogg -> .../music/song.lrc + String(Files.readAllBytes( + Paths.get(title.substringBeforeLast('.') + ".lrc")) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/LrcLibLyricsProvider.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/LrcLibLyricsProvider.kt new file mode 100644 index 0000000..653a5ff --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/LrcLibLyricsProvider.kt @@ -0,0 +1,31 @@ +package com.babelsoftware.loudly.lyrics + +import android.content.Context +import com.babelsoftware.loudly.constants.EnableLrcLibKey +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.lrclib.LrcLib + +object LrcLibLyricsProvider : LyricsProvider { + override val name = "LrcLib" + + override fun isEnabled(context: Context): Boolean = + context.dataStore[EnableLrcLibKey] ?: true + + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + ): Result = LrcLib.getLyrics(title, artist, duration) + + override suspend fun getAllLyrics( + id: String, + title: String, + artist: String, + duration: Int, + callback: (String) -> Unit, + ) { + LrcLib.getAllLyrics(title, artist, duration, null, callback) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsEntry.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsEntry.kt new file mode 100644 index 0000000..ad4a5e0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsEntry.kt @@ -0,0 +1,14 @@ +package com.babelsoftware.loudly.lyrics + +data class LyricsEntry( + val time: Long, + val text: String, + var translatedText: String? = null, + var isTranslation: Boolean = false +) : Comparable { + override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() + + companion object { + val HEAD_LYRICS_ENTRY = LyricsEntry(0L, "") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsHelper.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsHelper.kt new file mode 100644 index 0000000..8f3d315 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsHelper.kt @@ -0,0 +1,145 @@ +package com.babelsoftware.loudly.lyrics + +import android.content.Context +import android.os.Build +import android.util.LruCache +import com.babelsoftware.loudly.constants.PreferredLyricsProvider +import com.babelsoftware.loudly.constants.PreferredLyricsProviderKey +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.extensions.toEnum +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class LyricsHelper @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val PREFER_LOCAL_LYRIC = true + private var lyricsProviders = listOf(LrcLibLyricsProvider,KuGouLyricsProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider) + val preferred = context.dataStore.data.map { + it[PreferredLyricsProviderKey].toEnum(PreferredLyricsProvider.LRCLIB) + }.distinctUntilChanged() + .map { + lyricsProviders = if (it == PreferredLyricsProvider.LRCLIB) { + listOf(LrcLibLyricsProvider,KuGouLyricsProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider) + } else { + listOf(KuGouLyricsProvider, LrcLibLyricsProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider) + } + } + private val cache = LruCache>(MAX_CACHE_SIZE) + + suspend fun getLyrics(mediaMetadata: MediaMetadata): String { + val cached = cache.get(mediaMetadata.id)?.firstOrNull() + if (cached != null) { + return cached.lyrics + } + val localLyrics = getLocalLyrics(mediaMetadata) + var remoteLyrics: String? + + // fallback to secondary provider when primary is unavailable + if (PREFER_LOCAL_LYRIC) { + if (localLyrics != null) { + return localLyrics + } + + // "lazy eval" the remote lyrics cuz it is laughably slow + remoteLyrics= getRemoteLyrics(mediaMetadata) + if (remoteLyrics != null) { + return remoteLyrics + } + } else { + remoteLyrics= getRemoteLyrics(mediaMetadata) + if (remoteLyrics != null) { + return remoteLyrics + } else if (localLyrics != null) { + return localLyrics + } + + } + + return LyricsEntity.Companion.LYRICS_NOT_FOUND + } + + /** + * Lookup lyrics from remote providers + */ + private suspend fun getRemoteLyrics(mediaMetadata: MediaMetadata): String? { + lyricsProviders.forEach { provider -> + if (provider.isEnabled(context)) { + provider.getLyrics( + mediaMetadata.id, + mediaMetadata.title, + mediaMetadata.artists.joinToString { it.name }, + mediaMetadata.duration + ).onSuccess { lyrics -> + return lyrics + }.onFailure { + reportException(it) + } + } + } + return null + } + + /** + * Lookup lyrics from local disk (.lrc) file + */ + private suspend fun getLocalLyrics(mediaMetadata: MediaMetadata): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + throw Exception("Local lyrics are not supported below SDK 26 (Oreo)") + } + if (LocalLyricsProvider.isEnabled(context)) { + LocalLyricsProvider.getLyrics( + mediaMetadata.id, + "" + mediaMetadata.localPath, // title used as path + mediaMetadata.artists.joinToString { it.name }, + mediaMetadata.duration + ).onSuccess { lyrics -> + return lyrics + }.onFailure { + reportException(it) + } + } + return null + } + + suspend fun getAllLyrics( + mediaId: String, + songTitle: String, + songArtists: String, + duration: Int, + callback: (LyricsResult) -> Unit, + ) { + val cacheKey = "$songArtists-$songTitle".replace(" ", "") + cache.get(cacheKey)?.let { results -> + results.forEach { + callback(it) + } + return + } + val allResult = mutableListOf() + lyricsProviders.forEach { provider -> + if (provider.isEnabled(context)) { + provider.getAllLyrics(mediaId, songTitle, songArtists, duration) { lyrics -> + val result = LyricsResult(provider.name, lyrics) + allResult += result + callback(result) + } + } + } + cache.put(cacheKey, allResult) + } + + companion object { + private const val MAX_CACHE_SIZE = 3 + } +} + +data class LyricsResult( + val providerName: String, + val lyrics: String, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsProvider.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsProvider.kt new file mode 100644 index 0000000..0d22679 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsProvider.kt @@ -0,0 +1,12 @@ +package com.babelsoftware.loudly.lyrics + +import android.content.Context + +interface LyricsProvider { + val name: String + fun isEnabled(context: Context): Boolean + suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result + suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) { + getLyrics(id, title, artist, duration).onSuccess(callback) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsUtils.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsUtils.kt new file mode 100644 index 0000000..9a81102 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsUtils.kt @@ -0,0 +1,124 @@ +@file:Suppress("UNREACHABLE_CODE") + +package com.babelsoftware.loudly.lyrics + +import com.babelsoftware.loudly.reportException +import com.babelsoftware.loudly.ui.component.animateScrollDuration +import kotlin.math.pow + +object LyricsUtils { + private val timeMarksRegex = "\\[(\\d{2}:\\d{2})([.:]\\d+)?]".toRegex() + /** + * Give lyrics in LRC format, parse and return a list of LyricEntry. + * + * The following implementation is imported from Gramophone (https://github.com/AkaneTan/Gramophone) + * and has been adapted for OverTune (mostly variable renaming). + * Note: OverTube does not support lyric translations. + * + * + * Formats we have to consider in this method are: + * - Simple LRC files (ref Wikipedia) ex: [00:11.22] hello i am lyric + * - "compressed LRC" with >1 tag for repeating line ex: [00:11.22][00:15.33] hello i am lyric + * - Invalid LRC with all-zero tags [00:00.00] hello i am lyric + * - Lyrics that aren't synced and have no tags at all + * - Translations, type 1 (ex: pasting first japanese and then english lrc file into one file) + * - Translations, type 2 (ex: translated line directly under previous non-translated line) + * - The timestamps can variate in the following ways: [00:11] [00:11:22] [00:11.22] [00:11.222] [00:11:222] + * + * Multiline format: + * - This technically isn't part of any listed guidelines, however is allows for + * reading of otherwise discarded lyrics + * - All the lines between sync point A and B are read as lyric text of A + * + * In the future, we also want to support: + * - Extended LRC (ref Wikipedia) ex: [00:11.22] <00:11.22> hello <00:12.85> i am <00:13.23> lyric + * - Wakaloke gender extension (ref Wikipedia) + * - [offset:] tag in header (ref Wikipedia) + * We completely ignore all ID3 tags from the header as MediaStore is our source of truth. + */ + fun parseLyrics(lyrics: String, trim: Boolean, multilineEnable: Boolean): List { + try { + val list = mutableListOf() + var foundNonNull = false + var lyricsText: StringBuilder? = StringBuilder() + lyrics.lines().forEach { line -> + timeMarksRegex.findAll(line).let { sequence -> + if (sequence.count() == 0) { + return@let + } + var lyricLine: String + sequence.forEach { match -> + val firstSync = match.groupValues.subList(1, match.groupValues.size) + .joinToString("") + val ts = parseTime(firstSync) + if (!foundNonNull && ts > 0) { + foundNonNull = true + lyricsText = null + } + if (multilineEnable) { + val startIndex = lyrics.indexOf(line) + firstSync.length + 1 + var endIndex = lyrics.length + var nextSync = "" + if (timeMarksRegex.find(lyrics, startIndex)?.value != null) { + nextSync = timeMarksRegex.find(lyrics, startIndex)?.value!! + endIndex = lyrics.indexOf(nextSync) - 1 + } + lyricLine = if (nextSync == "[$firstSync]") { + line.substring(sequence.last().range.last + 1) + .let { if (trim) it.trim() else it } + } else { + lyrics.substring(startIndex + 1, endIndex) + .let { if (trim) it.trim() else it } + } + } else { + lyricLine = line.substring(sequence.last().range.last + 1) + .let { if (trim) it.trim() else it } + } + lyricsText?.append(lyricLine + "\n") + list.add(LyricsEntry(time = ts, text = lyricLine)) + } + } + } + list.sortBy { it.time } + var previousTs = -1L + list.forEach { + it.isTranslation = (it.time == previousTs) + previousTs = it.time + } + if (list.isEmpty() && lyrics.isNotEmpty()) { + list.add(LyricsEntry(time = 1, text = lyrics, isTranslation = false)) + } else if (!foundNonNull) { + list.clear() + list.add(LyricsEntry(time = 1, text = lyricsText!!.toString(), isTranslation = false)) + } + return list + } catch (e: Exception) { + reportException(e) + return listOf(LyricsEntry(time = 1, text = lyrics, isTranslation = false)) + } + } + /** + * Parse a timestamp in string format (ex: [mm:ss.ms]) into a Long value + * + * The following implementation is imported from Gramophone (https://github.com/AkaneTan/Gramophone) + */ + private fun parseTime(timeString: String): Long { + val timeRegex = "(\\d{2}):(\\d{2})[.:](\\d+)".toRegex() + val matchResult = timeRegex.find(timeString) + val minutes = matchResult?.groupValues?.get(1)?.toLongOrNull() ?: 0 + val seconds = matchResult?.groupValues?.get(2)?.toLongOrNull() ?: 0 + val millisecondsString = matchResult?.groupValues?.get(3) + val milliseconds = (millisecondsString?.substring(0, millisecondsString.length.coerceAtMost(3) + )?.toLongOrNull() ?: 0) * 10f.pow(3 - (millisecondsString?.length ?: 0)).toLong() + return minutes * 60000 + seconds * 1000 + milliseconds + } + + fun findCurrentLineIndex(lines: List, position: Long): Int { + for (index in lines.indices) { + if (lines[index].time >= position + animateScrollDuration) { + return index - 1 + } + } + return lines.lastIndex + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsViewModel.kt new file mode 100644 index 0000000..ccf79b9 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/LyricsViewModel.kt @@ -0,0 +1,113 @@ +package com.babelsoftware.loudly.lyrics + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.reportException +import com.google.mlkit.nl.languageid.LanguageIdentification +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +sealed class LyricsUiState { + data object Loading : LyricsUiState() + data class Success(val entries: List) : LyricsUiState() + data class Error(val message: String) : LyricsUiState() +} + +@HiltViewModel +class LyricsViewModel @Inject constructor( + private val lyricsHelper: LyricsHelper, + private val translationService: MlKitTranslationService +) : ViewModel() { + + private val _lyricsState = MutableStateFlow(LyricsUiState.Success(emptyList())) + val lyricsState: StateFlow = _lyricsState + val targetLanguages: List = translationService.targetLanguages + + private var originalLyrics: String? = null + private var isTranslationActive = false + private var sourceLanguage: String? = null + + fun loadLyricsFor(mediaMetadata: MediaMetadata) { + viewModelScope.launch { + isTranslationActive = false + _lyricsState.value = LyricsUiState.Loading + + try { + val lyricsResult = withContext(Dispatchers.IO) { + lyricsHelper.getLyrics(mediaMetadata) + } + + if (lyricsResult == LyricsEntity.LYRICS_NOT_FOUND) { + _lyricsState.value = LyricsUiState.Error("Not_found_LYRİCS") + return@launch + } + + originalLyrics = lyricsResult + identifyLanguage(lyricsResult) + + val parsed = parseLyricsSafely(lyricsResult) + _lyricsState.value = LyricsUiState.Success(parsed) + } catch (e: Exception) { + reportException(e) + _lyricsState.value = LyricsUiState.Error("An error occurred while loading the lyrics.") + } + } + } + + private fun identifyLanguage(text: String) { + val languageIdentifier = LanguageIdentification.getClient() + languageIdentifier.identifyLanguage(text) + .addOnSuccessListener { languageCode -> + sourceLanguage = if (languageCode != "und") languageCode else null + } + .addOnFailureListener { + sourceLanguage = null + } + } + + fun translateLyrics(targetLanguage: String) { + viewModelScope.launch { + if (originalLyrics == null) { + _lyricsState.value = LyricsUiState.Error("No lyrics to translate found.") + return@launch + } + + if (sourceLanguage != "en") { + _lyricsState.value = LyricsUiState.Error("Currently, only English song lyrics can be translated.") + return@launch + } + + isTranslationActive = true + _lyricsState.value = LyricsUiState.Loading + try { + val translatedText = withContext(Dispatchers.IO) { + translationService.translate(originalLyrics!!, targetLanguage) + } + val parsed = parseLyricsSafely(translatedText) + _lyricsState.value = LyricsUiState.Success(parsed) + } catch (e: Exception) { + reportException(e) + _lyricsState.value = LyricsUiState.Error("Translation failed.") + isTranslationActive = false + } + } + } + + private suspend fun parseLyricsSafely(text: String): List { + return withContext(Dispatchers.Default) { + try { + LyricsUtils.parseLyrics(text, trim = true, multilineEnable = false) + } catch (e: Exception) { + reportException(e) + emptyList() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/MlKitTranslationService.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/MlKitTranslationService.kt new file mode 100644 index 0000000..10723d6 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/MlKitTranslationService.kt @@ -0,0 +1,94 @@ +package com.babelsoftware.loudly.lyrics // veya services paketi + +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.Translator +import com.google.mlkit.nl.translate.TranslatorOptions +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Singleton +class MlKitTranslationService @Inject constructor() { + + // ---> Desteklenen hedef dillerin listesi. + val targetLanguages: List = listOf( + TranslateLanguage.TURKISH, + TranslateLanguage.RUSSIAN, + TranslateLanguage.CHINESE, + TranslateLanguage.FRENCH, + TranslateLanguage.GERMAN, + TranslateLanguage.ITALIAN, + TranslateLanguage.SPANISH, + TranslateLanguage.PORTUGUESE, + TranslateLanguage.JAPANESE, + TranslateLanguage.KOREAN, + TranslateLanguage.ARABIC, + TranslateLanguage.INDONESIAN, + TranslateLanguage.THAI, + TranslateLanguage.VIETNAMESE, + TranslateLanguage.HEBREW, + TranslateLanguage.URDU, + TranslateLanguage.GREEK, + TranslateLanguage.BULGARIAN, + TranslateLanguage.PERSIAN, + TranslateLanguage.ROMANIAN, + TranslateLanguage.UKRAINIAN, + TranslateLanguage.NORWEGIAN, + TranslateLanguage.CZECH, + TranslateLanguage.HUNGARIAN, + TranslateLanguage.FINNISH, + TranslateLanguage.SWEDISH, + TranslateLanguage.DANISH, + TranslateLanguage.SLOVAK, + TranslateLanguage.SLOVENIAN, + TranslateLanguage.LATVIAN, + TranslateLanguage.LITHUANIAN, + TranslateLanguage.MACEDONIAN, + TranslateLanguage.ALBANIAN + ) + // <--- + + /** + * Creates a Translator object for the specified target language. + */ + private fun createTranslator(targetLanguage: String): Translator { + val options = TranslatorOptions.Builder() + .setSourceLanguage(TranslateLanguage.ENGLISH) + .setTargetLanguage(targetLanguage) + .build() + return Translation.getClient(options) + } + + /** + * Downloads the required language model. + */ + private fun downloadModelIfNeeded(translator: Translator) { + val conditions = DownloadConditions.Builder() + .requireWifi() + .build() + translator.downloadModelIfNeeded(conditions) + .addOnSuccessListener { /* The model has been downloaded or is already available. */ } + .addOnFailureListener { e -> /* Model download error. */ } + } + + /** + * Translates the given text from English into the specified target language. + */ + suspend fun translate(text: String, targetLanguage: String): String { + val translator = createTranslator(targetLanguage) + downloadModelIfNeeded(translator) + return suspendCancellableCoroutine { continuation -> + translator.translate(text) + .addOnSuccessListener { translatedText -> + continuation.resume(translatedText) + } + .addOnFailureListener { e -> + continuation.resumeWithException(e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeLyricsProvider.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeLyricsProvider.kt new file mode 100644 index 0000000..eb9d08f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeLyricsProvider.kt @@ -0,0 +1,16 @@ +package com.babelsoftware.loudly.lyrics + +import android.content.Context +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.WatchEndpoint + +object YouTubeLyricsProvider : LyricsProvider { + override val name = "YouTube Music" + override fun isEnabled(context: Context) = true + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = runCatching { + val nextResult = YouTube.next(WatchEndpoint(videoId = id)).getOrThrow() + YouTube.lyrics( + endpoint = nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found") + ).getOrThrow() ?: throw IllegalStateException("Lyrics unavailable") + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeSubtitleLyricsProvider.kt b/app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeSubtitleLyricsProvider.kt new file mode 100644 index 0000000..0bac901 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/lyrics/YouTubeSubtitleLyricsProvider.kt @@ -0,0 +1,11 @@ +package com.babelsoftware.loudly.lyrics + +import android.content.Context +import com.babelsoftware.innertube.YouTube + +object YouTubeSubtitleLyricsProvider : LyricsProvider { + override val name = "YouTube Subtitle" + override fun isEnabled(context: Context) = true + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = + YouTube.transcript(id) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/models/ImageCache.kt b/app/src/main/java/com/babelsoftware/loudly/models/ImageCache.kt new file mode 100644 index 0000000..43b5ef4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/ImageCache.kt @@ -0,0 +1,56 @@ +package com.babelsoftware.loudly.models + +import android.graphics.Bitmap + +/** + * Cached image + */ +data class CachedBitmap(var path: String?, var image: Bitmap?, var resizedImage: Bitmap?) +class ImageCacheManager(private val maxSize: Int) { + private var bitmapCache = ArrayDeque() + + /** + * Retrieves an image from the cache + */ + fun retrieveImage(path: String): CachedBitmap? { + // do not remove the null check regardless of what kotlin or IDE tells you. it?.path + return bitmapCache.firstOrNull { it.path == path } + } + + /** + * Adds an image to the cache + */ + fun cache(path: String, image: Bitmap?, resize: Boolean) { + if (image == null) { + return + } + + // adhere to limit + if (bitmapCache.size >= maxSize) { + bitmapCache.removeFirst() + } + + val existingCached = retrieveImage(path) + if (existingCached == null) { + // add the image + if (resize) { + bitmapCache.addLast(CachedBitmap(path, null, image)) + } else { + bitmapCache.addLast(CachedBitmap(path, image, null)) + } + } else { + if (resize) { + existingCached.resizedImage = image + } else { + existingCached.image = image + } + } + } + + /** + * Removes all cached images + */ + fun purgeCache() { + bitmapCache = ArrayDeque() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/ItemsPage.kt b/app/src/main/java/com/babelsoftware/loudly/models/ItemsPage.kt new file mode 100644 index 0000000..25b8779 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/ItemsPage.kt @@ -0,0 +1,8 @@ +package com.babelsoftware.loudly.models + +import com.babelsoftware.innertube.models.YTItem + +data class ItemsPage( + val items: List, + val continuation: String?, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/models/MediaMetadata.kt b/app/src/main/java/com/babelsoftware/loudly/models/MediaMetadata.kt new file mode 100644 index 0000000..7082e35 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/MediaMetadata.kt @@ -0,0 +1,92 @@ +package com.babelsoftware.loudly.models + +import androidx.compose.runtime.Immutable +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.ui.utils.resize +import java.io.Serializable + +@Immutable +data class MediaMetadata( + val id: String, + val title: String, + val artists: List, + val duration: Int, + val thumbnailUrl: String? = null, + val album: Album? = null, + val setVideoId: String? = null, + val isLocal: Boolean = false, + val localPath: String? = null, + val explicit: Boolean = false, + val blurSync: Boolean = false, + val blurThumbnail: String? = null, +) : Serializable { + data class Artist( + val id: String?, + val name: String, + ) : Serializable + + data class Album( + val id: String, + val title: String, + ) : Serializable + + fun toSongEntity() = SongEntity( + id = id, + title = title, + duration = duration, + thumbnailUrl = thumbnailUrl, + albumId = album?.id, + albumName = album?.title, + isLocal = isLocal, + localPath = localPath + ) +} + +fun Song.toMediaMetadata() = MediaMetadata( + id = song.id, + title = song.title, + artists = artists.map { + MediaMetadata.Artist( + id = it.id, + name = it.name, + ) + }, + duration = song.duration, + thumbnailUrl = song.thumbnailUrl, + album = album?.let { + MediaMetadata.Album( + id = it.id, + title = it.title, + ) + } ?: song.albumId?.let { albumId -> + MediaMetadata.Album( + id = albumId, + title = song.albumName.orEmpty() + ) + }, + isLocal = song.isLocal, + localPath = song.localPath +) + +fun SongItem.toMediaMetadata() = MediaMetadata( + id = id, + title = title, + artists = artists.map { + MediaMetadata.Artist( + id = it.id, + name = it.name + ) + }, + duration = duration ?: -1, + thumbnailUrl = thumbnail.resize(544, 544), + album = album?.let { + MediaMetadata.Album( + id = it.id, + title = it.name + ) + }, + explicit = explicit, + setVideoId = setVideoId +) diff --git a/app/src/main/java/com/babelsoftware/loudly/models/PersistQueue.kt b/app/src/main/java/com/babelsoftware/loudly/models/PersistQueue.kt new file mode 100644 index 0000000..47ed412 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/PersistQueue.kt @@ -0,0 +1,10 @@ +package com.babelsoftware.loudly.models + +import java.io.Serializable + +data class PersistQueue( + val title: String?, + val items: List, + val mediaItemIndex: Int, + val position: Long, +) : Serializable diff --git a/app/src/main/java/com/babelsoftware/loudly/models/SimilarRecommendation.kt b/app/src/main/java/com/babelsoftware/loudly/models/SimilarRecommendation.kt new file mode 100644 index 0000000..76ac047 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/SimilarRecommendation.kt @@ -0,0 +1,9 @@ +package com.babelsoftware.loudly.models + +import com.babelsoftware.innertube.models.YTItem +import com.babelsoftware.loudly.db.entities.LocalItem + +data class SimilarRecommendation( + val title: LocalItem, + val items: List, +) diff --git a/app/src/main/java/com/babelsoftware/loudly/models/SongWithPlayCount.kt b/app/src/main/java/com/babelsoftware/loudly/models/SongWithPlayCount.kt new file mode 100644 index 0000000..0103063 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/SongWithPlayCount.kt @@ -0,0 +1,10 @@ +package com.babelsoftware.loudly.models + +import androidx.room.Embedded +import com.babelsoftware.loudly.db.entities.Song + +data class SongWithPlayCount( + @Embedded + val song: Song, + val playCount: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/SpotifyAuthResponse.kt b/app/src/main/java/com/babelsoftware/loudly/models/SpotifyAuthResponse.kt new file mode 100644 index 0000000..a68fa13 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/SpotifyAuthResponse.kt @@ -0,0 +1,16 @@ +package com.babelsoftware.loudly.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SpotifyAuthResponse( + @SerialName("access_token") + val accessToken: String + + /* + only `access_token` is required for the import process. + the spotify servers will be triggered again if a new import needs to be made. + */ + +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/SpotifyUserProfile.kt b/app/src/main/java/com/babelsoftware/loudly/models/SpotifyUserProfile.kt new file mode 100644 index 0000000..f8c6ba7 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/SpotifyUserProfile.kt @@ -0,0 +1,10 @@ +package com.babelsoftware.loudly.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SpotifyUserProfile( + @SerialName("display_name") + val displayName: String +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/Tracks.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/Tracks.kt new file mode 100644 index 0000000..996a847 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/Tracks.kt @@ -0,0 +1,10 @@ +package com.babelsoftware.loudly.models.spotify + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Tracks( + @SerialName("total") + val totalTracksCount: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/Images.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/Images.kt new file mode 100644 index 0000000..af876e4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/Images.kt @@ -0,0 +1,8 @@ +package com.babelsoftware.loudly.models.spotify.playlists + +import kotlinx.serialization.Serializable + +@Serializable +data class Images( + val url:String +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistItem.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistItem.kt new file mode 100644 index 0000000..28368d1 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistItem.kt @@ -0,0 +1,19 @@ +package com.babelsoftware.loudly.models.spotify.playlists + +import com.babelsoftware.loudly.models.spotify.Tracks +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SpotifyPlaylistItem( + @SerialName("description") + var playlistDescription: String, + @SerialName("id") + var playlistId: String, + var images: List = emptyList(), + @SerialName("name") + var playlistName: String, + var tracks: Tracks? = Tracks(0), + var type: String, + var uri: String +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistPaginatedResponse.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistPaginatedResponse.kt new file mode 100644 index 0000000..a5fd70d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/playlists/SpotifyPlaylistPaginatedResponse.kt @@ -0,0 +1,15 @@ +package com.babelsoftware.loudly.models.spotify.playlists + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SpotifyPlaylistPaginatedResponse( + @SerialName("next") + var nextUrl: String? = null, + @SerialName("previous") + var previousUrl: String? = null, + @SerialName("total") + var totalResults: Int? = null, + var items: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Artists.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Artists.kt new file mode 100644 index 0000000..daad4e7 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Artists.kt @@ -0,0 +1,8 @@ +package com.babelsoftware.loudly.models.spotify.tracks + +import kotlinx.serialization.Serializable + +@Serializable +data class Artist( + val name: String +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/SpotifyResultPaginatedResponse.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/SpotifyResultPaginatedResponse.kt new file mode 100644 index 0000000..6ec578c --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/SpotifyResultPaginatedResponse.kt @@ -0,0 +1,14 @@ +package com.babelsoftware.loudly.models.spotify.tracks + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SpotifyResultPaginatedResponse( + @SerialName("total") + val totalCountOfLikedSongs: Int, + @SerialName("next") + val nextPaginatedUrl: String? = null, + @SerialName("items") + val tracks: List +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Track.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Track.kt new file mode 100644 index 0000000..ec3993c --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/Track.kt @@ -0,0 +1,10 @@ +package com.babelsoftware.loudly.models.spotify.tracks + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Track( + @SerialName("track") + val trackItem: TrackItem +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/TrackItem.kt b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/TrackItem.kt new file mode 100644 index 0000000..40561db --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/models/spotify/tracks/TrackItem.kt @@ -0,0 +1,16 @@ +package com.babelsoftware.loudly.models.spotify.tracks + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TrackItem( + val type: String, + @SerialName("id") + val trackId: String, + @SerialName("name") + val trackName: String, + @SerialName("is_local") + val isLocal: Boolean, + val artists:List +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/DownloadUtil.kt b/app/src/main/java/com/babelsoftware/loudly/playback/DownloadUtil.kt new file mode 100644 index 0000000..3acaa4a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/DownloadUtil.kt @@ -0,0 +1,186 @@ +package com.babelsoftware.loudly.playback + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.media3.database.DatabaseProvider +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import com.babelsoftware.loudly.constants.AudioQuality +import com.babelsoftware.loudly.constants.AudioQualityKey +import com.babelsoftware.loudly.constants.LikedAutodownloadMode +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.FormatEntity +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.di.DownloadCache +import com.babelsoftware.loudly.di.PlayerCache +import com.babelsoftware.loudly.extensions.getLikeAutoDownload +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.utils.YTPlayerUtils +import com.babelsoftware.loudly.utils.enumPreference +import com.babelsoftware.innertube.YouTube +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import java.util.concurrent.Executor +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.collections.get + +@Singleton +class DownloadUtil @Inject constructor( + @ApplicationContext private val context: Context, + val database: MusicDatabase, + databaseProvider: DatabaseProvider, + @DownloadCache val downloadCache: SimpleCache, + @PlayerCache val playerCache: SimpleCache, +) { + private val connectivityManager = context.getSystemService()!! + private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO) + private val songUrlCache = HashMap>() + private val dataSourceFactory = ResolvingDataSource.Factory( + CacheDataSource.Factory() + .setCache(playerCache) + .setUpstreamDataSourceFactory( + OkHttpDataSource.Factory( + OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + ) + ) + ) { dataSpec -> + val mediaId = dataSpec.key ?: error("No media id") + val length = if (dataSpec.length >= 0) dataSpec.length else 1 + + if (playerCache.isCached(mediaId, dataSpec.position, length)) { + return@Factory dataSpec + } + + songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { + return@Factory dataSpec.withUri(it.first.toUri()) + } + + val playbackData = runBlocking(Dispatchers.IO) { + YTPlayerUtils.playerResponseForPlayback( + mediaId, + audioQuality = audioQuality, + connectivityManager = connectivityManager, + ) + }.getOrThrow() + val format = playbackData.format + + + + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + loudnessDb = playbackData.audioConfig?.loudnessDb, + playbackUrl = playbackData.playbackTracking?.videostatsPlaybackUrl?.baseUrl, + contentLength = format.contentLength!!, + ), + ) + } + + val streamUrl = playbackData.streamUrl.let { + // Specify range to avoid YouTube's throttling + "${it}&range=0-${format.contentLength ?: 10000000}" + } + + songUrlCache[mediaId] = streamUrl to playbackData.streamExpiresInSeconds * 1000L + dataSpec.withUri(streamUrl.toUri()) + } + val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) + val downloadManager: DownloadManager = DownloadManager(context, databaseProvider, downloadCache, dataSourceFactory, Executor(Runnable::run)).apply { + maxParallelDownloads = 3 + addListener( + ExoDownloadService.TerminalStateNotificationHelper( + context = context, + notificationHelper = downloadNotificationHelper, + nextNotificationId = ExoDownloadService.NOTIFICATION_ID + 1 + ) + ) + } + val downloads = MutableStateFlow>(emptyMap()) + + fun getDownload(songId: String?): Flow = downloads.map { it[songId] } + + fun download(songs: List) { + songs.forEach { song -> downloadSong(song.id, song.title) } + } + fun download(song: MediaMetadata){ + downloadSong(song.id, song.title) + } + fun download(song: SongEntity){ + downloadSong(song.id, song.title) + } + private fun downloadSong(id: String, title: String){ + val downloadRequest = DownloadRequest.Builder(id, id.toUri()) + .setCustomCacheKey(id) + .setData(title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false) + } + + fun autoDownloadIfLiked(songs: List){ + songs.forEach { song -> autoDownloadIfLiked(song) } + } + + fun autoDownloadIfLiked(song: SongEntity){ + if (!song.liked || song.dateDownload != null){ + return + } + val isWifiConnected = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false + if ( + context.getLikeAutoDownload() == LikedAutodownloadMode.ON + || (context.getLikeAutoDownload() == LikedAutodownloadMode.WIFI_ONLY && isWifiConnected) + ) + { + download(song) + } + } + + init { + val result = mutableMapOf() + val cursor = downloadManager.downloadIndex.getDownloads() + while (cursor.moveToNext()) { + result[cursor.download.request.id] = cursor.download + } + downloads.value = result + downloadManager.addListener( + object : DownloadManager.Listener { + override fun onDownloadChanged(downloadManager: DownloadManager, download: Download, finalException: Exception?) { + downloads.update { map -> + map.toMutableMap().apply { + set(download.request.id, download) + } + } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/ExoDownloadService.kt b/app/src/main/java/com/babelsoftware/loudly/playback/ExoDownloadService.kt new file mode 100644 index 0000000..9d55ac0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/ExoDownloadService.kt @@ -0,0 +1,100 @@ +package com.babelsoftware.loudly.playback + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import androidx.media3.common.util.NotificationUtil +import android.content.Intent +import android.graphics.drawable.Icon +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.scheduler.PlatformScheduler +import androidx.media3.exoplayer.scheduler.Scheduler +import com.babelsoftware.loudly.R +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + + +@AndroidEntryPoint +class ExoDownloadService : DownloadService( + NOTIFICATION_ID, + 1000L, + CHANNEL_ID, + R.string.download, + 0 +) { + @Inject + lateinit var downloadUtil: DownloadUtil + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == REMOVE_ALL_PENDING_DOWNLOADS) { + downloadManager.currentDownloads.forEach { download -> + downloadManager.removeDownload(download.request.id) + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun getDownloadManager() = downloadUtil.downloadManager + + override fun getScheduler(): Scheduler = PlatformScheduler(this, JOB_ID) + + override fun getForegroundNotification(downloads: MutableList, notMetRequirements: Int): Notification = + Notification.Builder.recoverBuilder( + this, downloadUtil.downloadNotificationHelper.buildProgressNotification( + this, + R.drawable.download, + null, + if (downloads.size == 1) Util.fromUtf8Bytes(downloads[0].request.data) + else resources.getQuantityString(R.plurals.n_song, downloads.size, downloads.size), + downloads, + notMetRequirements + ) + ).addAction( + Notification.Action.Builder( + Icon.createWithResource(this, R.drawable.close), + getString(android.R.string.cancel), + PendingIntent.getService( + this, + 0, + Intent(this, ExoDownloadService::class.java).setAction(REMOVE_ALL_PENDING_DOWNLOADS), + PendingIntent.FLAG_IMMUTABLE + ) + ).build() + ).build() + + /** + * This helper will outlive the lifespan of a single instance of [ExoDownloadService] + */ + class TerminalStateNotificationHelper( + private val context: Context, + private val notificationHelper: DownloadNotificationHelper, + private var nextNotificationId: Int, + ) : DownloadManager.Listener { + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception?, + ) { + if (download.state == Download.STATE_FAILED) { + val notification = notificationHelper.buildDownloadFailedNotification( + context, + R.drawable.error, + null, + Util.fromUtf8Bytes(download.request.data) + ) + NotificationUtil.setNotification(context, nextNotificationId++, notification) + } + } + } + + companion object { + const val CHANNEL_ID = "download" + const val NOTIFICATION_ID = 1 + const val JOB_ID = 1 + const val REMOVE_ALL_PENDING_DOWNLOADS = "REMOVE_ALL_PENDING_DOWNLOADS" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/KeepAlive.kt b/app/src/main/java/com/babelsoftware/loudly/playback/KeepAlive.kt new file mode 100644 index 0000000..750db07 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/KeepAlive.kt @@ -0,0 +1,70 @@ +package com.babelsoftware.loudly.playback + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.Build +import androidx.core.app.NotificationCompat +import com.babelsoftware.loudly.MainActivity +import com.babelsoftware.loudly.R +/** + * Dummy service that does nothing but post a notification + */ +class KeepAlive : Service() { + private lateinit var notification: Notification + private val binder = DummyBinder() + inner class DummyBinder : Binder() { + val service: KeepAlive + get() = this@KeepAlive + } + override fun onCreate() { + super.onCreate() + notification = getNotification() + createNotificationChannel() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(1, notification) + return START_STICKY + } + override fun onBind(intent: Intent?) = binder + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + KEEP_ALIVE_CHANNEL_ID, + "ot_keep_alive", + NotificationManager.IMPORTANCE_MIN + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(serviceChannel) + } + } + private fun getNotification(): Notification { + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ) + val builder = NotificationCompat.Builder(this, KEEP_ALIVE_CHANNEL_ID) + .setContentTitle("Keep alive") + .setSmallIcon(R.drawable.small_icon) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setVisibility(NotificationCompat.VISIBILITY_SECRET) + .setAutoCancel(false) + .setShowWhen(false) + .setLocalOnly(true) + .setOngoing(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) + } + return builder.build() + } + companion object { + const val KEEP_ALIVE_CHANNEL_ID = "Loudly_keep_alive" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/MediaLibrarySessionCallback.kt b/app/src/main/java/com/babelsoftware/loudly/playback/MediaLibrarySessionCallback.kt new file mode 100644 index 0000000..a74321d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/MediaLibrarySessionCallback.kt @@ -0,0 +1,326 @@ +package com.babelsoftware.loudly.playback + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Bundle +import androidx.annotation.DrawableRes +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.exoplayer.offline.Download +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionError +import androidx.media3.session.SessionResult +import com.babelsoftware.loudly.constants.MediaSessionConstants +import com.babelsoftware.loudly.constants.SongSortType +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.toggleRepeatMode +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.babelsoftware.loudly.BuildConfig +import com.babelsoftware.loudly.R +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.plus +import timber.log.Timber +import javax.inject.Inject + +class MediaLibrarySessionCallback @Inject constructor( + @ApplicationContext val context: Context, + val database: MusicDatabase, + val downloadUtil: DownloadUtil, +) : MediaLibrarySession.Callback { + private val TAG = MediaLibrarySessionCallback::class.simpleName.toString() + private val scope = CoroutineScope(Dispatchers.Main) + Job() + var toggleLike: () -> Unit = {} + var toggleStartRadio: () -> Unit = {} + var toggleLibrary: () -> Unit = {} + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + return MediaSession.ConnectionResult.accept( + connectionResult.availableSessionCommands.buildUpon() + .add(MediaSessionConstants.CommandToggleLibrary) + .add(MediaSessionConstants.CommandToggleLike) + .add(MediaSessionConstants.CommandToggleStartRadio) + .add(MediaSessionConstants.CommandToggleShuffle) + .add(MediaSessionConstants.CommandToggleRepeatMode) + .build(), + connectionResult.availablePlayerCommands + ) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + when (customCommand.customAction) { + MediaSessionConstants.ACTION_TOGGLE_LIKE -> toggleLike() + MediaSessionConstants.ACTION_TOGGLE_LIBRARY -> toggleLibrary() + MediaSessionConstants.ACTION_TOGGLE_START_RADIO -> toggleStartRadio() + MediaSessionConstants.ACTION_TOGGLE_SHUFFLE -> session.player.shuffleModeEnabled = !session.player.shuffleModeEnabled + MediaSessionConstants.ACTION_TOGGLE_REPEAT_MODE -> session.player.toggleRepeatMode() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams?, + ): ListenableFuture> = Futures.immediateFuture( + LibraryResult.ofItem( + MediaItem.Builder() + .setMediaId(MusicService.ROOT) + .setMediaMetadata( + MediaMetadata.Builder() + .setIsPlayable(false) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build() + ) + .build(), + params + ) + ) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams?, + ): ListenableFuture>> = scope.future(Dispatchers.IO) { + LibraryResult.ofItemList( + when (parentId) { + MusicService.ROOT -> listOf( + browsableMediaItem(MusicService.SONG, context.getString(R.string.songs), null, drawableUri(R.drawable.music_note), MediaMetadata.MEDIA_TYPE_PLAYLIST), + browsableMediaItem(MusicService.ARTIST, context.getString(R.string.artists), null, drawableUri(R.drawable.artist), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), + browsableMediaItem(MusicService.ALBUM, context.getString(R.string.albums), null, drawableUri(R.drawable.album), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), + browsableMediaItem(MusicService.PLAYLIST, context.getString(R.string.playlists), null, drawableUri(R.drawable.queue_music), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + ) + + MusicService.SONG -> database.songsByCreateDateAsc().first().map { it.toMediaItem(parentId) } + MusicService.ARTIST -> database.artistsByCreateDateAsc().first().map { artist -> + browsableMediaItem("${MusicService.ARTIST}/${artist.id}", artist.artist.name, context.resources.getQuantityString(R.plurals.n_song, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri(), MediaMetadata.MEDIA_TYPE_ARTIST) + } + + MusicService.ALBUM -> database.albumsByCreateDateAsc().first().map { album -> + browsableMediaItem("${MusicService.ALBUM}/${album.id}", album.album.title, album.artists.joinToString { it.name }, album.album.thumbnailUrl?.toUri(), MediaMetadata.MEDIA_TYPE_ALBUM) + } + + MusicService.PLAYLIST -> { + val likedSongCount = database.likedSongsCount().first() + val downloadedSongCount = downloadUtil.downloads.value.size + listOf( + browsableMediaItem("${MusicService.PLAYLIST}/${PlaylistEntity.Companion.LIKED_PLAYLIST_ID}", context.getString(R.string.liked_songs), context.resources.getQuantityString(R.plurals.n_song, likedSongCount, likedSongCount), drawableUri(R.drawable.favorite), MediaMetadata.MEDIA_TYPE_PLAYLIST), + browsableMediaItem("${MusicService.PLAYLIST}/${PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID}", context.getString(R.string.downloaded_songs), context.resources.getQuantityString(R.plurals.n_song, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.download), MediaMetadata.MEDIA_TYPE_PLAYLIST) + ) + database.playlistsByCreateDateAsc().first().map { playlist -> + browsableMediaItem("${MusicService.PLAYLIST}/${playlist.id}", playlist.playlist.name, context.resources.getQuantityString(R.plurals.n_song, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri(), MediaMetadata.MEDIA_TYPE_PLAYLIST) + } + } + + else -> when { + parentId.startsWith("${MusicService.ARTIST}/") -> + database.artistSongsByCreateDateAsc(parentId.removePrefix("${MusicService.ARTIST}/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("${MusicService.ALBUM}/") -> + database.albumSongs(parentId.removePrefix("${MusicService.ALBUM}/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("${MusicService.PLAYLIST}/") -> + when (val playlistId = parentId.removePrefix("${MusicService.PLAYLIST}/")) { + PlaylistEntity.Companion.LIKED_PLAYLIST_ID -> database.likedSongs( + SongSortType.CREATE_DATE, true) + PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } + + else -> database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first().map { + it.toMediaItem(parentId) + } + + else -> emptyList() + } + }, + params + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String, + ): ListenableFuture> = scope.future(Dispatchers.IO) { + database.song(mediaId).first()?.toMediaItem()?.let { + LibraryResult.ofItem(it, null) + } ?: LibraryResult.ofError(SessionError.ERROR_UNKNOWN) + } + + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture = scope.future { + val defaultResult = MediaSession.MediaItemsWithStartPosition(emptyList(), startIndex, startPositionMs) + val path = mediaItems.firstOrNull()?.mediaId?.split("/") + ?: return@future defaultResult + when (path.firstOrNull()) { + MusicService.SONG -> { + val songId = path.getOrNull(1) ?: return@future defaultResult + val allSongs = database.songsByCreateDateAsc().first() + MediaSession.MediaItemsWithStartPosition( + allSongs.map { it.toMediaItem() }, + allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + MusicService.ARTIST -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val artistId = path.getOrNull(1) ?: return@future defaultResult + val songs = database.artistSongsByCreateDateAsc(artistId).first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + MusicService.ALBUM -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val albumId = path.getOrNull(1) ?: return@future defaultResult + val albumWithSongs = database.albumWithSongs(albumId).first() ?: return@future defaultResult + MediaSession.MediaItemsWithStartPosition( + albumWithSongs.songs.map { it.toMediaItem() }, + albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + MusicService.PLAYLIST -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val playlistId = path.getOrNull(1) ?: return@future defaultResult + val songs = when (playlistId) { + PlaylistEntity.Companion.LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true) + PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } + + else -> database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) + } + + else -> defaultResult + } + } + + override fun onSearch( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: MediaLibraryService.LibraryParams? + ): ListenableFuture> { + if (BuildConfig.DEBUG) { + Timber.tag(TAG).d("MediaLibrarySessionCallback.onSearch: $query") + } + session.notifySearchResultChanged(browser, query, 0, params) + return Futures.immediateFuture(LibraryResult.ofVoid(params)) + } + + private fun drawableUri(@DrawableRes id: Int) = Uri.Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.resources.getResourcePackageName(id)) + .appendPath(context.resources.getResourceTypeName(id)) + .appendPath(context.resources.getResourceEntryName(id)) + .build() + + private fun browsableMediaItem(id: String, title: String, subtitle: String?, iconUri: Uri?, mediaType: Int = MediaMetadata.MEDIA_TYPE_MUSIC) = + MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setArtist(subtitle) + .setArtworkUri(iconUri) + .setIsPlayable(false) + .setIsBrowsable(true) + .setMediaType(mediaType) + .build() + ) + .build() + + private fun Song.toMediaItem(path: String) = + MediaItem.Builder() + .setMediaId("$path/$id") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build() + ) + .build() +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/MusicService.kt b/app/src/main/java/com/babelsoftware/loudly/playback/MusicService.kt new file mode 100644 index 0000000..e4c9eaf --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/MusicService.kt @@ -0,0 +1,1039 @@ +package com.babelsoftware.loudly.playback + +import android.app.PendingIntent +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.database.SQLException +import android.media.AudioManager +import android.media.audiofx.AudioEffect +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Binder +import android.os.Bundle +import android.util.Log +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.datastore.preferences.core.edit +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.EVENT_POSITION_DISCONTINUITY +import androidx.media3.common.Player.EVENT_TIMELINE_CHANGED +import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_SEEK +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Player.STATE_IDLE +import androidx.media3.common.Timeline +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.PlaybackStats +import androidx.media3.exoplayer.analytics.PlaybackStatsListener +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaController +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionToken +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.innertube.models.response.PlayerResponse +import com.babelsoftware.loudly.MainActivity +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AddingPlayedSongsToYTMHistoryKey +import com.babelsoftware.loudly.constants.AudioNormalizationKey +import com.babelsoftware.loudly.constants.AudioOffload +import com.babelsoftware.loudly.constants.AudioQuality +import com.babelsoftware.loudly.constants.AudioQualityKey +import com.babelsoftware.loudly.constants.AutoLoadMoreKey +import com.babelsoftware.loudly.constants.AutoPlaySongWhenBluetoothDeviceConnectedKey +import com.babelsoftware.loudly.constants.DiscordTokenKey +import com.babelsoftware.loudly.constants.EnableDiscordRPCKey +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.constants.KeepAliveKey +import com.babelsoftware.loudly.constants.MediaSessionConstants +import com.babelsoftware.loudly.constants.PauseListenHistoryKey +import com.babelsoftware.loudly.constants.PersistentQueueKey +import com.babelsoftware.loudly.constants.PlayerVolumeKey +import com.babelsoftware.loudly.constants.RepeatModeKey +import com.babelsoftware.loudly.constants.ShowLyricsKey +import com.babelsoftware.loudly.constants.SkipSilenceKey +import com.babelsoftware.loudly.constants.StopPlayingSongWhenMinimumVolumeKey +import com.babelsoftware.loudly.constants.minPlaybackDurKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.Event +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.db.entities.RelatedSongMap +import com.babelsoftware.loudly.di.DownloadCache +import com.babelsoftware.loudly.di.PlayerCache +import com.babelsoftware.loudly.extensions.SilentHandler +import com.babelsoftware.loudly.extensions.collect +import com.babelsoftware.loudly.extensions.collectLatest +import com.babelsoftware.loudly.extensions.currentMetadata +import com.babelsoftware.loudly.extensions.findNextMediaItemById +import com.babelsoftware.loudly.extensions.mediaItems +import com.babelsoftware.loudly.extensions.metadata +import com.babelsoftware.loudly.extensions.setOffloadEnabled +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.lyrics.LyricsHelper +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.models.PersistQueue +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.EmptyQueue +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.playback.queues.Queue +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.playback.queues.filterExplicit +import com.babelsoftware.loudly.reportException +import com.babelsoftware.loudly.utils.CoilBitmapLoader +import com.babelsoftware.loudly.utils.DiscordRPC +import com.babelsoftware.loudly.utils.YTPlayerUtils +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.enumPreference +import com.babelsoftware.loudly.utils.get +import com.google.common.util.concurrent.MoreExecutors +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import java.io.File +import java.io.IOException +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.time.LocalDateTime +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import kotlin.math.min +import kotlin.math.pow +import kotlin.time.Duration.Companion.seconds + +sealed class UrlStatus { + object Loading : UrlStatus() // Indicates that the URL is being fetched + data class Ready(val url: String, val expiration: Long, val format: PlayerResponse.StreamingData.Format) : UrlStatus() // URL ready + data class Failed(val error: Throwable) : UrlStatus() // An error occurred while retrieving the URL +} +@Suppress("DEPRECATION") +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@AndroidEntryPoint +class MusicService : MediaLibraryService(), + Player.Listener, + PlaybackStatsListener.Callback { + @Inject + lateinit var database: MusicDatabase + + @Inject + lateinit var downloadUtil: DownloadUtil + + @Inject + lateinit var lyricsHelper: LyricsHelper + + @Inject + lateinit var mediaLibrarySessionCallback: MediaLibrarySessionCallback + + private var scope = CoroutineScope(Dispatchers.Main) + Job() + private val binder = MusicBinder() + + private lateinit var connectivityManager: ConnectivityManager + + private val audioQuality by enumPreference(this, AudioQualityKey, AudioQuality.AUTO) + + private var currentQueue: Queue = EmptyQueue + var queueTitle: String? = null + + var consecutivePlaybackErr = 0 + + val currentMediaMetadata = MutableStateFlow(null) + private val currentSong = currentMediaMetadata.flatMapLatest { mediaMetadata -> + database.song(mediaMetadata?.id) + }.stateIn(scope, SharingStarted.Lazily, null) + private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> + database.format(mediaMetadata?.id) + } + + private val normalizeFactor = MutableStateFlow(1f) + val playerVolume = MutableStateFlow(dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) + + lateinit var sleepTimer: SleepTimer + + val currentPlaybackFormat = MutableStateFlow(null) + + @Inject + @PlayerCache + lateinit var playerCache: SimpleCache + + @Inject + @DownloadCache + lateinit var downloadCache: SimpleCache + + lateinit var player: ExoPlayer + private lateinit var mediaSession: MediaLibrarySession + + private var isAudioEffectSessionOpened = false + + private var discordRpc: DiscordRPC? = null + + private var bluetoothReceiver: BroadcastReceiver? = null + + private var wasPlayingBeforeMute = false + + private var volumeReceiver: BroadcastReceiver? = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (dataStore.get(StopPlayingSongWhenMinimumVolumeKey, true)) { + val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager + val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + + if (currentVolume == 0 && player.isPlaying) { + wasPlayingBeforeMute = true + player.pause() + } else if (currentVolume > 0 && !player.isPlaying && wasPlayingBeforeMute) { + player.play() + wasPlayingBeforeMute = false + } + } + } + } + + override fun onCreate() { + super.onCreate() + registerReceiver(volumeReceiver, IntentFilter().apply { + addAction("android.media.VOLUME_CHANGED_ACTION") + }) + playerVolume + .debounce(100) + .collectLatest(scope) { volume -> + when { + volume == 0f && player.isPlaying && dataStore.get( + StopPlayingSongWhenMinimumVolumeKey, + true + ) -> { + wasPlayingBeforeMute = true + player.pause() + } + + volume > 0f && !player.isPlaying && wasPlayingBeforeMute && dataStore.get( + StopPlayingSongWhenMinimumVolumeKey, + true + ) -> { + player.play() + wasPlayingBeforeMute = false + } + } + } + setMediaNotificationProvider( + DefaultMediaNotificationProvider(this, { NOTIFICATION_ID }, CHANNEL_ID, R.string.music_player) + .apply { + setSmallIcon(R.drawable.small_icon) + } + ) + if (dataStore.get(KeepAliveKey, false)) { + try { + startService(Intent(this, KeepAlive::class.java)) + } catch (e: Exception) { + reportException(e) + } + } else { + try { + stopService(Intent(this, KeepAlive::class.java)) + } catch (e: Exception) { + reportException(e) + } + } + + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + 10_000, // Keep data in memory for at least 10 seconds + 30_000, // Accumulate up to a maximum of 30 seconds + 2_500, + 5_000 + ) + .build() + + player = ExoPlayer.Builder(this) + .setLoadControl(loadControl) + .setMediaSourceFactory(DefaultMediaSourceFactory(createDataSourceFactory())) + .setRenderersFactory(createRenderersFactory()) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), true + ) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(5000) + .build() + .apply { + addListener(this@MusicService) + setOffloadEnabled(dataStore.get(AudioOffload, false)) + sleepTimer = SleepTimer(scope, this) + addListener(sleepTimer) + addAnalyticsListener(PlaybackStatsListener(false, this@MusicService)) + } + mediaLibrarySessionCallback.apply { + toggleLike = ::toggleLike + toggleStartRadio = ::toggleStartRadio + toggleLibrary = ::toggleLibrary + } + mediaSession = MediaLibrarySession.Builder(this, player, mediaLibrarySessionCallback) + .setSessionActivity( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + ) + .setBitmapLoader(CoilBitmapLoader(this, scope)) + .build() + player.repeatMode = dataStore.get(RepeatModeKey, REPEAT_MODE_OFF) + + val sessionToken = SessionToken(this, ComponentName(this, MusicService::class.java)) + val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener({ controllerFuture.get() }, MoreExecutors.directExecutor()) + + connectivityManager = getSystemService() + ?: throw IllegalStateException("ConnectivityManager not found") + + combine(playerVolume, normalizeFactor) { playerVolume, normalizeFactor -> + playerVolume * normalizeFactor + }.collectLatest(scope) { + player.volume = it + } + + playerVolume.debounce(1000).collect(scope) { volume -> + dataStore.edit { settings -> + settings[PlayerVolumeKey] = volume + } + } + + currentSong.debounce(1000).collect(scope) { song -> + updateNotification() + if (song != null) { + discordRpc?.updateSong(song) + } else { + discordRpc?.closeRPC() + } + } + + combine( + currentMediaMetadata.distinctUntilChangedBy { it?.id }, + dataStore.data.map { it[ShowLyricsKey] ?: false }.distinctUntilChanged() + ) { mediaMetadata, showLyrics -> + mediaMetadata to showLyrics + }.collectLatest(scope) { (mediaMetadata, showLyrics) -> + if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { + val lyrics = lyricsHelper.getLyrics(mediaMetadata) + database.query { + upsert( + LyricsEntity( + id = mediaMetadata.id, + lyrics = lyrics + ) + ) + } + } + } + + dataStore.data + .map { it[SkipSilenceKey] ?: false } + .distinctUntilChanged() + .collectLatest(scope) { + player.skipSilenceEnabled = it + } + + combine( + currentFormat, + dataStore.data + .map { it[AudioNormalizationKey] ?: true } + .distinctUntilChanged() + ) { format, normalizeAudio -> + format to normalizeAudio + }.collectLatest(scope) { (format, normalizeAudio) -> + normalizeFactor.value = if (normalizeAudio && format?.loudnessDb != null) { + min(10f.pow(-format.loudnessDb.toFloat() / 20), 1f) + } else { + 1f + } + } + + dataStore.data + .map { it[DiscordTokenKey] to (it[EnableDiscordRPCKey] ?: true) } + .debounce(300) + .distinctUntilChanged() + .collect(scope) { (key, enabled) -> + if (discordRpc?.isRpcRunning() == true) { + discordRpc?.closeRPC() + } + discordRpc = null + if (key != null && enabled) { + discordRpc = DiscordRPC(this, key) + currentSong.value?.let { + discordRpc?.updateSong(it) + } + } + } + + if (dataStore.get(PersistentQueueKey, true)) { + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> + ObjectInputStream(fis).use { oos -> + oos.readObject() as PersistQueue + } + } + }.onSuccess { queue -> + playQueue( + queue = ListQueue( + title = queue.title, + items = queue.items.map { it.toMediaItem() }, + startIndex = queue.mediaItemIndex, + position = queue.position + ), + playWhenReady = false + ) + } + } + + scope.launch { + while (isActive) { + delay(30.seconds) + if (dataStore.get(PersistentQueueKey, true)) { + saveQueueToDisk() + } + } + } + bluetoothReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (dataStore.get(AutoPlaySongWhenBluetoothDeviceConnectedKey, true)) { + when (intent.action) { + BluetoothDevice.ACTION_ACL_CONNECTED -> { + if (player.playbackState == Player.STATE_READY + && !player.isPlaying + && dataStore.get(PersistentQueueKey, true) + ) { + player.play() + } + } + } + } + } + }.apply { + registerReceiver(this, IntentFilter().apply { + addAction(BluetoothDevice.ACTION_ACL_CONNECTED) + }) + } + } + + private fun updateNotification() { + mediaSession.setCustomLayout( + listOf( + CommandButton.Builder() + .setDisplayName(getString(if (currentSong.value?.song?.inLibrary != null) R.string.remove_from_library else R.string.add_to_library)) + .setIconResId(if (currentSong.value?.song?.inLibrary != null) R.drawable.library_add_check else R.drawable.library_add) + .setSessionCommand(MediaSessionConstants.CommandToggleLibrary) + .setEnabled(currentSong.value != null) + .build(), + CommandButton.Builder() + .setDisplayName(getString(if (currentSong.value?.song?.liked == true) R.string.action_remove_like else R.string.action_like)) + .setIconResId(if (currentSong.value?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border) + .setSessionCommand(MediaSessionConstants.CommandToggleLike) + .setEnabled(currentSong.value != null) + .build(), + CommandButton.Builder() + .setDisplayName(getString(R.string.start_radio)) + .setIconResId(R.drawable.radio) + .setSessionCommand(MediaSessionConstants.CommandToggleStartRadio) + .setEnabled(currentSong.value != null) + .build(), + CommandButton.Builder() + .setDisplayName(getString(if (player.shuffleModeEnabled) R.string.action_shuffle_off else R.string.action_shuffle_on)) + .setIconResId(if (player.shuffleModeEnabled) R.drawable.shuffle_on else R.drawable.shuffle) + .setSessionCommand(MediaSessionConstants.CommandToggleShuffle) + .build(), + CommandButton.Builder() + .setDisplayName( + getString( + when (player.repeatMode) { + REPEAT_MODE_OFF -> R.string.repeat_mode_off + REPEAT_MODE_ONE -> R.string.repeat_mode_one + REPEAT_MODE_ALL -> R.string.repeat_mode_all + else -> throw IllegalStateException() + } + ) + ) + .setIconResId( + when (player.repeatMode) { + REPEAT_MODE_OFF -> R.drawable.repeat + REPEAT_MODE_ONE -> R.drawable.repeat_one_on + REPEAT_MODE_ALL -> R.drawable.repeat_on + else -> throw IllegalStateException() + } + ) + .setSessionCommand(MediaSessionConstants.CommandToggleRepeatMode) + .build() + ) + ) + } + + private suspend fun recoverSong(mediaId: String, playbackData: YTPlayerUtils.PlaybackData? = null) { + val song = database.song(mediaId).first() + val mediaMetadata = withContext(Dispatchers.Main) { + player.findNextMediaItemById(mediaId)?.metadata + } ?: return + val duration = song?.song?.duration?.takeIf { it != -1 } + ?: mediaMetadata.duration.takeIf { it != -1 } + ?: (playbackData?.videoDetails ?: YTPlayerUtils.playerResponseForMetadata(mediaId).getOrNull()?.videoDetails)?.lengthSeconds?.toInt() + ?: -1 + database.query { + if (song == null) insert(mediaMetadata.copy(duration = duration)) + else if (song.song.duration == -1) update(song.song.copy(duration = duration)) + } + if (!database.hasRelatedSongs(mediaId)) { + val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint ?: return + val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return + database.query { + relatedPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .map { + RelatedSongMap( + songId = mediaId, + relatedSongId = it.id + ) + } + .forEach(::insert) + } + } + } + + fun playQueue(queue: Queue, playWhenReady: Boolean = true) { + if (!scope.isActive) { + scope = CoroutineScope(Dispatchers.Main) + Job() + } + currentQueue = queue + queueTitle = null + player.shuffleModeEnabled = false + if (queue.preloadItem != null) { + player.setMediaItem(queue.preloadItem!!.toMediaItem()) + player.prepare() + player.playWhenReady = playWhenReady + } + + scope.launch(SilentHandler) { + val initialStatus = withContext(Dispatchers.IO) { + queue.getInitialStatus().filterExplicit(dataStore.get(HideExplicitKey, false)) + } + if (queue.preloadItem != null && player.playbackState == STATE_IDLE) return@launch + if (initialStatus.title != null) { + queueTitle = initialStatus.title + } + if (initialStatus.items.isEmpty()) return@launch + if (queue.preloadItem != null) { + player.addMediaItems(0, initialStatus.items.subList(0, initialStatus.mediaItemIndex)) + player.addMediaItems(initialStatus.items.subList(initialStatus.mediaItemIndex + 1, initialStatus.items.size)) + } else { + player.setMediaItems(initialStatus.items, if (initialStatus.mediaItemIndex > 0) initialStatus.mediaItemIndex else 0, initialStatus.position) + player.prepare() + player.playWhenReady = playWhenReady + } + } + } + + fun startRadioSeamlessly() { + val currentMediaMetadata = player.currentMetadata ?: return + if (player.currentMediaItemIndex > 0) player.removeMediaItems(0, player.currentMediaItemIndex) + if (player.currentMediaItemIndex < player.mediaItemCount - 1) player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) + scope.launch(SilentHandler) { + val radioQueue = + YouTubeQueue(endpoint = WatchEndpoint(videoId = currentMediaMetadata.id)) + val initialStatus = radioQueue.getInitialStatus() + if (initialStatus.title != null) { + queueTitle = initialStatus.title + } + player.addMediaItems(initialStatus.items.drop(1)) + currentQueue = radioQueue + } + } + + fun playNext(items: List) { + player.addMediaItems(if (player.mediaItemCount == 0) 0 else player.currentMediaItemIndex + 1, items) + player.prepare() + } + + fun addToQueue(items: List) { + player.addMediaItems(items) + player.prepare() + } + + fun toggleLibrary() { + database.query { + currentSong.value?.let { + update(it.song.toggleLibrary()) + } + } + } + + fun toggleLike() { + database.query { + currentSong.value?.let { + val song = it.song.toggleLike() + update(song) + downloadUtil.autoDownloadIfLiked(song) + } + } + } + + fun toggleStartRadio() { + startRadioSeamlessly() + } + + private fun openAudioEffectSession() { + if (isAudioEffectSessionOpened) return + isAudioEffectSessionOpened = true + sendBroadcast( + Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + ) + } + + private fun closeAudioEffectSession() { + if (!isAudioEffectSessionOpened) return + isAudioEffectSessionOpened = false + sendBroadcast( + Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + } + ) + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + if (consecutivePlaybackErr > 0) { + consecutivePlaybackErr-- + } + + if (player.isPlaying && reason == MEDIA_ITEM_TRANSITION_REASON_SEEK) { + player.prepare() + player.play() + } + if (dataStore.get(AutoLoadMoreKey, true) && + reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT && + player.mediaItemCount - player.currentMediaItemIndex <= 5 && + currentQueue.hasNextPage() && player.currentMediaItemIndex < player.mediaItemCount - 1 + ) { + scope.launch(SilentHandler) { + val mediaItems = currentQueue.nextPage().filterExplicit( + dataStore.get( + HideExplicitKey, false + ) + ) + if (player.playbackState != STATE_IDLE) { + player.addMediaItems(mediaItems.drop(1)) + } + } + } + + if (player.nextMediaItemIndex != C.INDEX_UNSET) { + val nextMediaItem = player.getMediaItemAt(player.nextMediaItemIndex) + scope.launch(Dispatchers.IO) { + getOrFetchSongUrl(nextMediaItem.mediaId) + } + } + } + + override fun onPlaybackStateChanged(@Player.State playbackState: Int) { + if (playbackState == STATE_IDLE) { + currentQueue = EmptyQueue + player.shuffleModeEnabled = false + queueTitle = null + } + } + + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED)) { + val isBufferingOrReady = player.playbackState == Player.STATE_BUFFERING || player.playbackState == Player.STATE_READY + if (isBufferingOrReady && player.playWhenReady) { + openAudioEffectSession() + } else { + closeAudioEffectSession() + } + } + if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { + currentMediaMetadata.value = player.currentMetadata + } + } + + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + updateNotification() + if (shuffleModeEnabled) { + val shuffledIndices = IntArray(player.mediaItemCount) { it } + shuffledIndices.shuffle() + shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = shuffledIndices[0] + shuffledIndices[0] = player.currentMediaItemIndex + player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + updateNotification() + scope.launch { + dataStore.edit { settings -> + settings[RepeatModeKey] = repeatMode + } + } + } + + private var consecutivePlaybackFailures = 0 + private val MAX_CONSECUTIVE_FAILURES = 2 + private var currentMediaIdRetries = 0 + private var lastFailedMediaId: String? = null + val isRetryingSong = MutableStateFlow(false) + + override fun onPlayerError(error: PlaybackException) { + val currentMediaItem = player.currentMediaItem ?: return + val currentMediaId = currentMediaItem.mediaId + + if (lastFailedMediaId != currentMediaId) { + currentMediaIdRetries = 0 + } + lastFailedMediaId = currentMediaId + + songUrlCache.remove(currentMediaId) + currentPlaybackFormat.value = null + + currentMediaIdRetries++ + + if (currentMediaIdRetries <= 2) { + // Yeniden deneme başladığında durumu 'true' yap + isRetryingSong.value = true + + player.setMediaItem(currentMediaItem, player.currentPosition) + player.prepare() + player.play() + } else { + // Yeniden deneme bitti ve başarısız oldu, durumu 'false' yap + isRetryingSong.value = false + consecutivePlaybackFailures++ + currentMediaIdRetries = 0 + + if (consecutivePlaybackFailures >= MAX_CONSECUTIVE_FAILURES) { + player.stop() + player.clearMediaItems() + } else { + if (player.hasNextMediaItem()) { + player.seekToNext() + player.prepare() + player.playWhenReady = true + } + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + if (isPlaying && player.playbackState == Player.STATE_READY) { + // Oynatma başarılı olduğunda durumu 'false' yap + isRetryingSong.value = false + + if (consecutivePlaybackFailures > 0 || currentMediaIdRetries > 0) { + consecutivePlaybackFailures = 0 + currentMediaIdRetries = 0 + lastFailedMediaId = null + } + } + } + + private fun createCacheDataSource(): CacheDataSource.Factory = + CacheDataSource.Factory() + .setCache(downloadCache) + .setUpstreamDataSourceFactory( + CacheDataSource.Factory() + .setCache(playerCache) + .setUpstreamDataSourceFactory( + DefaultDataSource.Factory( + this, + OkHttpDataSource.Factory( + OkHttpClient.Builder() + .proxy(YouTube.proxy) + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + ) + ) + ) + ) + .setCacheWriteDataSinkFactory(null) + .setFlags(FLAG_IGNORE_CACHE_ON_ERROR) + + private suspend fun getOrFetchSongUrl(mediaId: String): String? { + when (val status = songUrlCache[mediaId]) { + is UrlStatus.Ready -> { + if (status.expiration > System.currentTimeMillis()) { + return status.url + } + songUrlCache.remove(mediaId) + } + is UrlStatus.Loading -> { + return null + } + is UrlStatus.Failed -> { + songUrlCache.remove(mediaId) + } + null -> {} + } + songUrlCache[mediaId] = UrlStatus.Loading + + YTPlayerUtils.playerResponseForPlayback( + videoId = mediaId, + audioQuality = audioQuality, + connectivityManager = connectivityManager, + ).onSuccess { playbackData -> + val streamUrl = playbackData.streamUrl + val expiration = System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 1000L) + songUrlCache[mediaId] = UrlStatus.Ready(streamUrl, expiration, playbackData.format) + currentPlaybackFormat.value = playbackData.format + + withContext(Dispatchers.Main) { + val timeline = player.currentTimeline + for (i in 0 until timeline.windowCount) { + val window = timeline.getWindow(i, Timeline.Window()) + if (window.mediaItem.mediaId == mediaId) { + val currentMediaItem = window.mediaItem + val format = playbackData.format + val extras = Bundle().apply { + putInt("bitrate", format.bitrate) + putString("mimeType", format.mimeType) + format.audioSampleRate?.let { putInt("sampleRate", it) } + playbackData.audioConfig?.loudnessDb?.let { putDouble("loudnessDb", it) } + putLong("contentLength", format.contentLength ?: 0L) + } + val newMetadata = currentMediaItem.mediaMetadata.buildUpon() + .setExtras(extras) + .build() + val newMediaItem = currentMediaItem.buildUpon() + .setMediaMetadata(newMetadata) + .build() + player.replaceMediaItem(i, newMediaItem) + break + } + } + } + + return streamUrl + }.onFailure { throwable -> + songUrlCache[mediaId] = UrlStatus.Failed(throwable) + return null + } + return null + } + + private val songUrlCache = ConcurrentHashMap() + private fun createDataSourceFactory(): DataSource.Factory { + return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> + val mediaId = dataSpec.key ?: throw IOException("Media ID is null") + val status = songUrlCache[mediaId] + if (status is UrlStatus.Ready) { + currentPlaybackFormat.value = status.format + } + if (mediaId.startsWith("1000")) { + val songPath = runBlocking(Dispatchers.IO) { + database.song(mediaId).firstOrNull()?.song?.localPath + } + songPath?.let { + Log.d("DataSourceFactory", "Local file being served: $it") + return@Factory dataSpec.withUri(Uri.fromFile(File(it))) + } + throw IOException("Local song path not found: $mediaId") + } + if (downloadCache.isCached(mediaId, dataSpec.position, if (dataSpec.length >= 0) dataSpec.length else 1) || + playerCache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH) + ) { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec + } + + if (status is UrlStatus.Ready && status.expiration > System.currentTimeMillis()) { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(status.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) + } else { + if (status !is UrlStatus.Loading) { + scope.launch(Dispatchers.IO) { getOrFetchSongUrl(mediaId) } + } + throw IOException("Stream URL for $mediaId not ready yet. Retrying...") + } + } + } + + private fun createRenderersFactory() = + object : DefaultRenderersFactory(this) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ) = DefaultAudioSink.Builder(this@MusicService) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain( + emptyArray(), + SilenceSkippingAudioProcessor(2_000_000, 0.01f, 2_000_000, 0, 256), + SonicAudioProcessor() + ) + ) + .build() + } + + override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { + val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem + val minPlaybackDur = (dataStore.get(minPlaybackDurKey, 30) / 100) + if (playbackStats.totalPlayTimeMs.toFloat() / ((mediaItem.metadata?.duration?.times(1000)) ?: -1) >= minPlaybackDur + && !dataStore.get(PauseListenHistoryKey, false)) { + database.query { + incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + try { + insert( + Event( + songId = mediaItem.mediaId, + timestamp = LocalDateTime.now(), + playTime = playbackStats.totalPlayTimeMs + ) + ) + } catch (_: SQLException) { + } + if (dataStore.get(AddingPlayedSongsToYTMHistoryKey, true)) { + CoroutineScope(Dispatchers.IO).launch { + val playbackUrl = database.format(mediaItem.mediaId).first()?.playbackUrl + ?: YTPlayerUtils.playerResponseForMetadata(mediaItem.mediaId, null) + .getOrNull()?.playbackTracking?.videostatsPlaybackUrl?.baseUrl + playbackUrl?.let { + YouTube.registerPlayback(null, playbackUrl) + .onFailure { + reportException(it) + } + } + } + } + } + } + } + + private fun saveQueueToDisk() { + if (player.playbackState == STATE_IDLE) { + filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() + return + } + val persistQueue = PersistQueue( + title = queueTitle, + items = player.mediaItems.mapNotNull { it.metadata }, + mediaItemIndex = player.currentMediaItemIndex, + position = player.currentPosition + ) + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).outputStream().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(persistQueue) + } + } + }.onFailure { + reportException(it) + } + } + + override fun onDestroy() { + scope.cancel() + volumeReceiver?.let { unregisterReceiver(it) } + volumeReceiver = null + if (dataStore.get(PersistentQueueKey, true)) { + saveQueueToDisk() + } + if (discordRpc?.isRpcRunning() == true) { + discordRpc?.closeRPC() + } + discordRpc = null + mediaSession.release() + player.removeListener(this) + player.removeListener(sleepTimer) + player.release() + bluetoothReceiver?.let { + unregisterReceiver(it) + } + super.onDestroy() + } + + override fun onBind(intent: Intent?) = super.onBind(intent) ?: binder + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + stopSelf() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession + + inner class MusicBinder : Binder() { + val service: MusicService + get() = this@MusicService + } + + companion object { + const val ROOT = "root" + const val SONG = "song" + const val ARTIST = "artist" + const val ALBUM = "album" + const val PLAYLIST = "playlist" + + const val CHANNEL_ID = "music_channel_01" + const val NOTIFICATION_ID = 888 + const val CHUNK_LENGTH = 512 * 1024L + const val PERSISTENT_QUEUE_FILE = "persistent_queue.data" + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/PlayerConnection.kt b/app/src/main/java/com/babelsoftware/loudly/playback/PlayerConnection.kt new file mode 100644 index 0000000..14d28a4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/PlayerConnection.kt @@ -0,0 +1,339 @@ +package com.babelsoftware.loudly.playback + +import android.content.Context +import android.content.Intent +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.STATE_ENDED +import androidx.media3.common.Timeline +import com.babelsoftware.loudly.MusicWidget +import com.babelsoftware.loudly.constants.TranslateLyricsKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.extensions.currentMetadata +import com.babelsoftware.loudly.extensions.getCurrentQueueIndex +import com.babelsoftware.loudly.extensions.getQueueWindows +import com.babelsoftware.loudly.extensions.metadata +import com.babelsoftware.loudly.playback.queues.Queue +import com.babelsoftware.loudly.TranslationHelper +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.reportException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class SleepTimerState( + val isActive: Boolean = false, + val remainingSeconds: Long = 0 +) + +sealed class StreakInfo { + object None : StreakInfo() + data class AlbumStreak(val album: MediaMetadata.Album) : StreakInfo() + data class ArtistStreak(val artist: MediaMetadata.Artist) : StreakInfo() +} + +@OptIn(ExperimentalCoroutinesApi::class) +class PlayerConnection( + private val context: Context, + binder: MusicService.MusicBinder, + val database: MusicDatabase, + private val scope: CoroutineScope, +) : Player.Listener { + val service = binder.service + val player = service.player + + val playbackState = MutableStateFlow(player.playbackState) + private val playWhenReady = MutableStateFlow(player.playWhenReady) + val isPlaying = combine(playbackState, playWhenReady) { playbackState, playWhenReady -> + playWhenReady && playbackState != STATE_ENDED + }.stateIn(scope, SharingStarted.Lazily, player.playWhenReady && player.playbackState != STATE_ENDED) + val mediaMetadata = MutableStateFlow(player.currentMetadata) + val currentSong = mediaMetadata.flatMapLatest { + database.song(it?.id) + } + internal val translating = MutableStateFlow(false) + val currentLyrics = combine( + context.dataStore.data.map { + it[TranslateLyricsKey] ?: false + }.distinctUntilChanged(), + mediaMetadata.flatMapLatest { mediaMetadata -> + database.lyrics(mediaMetadata?.id) + } + ) { translateEnabled, lyrics -> + if (!translateEnabled || lyrics == null || lyrics.lyrics == LyricsEntity.Companion.LYRICS_NOT_FOUND) return@combine lyrics + translating.value = true + try { + TranslationHelper.translate(lyrics) + } catch (e: Exception) { + reportException(e) + lyrics + }.also { + translating.value = false + } + }.stateIn(scope, SharingStarted.Lazily, null) + val currentFormat = service.currentPlaybackFormat.stateIn(scope, SharingStarted.Lazily, null) + + val queueTitle = MutableStateFlow(null) + val queueWindows = MutableStateFlow>(emptyList()) + private val currentMediaItemIndex = MutableStateFlow(-1) + val currentWindowIndex = MutableStateFlow(-1) + + val shuffleModeEnabled = MutableStateFlow(false) + val repeatMode = MutableStateFlow(REPEAT_MODE_OFF) + + val canSkipPrevious = MutableStateFlow(true) + val canSkipNext = MutableStateFlow(true) + + val error = MutableStateFlow(null) + + val isRetryingSong = service.isRetryingSong.stateIn(scope, SharingStarted.Lazily, false) + + private val _sleepTimerState = MutableStateFlow(SleepTimerState()) + val sleepTimerState: StateFlow = _sleepTimerState + + private var previousMediaMetadata: MediaMetadata? = null + private var streakTimerJob: Job? = null + + private val _streakState = MutableStateFlow(StreakInfo.None) + val streakState: StateFlow = _streakState + + fun startSleepTimer(minutes: Int) { + service.sleepTimer.start(minutes) + } + + fun cancelSleepTimer() { + service.sleepTimer.clear() + } + + init { + player.addListener(this) + + playbackState.value = player.playbackState + playWhenReady.value = player.playWhenReady + mediaMetadata.value = player.currentMetadata + queueTitle.value = service.queueTitle + queueWindows.value = player.getQueueWindows() + currentWindowIndex.value = player.getCurrentQueueIndex() + currentMediaItemIndex.value = player.currentMediaItemIndex + shuffleModeEnabled.value = player.shuffleModeEnabled + repeatMode.value = player.repeatMode + + scope.launch { + while (true) { + val timer = service.sleepTimer + val isActive = timer.isActive + val remaining = if (isActive) { + if (timer.pauseWhenSongEnd) { + (player.duration - player.currentPosition).coerceAtLeast(0) / 1000 + } else { + (timer.triggerTime - System.currentTimeMillis()).coerceAtLeast(0) / 1000 + } + } else 0 + + _sleepTimerState.value = SleepTimerState(isActive, remaining) + delay(1000) + } + } + } + + fun playQueue(queue: Queue) { + service.playQueue(queue) + } + + fun playNext(item: MediaItem) = playNext(listOf(item)) + fun playNext(items: List) { + service.playNext(items) + } + + fun addToQueue(item: MediaItem) = addToQueue(listOf(item)) + fun addToQueue(items: List) { + service.addToQueue(items) + } + + fun toggleLike() { + service.toggleLike() + } + + fun seekToNext() { + player.seekToNext() + player.prepare() + player.playWhenReady = true + } + + fun seekToPrevious() { + player.seekToPrevious() + player.prepare() + player.playWhenReady = true + } + + fun startRadioFromStreak(streakInfo: StreakInfo) { + val radioMediaItem = when (streakInfo) { + is StreakInfo.AlbumStreak -> mediaMetadata.value?.takeIf { it.album?.id == streakInfo.album.id } + is StreakInfo.ArtistStreak -> mediaMetadata.value?.takeIf { it.artists.any { artist -> artist.id == streakInfo.artist.id } } + else -> null + } + radioMediaItem?.let { + service.startRadioSeamlessly() + } + } + + override fun onPlaybackStateChanged(state: Int) { + playbackState.value = state + error.value = player.playerError + sendWidgetUpdateBroadcast() + } + + override fun onPlayWhenReadyChanged(newPlayWhenReady: Boolean, reason: Int) { + playWhenReady.value = newPlayWhenReady + sendWidgetUpdateBroadcast() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + val currentMetadata = mediaItem?.metadata + mediaMetadata.value = currentMetadata + currentMediaItemIndex.value = player.currentMediaItemIndex + currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + sendWidgetUpdateBroadcast() + streakTimerJob?.cancel() + + val previous = previousMediaMetadata + if (currentMetadata != null && previous != null && reason != Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + val currentAlbumId = currentMetadata.album?.id + if (currentAlbumId != null && currentAlbumId == previous.album?.id) { + _streakState.value = StreakInfo.AlbumStreak(currentMetadata.album) + } + else { + val currentArtistId = currentMetadata.artists.firstOrNull()?.id + if (currentArtistId != null && currentArtistId == previous.artists.firstOrNull()?.id) { + _streakState.value = StreakInfo.ArtistStreak(currentMetadata.artists.first()) + } else { + _streakState.value = StreakInfo.None + } + } + } else { + _streakState.value = StreakInfo.None + } + if (_streakState.value !is StreakInfo.None) { + streakTimerJob = scope.launch { + delay(7000) + _streakState.value = StreakInfo.None + } + } + previousMediaMetadata = currentMetadata + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + queueWindows.value = player.getQueueWindows() + queueTitle.value = service.queueTitle + currentMediaItemIndex.value = player.currentMediaItemIndex + currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + sendWidgetUpdateBroadcast() + } + + override fun onShuffleModeEnabledChanged(enabled: Boolean) { + shuffleModeEnabled.value = enabled + queueWindows.value = player.getQueueWindows() + currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + } + + override fun onRepeatModeChanged(mode: Int) { + repeatMode.value = mode + updateCanSkipPreviousAndNext() + } + + override fun onPlayerErrorChanged(playbackError: PlaybackException?) { + if (playbackError != null) { + reportException(playbackError) + } + error.value = playbackError + } + + private fun updateCanSkipPreviousAndNext() { + if (!player.currentTimeline.isEmpty) { + val window = player.currentTimeline.getWindow(player.currentMediaItemIndex, Timeline.Window()) + canSkipPrevious.value = player.isCommandAvailable(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + || !window.isLive + || player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + canSkipNext.value = window.isLive && window.isDynamic + || player.isCommandAvailable(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + } else { + canSkipPrevious.value = false + canSkipNext.value = false + } + } + + fun saveCurrentLyrics() { + val lyricsToSave = currentLyrics.value ?: return + database.query { + upsert(lyricsToSave) + } + } + + fun togglePlayPause() { + instance?.player?.playWhenReady = + !instance?.player?.playWhenReady!! + } + + fun toggleShuffle() { + player.shuffleModeEnabled = !(player.shuffleModeEnabled) + } + + fun toggleReplayMode() { + player.repeatMode = if (player.repeatMode == Player.REPEAT_MODE_ONE) + REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + } + + fun dispose() { + instance = null + player.removeListener(this) + } + companion object { + @Volatile + var instance: PlayerConnection? = null + private set + } + private fun sendWidgetUpdateBroadcast() { + context.sendBroadcast(Intent(MusicWidget.ACTION_STATE_CHANGED).apply { + setPackage(context.packageName) + }) + } + init { + instance = this + player.addListener(object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED, + Player.EVENT_MEDIA_ITEM_TRANSITION + )) { + sendStateChangedBroadcast(context) + } + } + }) + } + private fun sendStateChangedBroadcast(context: Context) { + context.sendBroadcast(Intent(MusicWidget.Companion.ACTION_STATE_CHANGED).apply { + setPackage(context.packageName) + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/SleepTimer.kt b/app/src/main/java/com/babelsoftware/loudly/playback/SleepTimer.kt new file mode 100644 index 0000000..f6f3d9d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/SleepTimer.kt @@ -0,0 +1,62 @@ +package com.babelsoftware.loudly.playback + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.minutes + +class SleepTimer( + private val scope: CoroutineScope, + val player: Player, +) : Player.Listener { + private var sleepTimerJob: Job? = null + var triggerTime by mutableLongStateOf(-1L) + private set + var pauseWhenSongEnd by mutableStateOf(false) + private set + val isActive: Boolean + get() = triggerTime != -1L || pauseWhenSongEnd + + fun start(minute: Int) { + sleepTimerJob?.cancel() + sleepTimerJob = null + if (minute == -1) { + pauseWhenSongEnd = true + } else { + triggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds + sleepTimerJob = scope.launch { + delay(minute.minutes) + player.pause() + triggerTime = -1L + } + } + } + + fun clear() { + sleepTimerJob?.cancel() + sleepTimerJob = null + pauseWhenSongEnd = false + triggerTime = -1L + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } + + override fun onPlaybackStateChanged(@Player.State playbackState: Int) { + if (playbackState == Player.STATE_ENDED && pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/queues/EmptyQueue.kt b/app/src/main/java/com/babelsoftware/loudly/playback/queues/EmptyQueue.kt new file mode 100644 index 0000000..5c8fe21 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/queues/EmptyQueue.kt @@ -0,0 +1,11 @@ +package com.babelsoftware.loudly.playback.queues + +import androidx.media3.common.MediaItem +import com.babelsoftware.loudly.models.MediaMetadata + +object EmptyQueue : Queue { + override val preloadItem: MediaMetadata? = null + override suspend fun getInitialStatus() = Queue.Status(null, emptyList(), -1) + override fun hasNextPage() = false + override suspend fun nextPage() = emptyList() +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/queues/ListQueue.kt b/app/src/main/java/com/babelsoftware/loudly/playback/queues/ListQueue.kt new file mode 100644 index 0000000..313e677 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/queues/ListQueue.kt @@ -0,0 +1,16 @@ +package com.babelsoftware.loudly.playback.queues + +import androidx.media3.common.MediaItem +import com.babelsoftware.loudly.models.MediaMetadata + +class ListQueue( + val title: String? = null, + val items: List, + val startIndex: Int = 0, + val position: Long = 0L, +) : Queue { + override val preloadItem: MediaMetadata? = null + override suspend fun getInitialStatus() = Queue.Status(title, items, startIndex, position) + override fun hasNextPage(): Boolean = false + override suspend fun nextPage() = throw UnsupportedOperationException() +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/queues/LocalAlbumRadio.kt b/app/src/main/java/com/babelsoftware/loudly/playback/queues/LocalAlbumRadio.kt new file mode 100644 index 0000000..ba39b02 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/queues/LocalAlbumRadio.kt @@ -0,0 +1,50 @@ +package com.babelsoftware.loudly.playback.queues + +import androidx.media3.common.MediaItem +import com.babelsoftware.loudly.db.entities.AlbumWithSongs +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.WatchEndpoint +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class LocalAlbumRadio( + private val albumWithSongs: AlbumWithSongs, + private val startIndex: Int = 0, +) : Queue { + override val preloadItem: MediaMetadata? = null + + private lateinit var playlistId: String + private val endpoint: WatchEndpoint + get() = WatchEndpoint( + playlistId = playlistId, + params = "wAEB" + ) + + private var continuation: String? = null + private var firstTimeLoaded: Boolean = false + + override suspend fun getInitialStatus(): Queue.Status = withContext(IO) { + Queue.Status( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map { it.toMediaItem() }, + mediaItemIndex = startIndex + ) + } + + override fun hasNextPage(): Boolean = !firstTimeLoaded || continuation != null + + override suspend fun nextPage(): List = withContext(IO) { + if (!firstTimeLoaded) { + playlistId = YouTube.album(albumWithSongs.album.id).getOrThrow().album.playlistId + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + firstTimeLoaded = true + return@withContext nextResult.items.subList(albumWithSongs.songs.size, nextResult.items.size).map { it.toMediaItem() } + } + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + nextResult.items.map { it.toMediaItem() } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/queues/Queue.kt b/app/src/main/java/com/babelsoftware/loudly/playback/queues/Queue.kt new file mode 100644 index 0000000..dff5122 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/queues/Queue.kt @@ -0,0 +1,33 @@ +package com.babelsoftware.loudly.playback.queues + +import androidx.media3.common.MediaItem +import com.babelsoftware.loudly.extensions.metadata +import com.babelsoftware.loudly.models.MediaMetadata + +interface Queue { + val preloadItem: MediaMetadata? + suspend fun getInitialStatus(): Status + fun hasNextPage(): Boolean + suspend fun nextPage(): List + + data class Status( + val title: String?, + val items: List, + val mediaItemIndex: Int, + val position: Long = 0L, + ) { + fun filterExplicit(enabled: Boolean = true) = + if (enabled) { + copy( + items = items.filterExplicit() + ) + } else this + } +} + +fun List.filterExplicit(enabled: Boolean = true) = + if (enabled) { + filterNot { + it.metadata?.explicit == true + } + } else this diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeAlbumRadio.kt b/app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeAlbumRadio.kt new file mode 100644 index 0000000..ced50ac --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeAlbumRadio.kt @@ -0,0 +1,41 @@ +package com.babelsoftware.loudly.playback.queues + +import androidx.media3.common.MediaItem +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.WatchEndpoint +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class YouTubeAlbumRadio( + private val playlistId: String, +) : Queue { + override val preloadItem: MediaMetadata? = null + private val endpoint = WatchEndpoint( + playlistId = playlistId, + params = "wAEB" + ) + private var continuation: String? = null + + override suspend fun getInitialStatus(): Queue.Status = withContext(IO) { + val albumSongs = YouTube.albumSongs(playlistId).getOrThrow() + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + Queue.Status( + title = nextResult.title, + items = (albumSongs + nextResult.items.subList(albumSongs.size, nextResult.items.size)).map { it.toMediaItem() }, + mediaItemIndex = nextResult.currentIndex ?: 0 + ) + } + + override fun hasNextPage(): Boolean = continuation != null + + override suspend fun nextPage(): List { + val nextResult = withContext(IO) { + YouTube.next(endpoint, continuation).getOrThrow() + } + continuation = nextResult.continuation + return nextResult.items.map { it.toMediaItem() } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeQueue.kt b/app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeQueue.kt new file mode 100644 index 0000000..6d4f712 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/playback/queues/YouTubeQueue.kt @@ -0,0 +1,44 @@ +package com.babelsoftware.loudly.playback.queues + +import androidx.media3.common.MediaItem +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.WatchEndpoint +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class YouTubeQueue( + private var endpoint: WatchEndpoint, + override val preloadItem: MediaMetadata? = null, +) : Queue { + private var continuation: String? = null + + override suspend fun getInitialStatus(): Queue.Status { + val nextResult = withContext(IO) { + YouTube.next(endpoint, continuation).getOrThrow() + } + endpoint = nextResult.endpoint + continuation = nextResult.continuation + return Queue.Status( + title = nextResult.title, + items = nextResult.items.map { it.toMediaItem() }, + mediaItemIndex = nextResult.currentIndex ?: 0 + ) + } + + override fun hasNextPage(): Boolean = continuation != null + + override suspend fun nextPage(): List { + val nextResult = withContext(IO) { + YouTube.next(endpoint, continuation).getOrThrow() + } + endpoint = nextResult.endpoint + continuation = nextResult.continuation + return nextResult.items.map { it.toMediaItem() } + } + + companion object { + fun radio(song: MediaMetadata) = YouTubeQueue(WatchEndpoint(song.id), song) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/ActionChipButton.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/ActionChipButton.kt new file mode 100644 index 0000000..119d2d0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/ActionChipButton.kt @@ -0,0 +1,66 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ActionChipButton( + onClick: () -> Unit, + icon: @Composable () -> Unit, + text: String? = null, + modifier: Modifier = Modifier +) { + val frostedCardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + ) + val frostedCardBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f)) + + Card( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + colors = frostedCardColors, + border = frostedCardBorder, + modifier = modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Box(modifier = Modifier.size(20.dp)) { + icon() + } + if (text != null) { + Spacer(Modifier.width(8.dp)) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + maxLines = 1, + modifier = Modifier + .widthIn(max = 80.dp) + .basicMarquee() + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/AnimatedSoundWave.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/AnimatedSoundWave.kt new file mode 100644 index 0000000..9bb09c9 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/AnimatedSoundWave.kt @@ -0,0 +1,96 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun AnimatedSoundWave( + modifier: Modifier = Modifier, + isPlaying: Boolean, + barCount: Int = 4, + barWidth: Dp = 3.dp, + barMaxHeight: Dp = 20.dp, + barMinHeight: Dp = 4.dp, + barSpacing: Dp = 2.dp, + barColor: Color = Color.White +) { + val barHeights = remember { List(barCount) { mutableStateOf(barMinHeight) } } + + LaunchedEffect(key1 = isPlaying) { + if (!isPlaying) { + barHeights.forEach { + launch { + animate( + initialValue = it.value.value, + targetValue = barMinHeight.value, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) + ) { value, _ -> + it.value = value.dp + } + } + } + return@LaunchedEffect + } + while (true) { + barHeights.forEachIndexed { index, barHeight -> + val (delay, targetHeight) = when (index) { + 0, 3 -> 150L to barMaxHeight * 0.7f + 1, 2 -> 0L to barMaxHeight + else -> 0L to barMinHeight + } + + launch { + delay(delay) + animate( + initialValue = barHeight.value.value, + targetValue = targetHeight.value, + animationSpec = tween(400, easing = FastOutSlowInEasing) + ) { value, _ -> barHeight.value = value.dp } + + animate( + initialValue = barHeight.value.value, + targetValue = barMinHeight.value, + animationSpec = tween(600, easing = FastOutSlowInEasing) + ) { value, _ -> barHeight.value = value.dp } + } + } + delay(1100) + } + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(barSpacing), + verticalAlignment = Alignment.Bottom + ) { + barHeights.forEach { height -> + Box( + modifier = Modifier + .width(barWidth) + .height(height.value) + .clip(CircleShape) + .background(barColor) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/AsyncImageLocal.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/AsyncImageLocal.kt new file mode 100644 index 0000000..1dd7d82 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/AsyncImageLocal.kt @@ -0,0 +1,68 @@ +package com.babelsoftware.loudly.ui.component + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.newFixedThreadPoolContext + +const val MAX_IMAGE_JOBS = 4 +@OptIn(DelicateCoroutinesApi::class) +val imageSession = newFixedThreadPoolContext(MAX_IMAGE_JOBS , "ImageExtractor") + +/** + * Non-blocking image + */ +@Composable +fun AsyncLocalImage( + image: () -> Bitmap?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + contentDescription: String? = null, +) { + var imageBitmapState by remember { mutableStateOf(null) } + LaunchedEffect(image) { + CoroutineScope(imageSession).launch { + try { + imageBitmapState = image.invoke()?.asImageBitmap() + } catch (e: Exception) { } + } + } + + imageBitmapState.let { imageBitmap -> + if (imageBitmap == null) { + Icon( + Icons.Rounded.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(16.dp)) + ) + } else { + Image( + bitmap = imageBitmap, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/AutoResizeText.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/AutoResizeText.kt new file mode 100644 index 0000000..5065d40 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/AutoResizeText.kt @@ -0,0 +1,93 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * From https://stackoverflow.com/a/69780826 + */ +@Composable +fun AutoResizeText( + text: String, + fontSizeRange: FontSizeRange, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current, +) { + var fontSizeValue by remember { mutableFloatStateOf(fontSizeRange.max.value) } + var readyToDraw by remember { mutableStateOf(false) } + + Text( + text = text, + color = color, + maxLines = maxLines, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + style = style, + fontSize = fontSizeValue.sp, + onTextLayout = { + if (it.didOverflowHeight && !readyToDraw) { + val nextFontSizeValue = fontSizeValue - fontSizeRange.step.value + if (nextFontSizeValue <= fontSizeRange.min.value) { + fontSizeValue = fontSizeRange.min.value + readyToDraw = true + } else { + fontSizeValue = nextFontSizeValue + } + } else { + readyToDraw = true + } + }, + modifier = modifier.drawWithContent { if (readyToDraw) drawContent() } + ) +} + +data class FontSizeRange( + val min: TextUnit, + val max: TextUnit, + val step: TextUnit = DEFAULT_TEXT_STEP, +) { + init { + require(min < max) { "min should be less than max, $this" } + require(step.value > 0) { "step should be greater than 0, $this" } + } + + companion object { + private val DEFAULT_TEXT_STEP = 1.sp + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/BigSeekBar.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/BigSeekBar.kt new file mode 100644 index 0000000..2d0fb3b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/BigSeekBar.kt @@ -0,0 +1,54 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.dp + +@Composable +fun BigSeekBar( + progressProvider: () -> Float, + onProgressChange: (Float) -> Unit, + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.13f), + color: Color = MaterialTheme.colorScheme.primary, +) { + var width by remember { + mutableFloatStateOf(0f) + } + + Canvas( + modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(16.dp)) + .onPlaced { + width = it.size.width.toFloat() + } + .pointerInput(progressProvider) { + detectHorizontalDragGestures { _, dragAmount -> + onProgressChange((progressProvider() + dragAmount * 1.2f / width).coerceIn(0f, 1f)) + } + } + ) { + drawRect(color = background) + + drawRect( + color = color, + size = size.copy(width = size.width * progressProvider()) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheet.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheet.kt new file mode 100644 index 0000000..8acbabf --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheet.kt @@ -0,0 +1,344 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.constants.NavigationBarAnimationSpec +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Bottom Sheet + * Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic) + */ +@Composable +fun BottomSheet( + state: BottomSheetState, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + collapsedBackgroundColor: Color = Color.Transparent, + onDismiss: (() -> Unit)? = null, + collapsedContent: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .offset { + val y = (state.expandedBound - state.value) + .roundToPx() + .coerceAtLeast(0) + IntOffset(x = 0, y = y) + } + .pointerInput(state) { + val velocityTracker = VelocityTracker() + + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + state.dispatchRawDelta(dragAmount) + }, + onDragCancel = { + velocityTracker.resetTracking() + state.snapTo(state.collapsedBound) + }, + onDragEnd = { + val velocity = -velocityTracker.calculateVelocity().y + velocityTracker.resetTracking() + state.performFling(velocity, onDismiss) + } + ) + } + .clip( + RoundedCornerShape( + topStart = if (!state.isExpanded) 16.dp else 0.dp, + topEnd = if (!state.isExpanded) 16.dp else 0.dp + ) + ) + ) { + if (!state.isCollapsed && !state.isDismissed) { + BackHandler(onBack = state::collapseSoft) + } + + if (!state.isCollapsed) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = ((state.progress - 0.25f) * 4).coerceIn(0f, 1f) + } + .background(backgroundColor), + content = content + ) + } + + if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { + Box( + modifier = Modifier + .graphicsLayer { + alpha = 1f - (state.progress * 4).coerceAtMost(1f) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = state::expandSoft + ) + .fillMaxWidth() + .height(state.collapsedBound) + .background(collapsedBackgroundColor), + content = collapsedContent + ) + } + } +} + +@Stable +class BottomSheetState( + draggableState: DraggableState, + private val coroutineScope: CoroutineScope, + private val animation: Animatable, + private val onAnchorChanged: (Int) -> Unit, + val collapsedBound: Dp, +) : DraggableState by draggableState { + private val dismissedBound: Dp + get() = animation.lowerBound!! + + val expandedBound: Dp + get() = animation.upperBound!! + + val value by animation.asState() + + val isDismissed by derivedStateOf { + value == animation.lowerBound!! + } + + val isCollapsed by derivedStateOf { + value == collapsedBound + } + + val isExpanded by derivedStateOf { + value == animation.upperBound + } + + val progress by derivedStateOf { + 1f - (animation.upperBound!! - animation.value) / (animation.upperBound!! - collapsedBound) + } + + private fun collapse(animationSpec: AnimationSpec) { + onAnchorChanged(collapsedAnchor) + coroutineScope.launch { + animation.animateTo(collapsedBound, animationSpec) + } + } + + private fun expand(animationSpec: AnimationSpec) { + onAnchorChanged(expandedAnchor) + coroutineScope.launch { + animation.animateTo(animation.upperBound!!, animationSpec) + } + } + + private fun collapse() { + collapse(SpringSpec()) + } + + fun expand() { + expand(SpringSpec()) + } + + fun collapseSoft() { + collapse(spring(stiffness = Spring.StiffnessMediumLow)) + } + + fun expandSoft() { + expand(spring(stiffness = Spring.StiffnessMediumLow)) + } + + fun dismiss() { + onAnchorChanged(dismissedAnchor) + coroutineScope.launch { + animation.animateTo(animation.lowerBound!!) + } + } + + fun snapTo(value: Dp) { + coroutineScope.launch { + animation.snapTo(value) + } + } + + fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { + if (velocity > 250) { + expand() + } else if (velocity < -250) { + if (value < collapsedBound && onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } else { + val l0 = dismissedBound + val l1 = (collapsedBound - dismissedBound) / 2 + val l2 = (expandedBound - collapsedBound) / 2 + val l3 = expandedBound + + when (value) { + in l0..l1 -> { + if (onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } + + in l1..l2 -> collapse() + in l2..l3 -> expand() + else -> Unit + } + } + } + + val preUpPostDownNestedScrollConnection + get() = object : NestedScrollConnection { + var isTopReached = false + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isExpanded && available.y < 0) { + isTopReached = false + } + + return if (isTopReached && available.y < 0 && source == NestedScrollSource.UserInput) { + dispatchRawDelta(available.y) + available + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!isTopReached) { + isTopReached = consumed.y == 0f && available.y > 0 + } + + return if (isTopReached && source == NestedScrollSource.UserInput) { + dispatchRawDelta(available.y) + available + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return if (isTopReached) { + val velocity = -available.y + performFling(velocity, null) + + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + isTopReached = false + return Velocity.Zero + } + } +} + +const val expandedAnchor = 2 +const val collapsedAnchor = 1 +const val dismissedAnchor = 0 + +@Composable +fun rememberBottomSheetState( + dismissedBound: Dp, + expandedBound: Dp, + collapsedBound: Dp = dismissedBound, + initialAnchor: Int = dismissedAnchor, +): BottomSheetState { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + + var previousAnchor by rememberSaveable { + mutableIntStateOf(initialAnchor) + } + val animation = remember { + Animatable(0.dp, Dp.VectorConverter) + } + + return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { + val initialValue = when (previousAnchor) { + expandedAnchor -> expandedBound + collapsedAnchor -> collapsedBound + dismissedAnchor -> dismissedBound + else -> error("Unknown BottomSheet anchor") + } + + animation.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound) + coroutineScope.launch { + animation.animateTo(initialValue, NavigationBarAnimationSpec) + } + + BottomSheetState( + draggableState = DraggableState { delta -> + coroutineScope.launch { + animation.snapTo(animation.value - with(density) { delta.toDp() }) + } + }, + onAnchorChanged = { previousAnchor = it }, + coroutineScope = coroutineScope, + animation = animation, + collapsedBound = collapsedBound + ) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheetMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheetMenu.kt new file mode 100644 index 0000000..d2300e0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/BottomSheetMenu.kt @@ -0,0 +1,111 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ShapeDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.ui.utils.top + +val LocalMenuState = compositionLocalOf { MenuState() } + +@Stable +class MenuState( + isVisible: Boolean = false, + content: @Composable ColumnScope.() -> Unit = {}, +) { + var isVisible by mutableStateOf(isVisible) + var content by mutableStateOf(content) + + fun show(content: @Composable ColumnScope.() -> Unit) { + isVisible = true + this.content = content + } + + fun dismiss() { + isVisible = false + } +} + +@Composable +fun BottomSheetMenu( + modifier: Modifier = Modifier, + state: MenuState, + background: Color = MaterialTheme.colorScheme.surfaceContainer, +) { + val focusManager = LocalFocusManager.current + + AnimatedVisibility( + visible = state.isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + BackHandler { + state.dismiss() + } + + Spacer( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { + state.dismiss() + } + } + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)) + .fillMaxSize() + ) + } + + AnimatedVisibility( + visible = state.isVisible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = modifier + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(top = 48.dp) + .clip(ShapeDefaults.Large.top()) + .background(background) + ) { + state.content(this) + } + } + + LaunchedEffect(state.isVisible) { + if (state.isVisible) { + focusManager.clearFocus() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/ChipStyleButton.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/ChipStyleButton.kt new file mode 100644 index 0000000..c6c9771 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/ChipStyleButton.kt @@ -0,0 +1,31 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ChipStyleButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + Button( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp) + ) { + content() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/ChipsRow.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/ChipsRow.kt new file mode 100644 index 0000000..838927c --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/ChipsRow.kt @@ -0,0 +1,51 @@ +package com.babelsoftware.loudly.ui.component + + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.RoundedCornerShape + +@Composable +fun ChipsRow( + chips: List>, + currentValue: E, + onValueUpdate: (E) -> Unit, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + Spacer(Modifier.width(12.dp)) + + chips.forEach { (value, label) -> + FilterChip( + label = { Text(label) }, + selected = currentValue == value, + colors = FilterChipDefaults.filterChipColors( + containerColor = containerColor, + ), + onClick = { onValueUpdate(value) }, + shape = RoundedCornerShape(16.dp), + border = null + ) + + Spacer(Modifier.width(8.dp)) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/Dialog.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/Dialog.kt new file mode 100644 index 0000000..e23cd34 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/Dialog.kt @@ -0,0 +1,364 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.babelsoftware.loudly.constants.DialogCornerRadius +import com.babelsoftware.loudly.constants.SliderStyle +import com.babelsoftware.loudly.constants.SliderStyleKey +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.R +import kotlinx.coroutines.delay +import me.saket.squiggles.SquigglySlider + +@Composable +fun DefaultDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + buttons: (@Composable RowScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(24.dp) + ) { + if (icon != null) { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.iconContentColor) { + Box( + Modifier.align(Alignment.CenterHorizontally) + ) { + icon() + } + } + + Spacer(Modifier.height(16.dp)) + } + if (title != null) { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.titleContentColor) { + ProvideTextStyle(MaterialTheme.typography.headlineSmall) { + Box( + Modifier.align(if (icon == null) Alignment.Start else Alignment.CenterHorizontally) + ) { + title() + } + } + } + + Spacer(Modifier.height(16.dp)) + } + + content() + + if (buttons != null) { + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.align(Alignment.End) + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + ProvideTextStyle( + value = MaterialTheme.typography.labelLarge + ) { + buttons() + } + } + } + } + } + } + } +} + +@Composable +fun ListDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(vertical = 24.dp) + ) { + LazyColumn(content = content) + } + } + } +} + +@Composable +fun InfoLabel( + text: String +) = Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp) +) { + Icon( + Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(4.dp) + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) +} + +@Composable +fun TextFieldDialog( + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + initialTextFieldValue: TextFieldValue = TextFieldValue(), + placeholder: @Composable (() -> Unit)? = null, + singleLine: Boolean = true, + maxLines: Int = if (singleLine) 1 else 10, + isInputValid: (String) -> Boolean = { it.isNotEmpty() }, + onDone: (String) -> Unit, + onDismiss: () -> Unit, + extraContent: (@Composable () -> Unit)? = null, +) { + val (textFieldValue, onTextFieldValueChange) = remember { + mutableStateOf(initialTextFieldValue) + } + + val focusRequester = remember { + FocusRequester() + } + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + + DefaultDialog( + onDismiss = onDismiss, + modifier = modifier, + icon = icon, + title = title, + buttons = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + enabled = isInputValid(textFieldValue.text), + onClick = { + onDismiss() + onDone(textFieldValue.text) + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) { + TextField( + value = textFieldValue, + onValueChange = onTextFieldValueChange, + placeholder = placeholder, + singleLine = singleLine, + maxLines = maxLines, + colors = TextFieldDefaults.colors(), + keyboardOptions = KeyboardOptions(imeAction = if (singleLine) ImeAction.Done else ImeAction.None), + keyboardActions = KeyboardActions( + onDone = { + onDone(textFieldValue.text) + onDismiss() + } + ), + modifier = Modifier + .weight(weight = 1f, fill = false) + .focusRequester(focusRequester) + ) + extraContent?.invoke() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CounterDialog( + title: String, + description: String? = null, + initialValue: Int, + upperBound: Int = 100, + lowerBound: Int = 0, + resetValue: Int, + unitDisplay: String = "", + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit, + onReset: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, +) = BasicAlertDialog( + onDismissRequest = { onDismiss() }, + content = { + val tempValue = rememberSaveable { + mutableIntStateOf(initialValue) + } + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.DEFAULT) + Column( + modifier = Modifier + .background( + MaterialTheme.colorScheme.background, + RoundedCornerShape(DialogCornerRadius) + ) + .fillMaxWidth(0.8f) + .padding(16.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = title, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.titleLarge, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "${tempValue.intValue}$unitDisplay", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp) + ) + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = tempValue.intValue.toFloat(), + onValueChange = { tempValue.intValue = it.toInt() }, + valueRange = lowerBound.toFloat()..upperBound.toFloat(), + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = SliderDefaults.colors() + ) + } + ) + } + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = tempValue.intValue.toFloat(), + onValueChange = { tempValue.intValue = it.toInt() }, + valueRange = lowerBound.toFloat()..upperBound.toFloat(), + ) + } + SliderStyle.COMPOSE -> { + Slider( + value = tempValue.intValue.toFloat(), + onValueChange = { tempValue.intValue = it.toInt() }, + valueRange = lowerBound.toFloat()..upperBound.toFloat(), + ) + } + } + } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + if (onReset != null) { + Row(modifier = Modifier.weight(1f)) { + TextButton( + onClick = { tempValue.intValue = resetValue }, + ) { + Text(stringResource(R.string.reset)) + } + } + + TextButton( + onClick = { onConfirm(tempValue.intValue) } + ) { + Text(stringResource(android.R.string.ok)) + } + } + + if (onCancel != null) + TextButton( + onClick = { onCancel.invoke() } + ) { + Text(stringResource(android.R.string.cancel)) + } + } + } + } + } +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/EmptyPlaceholder.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/EmptyPlaceholder.kt new file mode 100644 index 0000000..308becd --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/EmptyPlaceholder.kt @@ -0,0 +1,46 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +@Composable +fun EmptyPlaceholder( + @DrawableRes icon: Int, + text: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 24.dp) + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(64.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge + ) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/ErrorDetailDialog.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/ErrorDetailDialog.kt new file mode 100644 index 0000000..6f5476f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/ErrorDetailDialog.kt @@ -0,0 +1,65 @@ +package com.babelsoftware.loudly.ui.component + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.R + +@Composable +fun ErrorDetailDialog( + errorTitle: String, + errorMessage: String, + onDismiss: () -> Unit +) { + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(text = errorTitle, fontWeight = FontWeight.Bold) }, + text = { + Box(modifier = Modifier.heightIn(max = 250.dp)) { + LazyColumn { + item { + Text(text = errorMessage) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + shareError(context, errorMessage) + onDismiss() + } + ) { + Text(stringResource(R.string.send_to_developer)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + } + ) +} + +private fun shareError(context: Context, error: String) { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "Loudly Update Error:\n\n$error") + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/FeaturedContentCard.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/FeaturedContentCard.kt new file mode 100644 index 0000000..df0bc18 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/FeaturedContentCard.kt @@ -0,0 +1,87 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.LocalItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeaturedContentCard( + item: LocalItem, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + val title = item.title + val thumbnailUrl = item.thumbnailUrl + val subtitle = when (item) { + is Album -> item.artists.joinToString { it.name } + is Artist -> stringResource(R.string.artist) + else -> "" + } + + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + // Image + AsyncImage( + model = thumbnailUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(90.dp) + .clip(RoundedCornerShape(12.dp)) + ) + Spacer(Modifier.width(16.dp)) + + // Text + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.featured_for_you), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + Icon( + painter = painterResource(R.drawable.play), + contentDescription = stringResource(R.string.play), + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/HideOnScrollFAB.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/HideOnScrollFAB.kt new file mode 100644 index 0000000..2e00b1b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/HideOnScrollFAB.kt @@ -0,0 +1,113 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.ui.utils.isScrollingUp + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + lazyListState: LazyListState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + } +} + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + lazyListState: LazyGridState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + } +} + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + scrollState: ScrollState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && scrollState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/IconButton.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/IconButton.kt new file mode 100644 index 0000000..8e2902d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/IconButton.kt @@ -0,0 +1,93 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.babelsoftware.loudly.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.Indication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.ripple +import androidx.compose.material3.tokens.IconButtonTokens +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role + +@Composable +fun ResizableIconButton( + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface, + enabled: Boolean = true, + indication: Indication? = null, + onClick: () -> Unit = {}, +) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .clickable( + indication = indication ?: ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick + ) + .alpha(if (enabled) 1f else 0.5f) + .then(modifier) + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun IconButton( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(IconButtonTokens.StateLayerSize) + .clip(CircleShape) + .background(color = colors.containerColor(enabled)) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple( + bounded = false, + radius = IconButtonTokens.StateLayerSize / 2 + ) + ), + contentAlignment = Alignment.Center + ) { + val contentColor = colors.contentColor(enabled) + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/Items.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/Items.kt new file mode 100644 index 0000000..bf65740 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/Items.kt @@ -0,0 +1,1612 @@ +package com.babelsoftware.loudly.ui.component + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.FolderCopy +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.ListThumbnailSize +import com.babelsoftware.loudly.constants.SwipeSongToDismissKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.RecentActivityEntity +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.playback.queues.LocalAlbumRadio +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.YTItem +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.menu.FolderMenu +import com.babelsoftware.loudly.ui.utils.DirectoryTree +import com.babelsoftware.loudly.ui.utils.imageCache +import com.babelsoftware.loudly.utils.joinByBullet +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.reportException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +const val ActiveBoxAlpha = 0.6f + +@Composable +inline fun ListItem( + modifier: Modifier = Modifier, + title: String, + noinline subtitle: (@Composable RowScope.() -> Unit)? = null, + thumbnailContent: @Composable () -> Unit, + trailingContent: @Composable RowScope.() -> Unit = {}, + isActive: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = if (isActive) + modifier + .height(ListItemHeight) + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color = MaterialTheme.colorScheme.secondaryContainer) + else + modifier + .height(ListItemHeight) + .padding(horizontal = 8.dp), + ) { + Box( + modifier = Modifier.padding(6.dp), + contentAlignment = Alignment.Center + ) { + thumbnailContent() + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (subtitle != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + subtitle() + } + } + } + + trailingContent() + } +} + +@Composable +fun ListItem( + modifier: Modifier = Modifier, + title: String, + subtitle: String?, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable () -> Unit, + trailingContent: @Composable RowScope.() -> Unit = {}, + isActive: Boolean = false +) = ListItem( + title = title, + subtitle = { + badges() + + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + thumbnailContent = thumbnailContent, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive +) + +@Composable +fun GridItem( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + subtitle: @Composable () -> Unit, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, + thumbnailRatio: Float = 1f, + fillMaxWidth: Boolean = false, +) { + Column( + modifier = if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight * thumbnailRatio) + } + ) { + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.Companion.height(GridThumbnailHeight) + } + .aspectRatio(thumbnailRatio) + ) { + thumbnailContent() + } + + Spacer(modifier = Modifier.height(6.dp)) + + title() + + Row(verticalAlignment = Alignment.CenterVertically) { + badges() + + subtitle() + } + } +} + +@Composable +fun GridItem( + modifier: Modifier = Modifier, + title: String, + subtitle: String, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, + thumbnailRatio: Float = 1f, + fillMaxWidth: Boolean = false, +) = GridItem( + modifier = modifier, + title = { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + }, + subtitle = { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + thumbnailContent = thumbnailContent, + thumbnailRatio = thumbnailRatio, + fillMaxWidth = fillMaxWidth +) + +@Composable +fun SongListItem( + song: Song, + modifier: Modifier = Modifier, + albumIndex: Int? = null, + showLikedIcon: Boolean = true, + showInLibraryIcon: Boolean = false, + showDownloadIcon: Boolean = true, + isSwipeable: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + if (!song.song.isLocal) { + if (showLikedIcon && song.song.liked) { + Icon.Favorite() + } + if (showInLibraryIcon && song.song.inLibrary != null) { + Icon.Library() + } + if (showDownloadIcon) { + val download by LocalDownloadUtil.current.getDownload(song.id) + .collectAsState(initial = null) + Icon.Download(download?.state) + } + } else { + if (showLikedIcon && song.song.liked) { + Icon.Favorite() + } + if (showInLibraryIcon && song.song.inLibrary != null) { + Icon.Library() + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, + contentScale: ContentScale = ContentScale.Fit, +) { + val context = LocalContext.current + val playerConnection = LocalPlayerConnection.current ?: return + + val dismissState = rememberSwipeToDismissBoxState(confirmValueChange = { false }) + val colorScheme = MaterialTheme.colorScheme + + val (swipeSongToDismiss) = rememberPreference(SwipeSongToDismissKey, defaultValue = true) + + if (isSwipeable && swipeSongToDismiss) { + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val target = dismissState.targetValue + var swipeStartTime = 0L + LaunchedEffect(target) { + when (target) { + SwipeToDismissBoxValue.StartToEnd -> { + val swipeEndTime = System.currentTimeMillis() + if (swipeEndTime - swipeStartTime > 1000) { + Toast.makeText(context, R.string.play_next, Toast.LENGTH_SHORT) + .show() + playerConnection.playNext(listOf(song.toMediaItem())) + } + } + + SwipeToDismissBoxValue.EndToStart -> { + val swipeEndTime = System.currentTimeMillis() + if (swipeEndTime - swipeStartTime > 1000) { + Toast.makeText(context, R.string.add_to_queue, Toast.LENGTH_SHORT) + .show() + playerConnection.addToQueue(listOf(song.toMediaItem())) + } + } + + else -> { + swipeStartTime = System.currentTimeMillis() + } + } + } + val color by + animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled -> Color.Transparent + SwipeToDismissBoxValue.StartToEnd -> colorScheme.primary + SwipeToDismissBoxValue.EndToStart -> colorScheme.primary + }, label = "" + ) + val icon = when (target) { + SwipeToDismissBoxValue.StartToEnd -> R.drawable.playlist_play + SwipeToDismissBoxValue.EndToStart -> R.drawable.queue_music + else -> null + } + when (target) { + SwipeToDismissBoxValue.StartToEnd -> Arrangement.Start + SwipeToDismissBoxValue.EndToStart -> Arrangement.End + else -> null + }?.let { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .background(color) + .align( + when (target) { + SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart + SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd + else -> Alignment.Center + } + ) + .padding(horizontal = 8.dp) + ) { + icon?.let { + Icon( + painter = painterResource(it), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterEnd), + tint = Color.White + ) + } + } + } + } + } + ) { + ListItem( + title = song.song.title, + subtitle = joinByBullet( + song.artists.joinToString { it.name }, + makeTimeString(song.song.duration * 1000L) + ), + badges = badges, + thumbnailContent = { + if (song.song.isLocal) { + song.song.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it.localPath, false) }, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + AnimatedVisibility(visible = isActive, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .size(ListThumbnailSize) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ), + contentAlignment = Alignment.Center + ) { + AnimatedSoundWave(isPlaying = isPlaying, barColor = Color.White) + } + } + } + } else { + ItemThumbnail( + thumbnailUrl = song.song.thumbnailUrl, + albumIndex = albumIndex, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.Companion.size(ListThumbnailSize) + ) + } + }, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive + ) + } + } else { + ListItem( + title = song.song.title, + subtitle = joinByBullet( + song.artists.joinToString { it.name }, + makeTimeString(song.song.duration * 1000L) + ), + badges = badges, + thumbnailContent = { + if (song.song.isLocal) { + song.song.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it.localPath, false) }, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = Color.White, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .background( + color = Color.Black.copy(alpha = ActiveBoxAlpha), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) + } + } else { + ItemThumbnail( + thumbnailUrl = song.song.thumbnailUrl, + albumIndex = albumIndex, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } + }, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive + ) + } +} + +@Composable +fun SongFolderItem( + folderTitle: String, + subtitle: String?, + modifier: Modifier = Modifier, +) = ListItem(title = folderTitle, + subtitle = subtitle, + thumbnailContent = { + Icon( + Icons.Rounded.Folder, + contentDescription = null, + modifier = modifier.size(48.dp) + ) + }, + modifier = modifier +) + +@Composable +fun SongFolderItem( + folder: DirectoryTree, + modifier: Modifier = Modifier, + folderTitle: String? = null, + menuState: MenuState, + navController: NavController, + subtitle: String +) = ListItem(title = folderTitle ?: folder.currentDir, + subtitle = subtitle, + thumbnailContent = { + Icon( + Icons.Rounded.FolderCopy, + contentDescription = null, + modifier = modifier.size(48.dp) + ) + }, + trailingContent = { + IconButton( + onClick = { + menuState.show { + FolderMenu( + folder = folder, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = modifier +) + +@Composable +fun SongGridItem( + song: Song, + modifier: Modifier = Modifier, + showLikedIcon: Boolean = true, + showInLibraryIcon: Boolean = false, + showDownloadIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + if (!song.song.isLocal) { + if (showLikedIcon && song.song.liked) { + Icon.Favorite() + } + if (showInLibraryIcon && song.song.inLibrary != null) { + Icon.Library() + } + if (showDownloadIcon) { + val download by LocalDownloadUtil.current.getDownload(song.id) + .collectAsState(initial = null) + Icon.Download(download?.state) + } + } else { + if (showLikedIcon && song.song.liked) { + Icon.Favorite() + } + if (showInLibraryIcon && song.song.inLibrary != null) { + Icon.Library() + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, + contentScale: ContentScale = ContentScale.Fit, +) = GridItem( + title = song.song.title, + subtitle = joinByBullet( + song.artists.joinToString { it.name }, + makeTimeString(song.song.duration * 1000L) + ), + badges = badges, + thumbnailContent = { + if (song.song.isLocal) { + song.song.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it.localPath, false) }, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier.Companion + .size(GridThumbnailHeight) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + AnimatedVisibility(visible = isActive, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ), + contentAlignment = Alignment.Center + ) { + AnimatedSoundWave(isPlaying = isPlaying, barColor = Color.White) + } + } + } + } else { + ItemThumbnail( + thumbnailUrl = song.song.thumbnailUrl, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.Companion + .size(GridThumbnailHeight) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } + SongPlayButton( + visible = !isActive + ) + }, + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + +@Composable +fun ArtistListItem( + artist: Artist, + modifier: Modifier = Modifier, + showLikedIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + if (showLikedIcon && artist.artist.bookmarkedAt != null) { + Icon.Favorite() + } + }, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), + badges = badges, + thumbnailContent = { + AsyncImage( + model = artist.artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(CircleShape) + ) + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun ArtistGridItem( + artist: Artist, + modifier: Modifier = Modifier, + showLikedIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + if (showLikedIcon && artist.artist.bookmarkedAt != null) { + Icon.Favorite() + } + }, + fillMaxWidth: Boolean = false, +) = GridItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), + badges = badges, + thumbnailContent = { + AsyncImage( + model = artist.artist.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + }, + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + +@Composable +fun AlbumListItem( + album: Album, + modifier: Modifier = Modifier, + showLikedIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = when { + songs.all { downloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED + songs.all { downloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING + else -> Download.STATE_STOPPED + } + } + } + + if (showLikedIcon && album.album.bookmarkedAt != null) { + Icon.Favorite() + } + + Icon.Download(downloadState) + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = album.album.title, + subtitle = joinByBullet( + album.artists.joinToString { it.name }, + pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), + album.album.year?.toString() + ), + badges = badges, + thumbnailContent = { + ItemThumbnail( + thumbnailUrl = album.album.thumbnailUrl, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.Companion.size(ListThumbnailSize) + ) + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun AlbumGridItem( + album: Album, + modifier: Modifier = Modifier, + showLikedIcon: Boolean = true, + coroutineScope: CoroutineScope, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = when { + songs.all { downloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED + songs.all { downloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING + else -> Download.STATE_STOPPED + } + } + } + + if (showLikedIcon && album.album.bookmarkedAt != null) { + Icon.Favorite() + } + + Icon.Download(downloadState) + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, +) = GridItem( + title = album.album.title, + subtitle = album.artists.joinToString { it.name }, + badges = badges, + thumbnailContent = { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@GridItem + + ItemThumbnail( + thumbnailUrl = album.album.thumbnailUrl, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + ) + + AlbumPlayButton( + visible = !isActive, + onClick = { + coroutineScope.launch { + database.albumWithSongs(album.id).first()?.let { albumWithSongs -> + playerConnection.playQueue( + LocalAlbumRadio(albumWithSongs) + ) + } + } + } + ) + }, + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + +@Composable +fun PlaylistListItem( + playlist: Playlist, + thumbnail: ImageVector, + modifier: Modifier = Modifier, + trailingContent: @Composable (RowScope.() -> Unit) = {}, +) = ListItem( + title = playlist.playlist.name, + subtitle = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), + thumbnailContent = { + Box( + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center + ) { + PlaylistThumbnail( + thumbnails = playlist.thumbnails, + size = ListThumbnailSize, + placeHolder = { + Icon( + imageVector = thumbnail, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier.Companion + .size(ListThumbnailSize / 2) + .align(Alignment.Center) + ) + }, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + } + }, + badges = { + if (playlist.playlist.isLocal) { + Icon( + imageVector = Icons.Rounded.CloudOff, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + trailingContent = trailingContent, + modifier = modifier +) + +@Composable +fun PlaylistGridItem( + playlist: Playlist, + thumbnail: ImageVector, + modifier: Modifier = Modifier, + fillMaxWidth: Boolean = false, + badges: @Composable (RowScope.() -> Unit) = {}, +) = GridItem( + title = playlist.playlist.name, + subtitle = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), + badges = badges, + thumbnailContent = { + val width = maxWidth + val libcarditem = 25.dp + Box( + modifier = Modifier + .size(width) + .clip(RoundedCornerShape(libcarditem)) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + PlaylistThumbnail( + thumbnails = playlist.thumbnails, + size = width, + placeHolder = { + Icon( + imageVector = thumbnail, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(width / 2) + .align(Alignment.Center) + ) + }, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + } + }, + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + +@Composable +fun MediaMetadataListItem( + mediaMetadata: MediaMetadata, + modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val song by database.song(mediaMetadata.id).collectAsState(initial = null) + + if (song?.song?.liked == true) { + Icon.Favorite() + } + if (song?.song?.inLibrary != null) { + Icon.Library() + } + val download by LocalDownloadUtil.current.getDownload(song?.id) + .collectAsState(initial = null) + Icon.Download(download?.state) + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, + contentScale: ContentScale = ContentScale.Fit, +) = ListItem( + title = mediaMetadata.title, + subtitle = joinByBullet( + mediaMetadata.artists.joinToString { it.name }, + makeTimeString(mediaMetadata.duration * 1000L) + ), + badges = badges, + thumbnailContent = { + Box { + if (mediaMetadata.isLocal) { + mediaMetadata.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it.localPath, false) }, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = Color.White, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .align(alignment = Alignment.Center) + .background( + color = Color.Black.copy(alpha = ActiveBoxAlpha), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) + } + } else { + ItemThumbnail( + thumbnailUrl = mediaMetadata.thumbnailUrl, + isActive = isActive, + isPlaying = isPlaying, + shape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = Modifier.Companion.size(ListThumbnailSize) + ) + } + } + }, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive +) + +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +fun YouTubeCardItem( + item: RecentActivityEntity, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val configuration = LocalConfiguration.current + val screenWidthDp = configuration.screenWidthDp + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(60.dp) + .width((screenWidthDp.dp - 12.dp) / 2) + .padding(6.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .clickable(onClick = onClick) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.Companion.size(ListThumbnailSize) + ) { + val thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius) + val thumbnailRatio = 1f + + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .aspectRatio(thumbnailRatio) + .clip(thumbnailShape) + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Start, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun YouTubeListItem( + item: YTItem, + modifier: Modifier = Modifier, + isSwipeable: Boolean = true, + albumIndex: Int? = null, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val song by database.song(item.id).collectAsState(initial = null) + val album by database.album(item.id).collectAsState(initial = null) + + if (item is SongItem && song?.song?.liked == true || + item is AlbumItem && album?.album?.bookmarkedAt != null + ) { + Icon.Favorite() + } + if (item.explicit) { + Icon.Explicit() + } + if (item is SongItem && song?.song?.inLibrary != null) { + Icon.Library() + } + if (item is SongItem) { + val downloads by LocalDownloadUtil.current.downloads.collectAsState() + Icon.Download(downloads[item.id]?.state) + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) { + val (swipeSongToDismiss) = rememberPreference(SwipeSongToDismissKey, defaultValue = true) + if (item is SongItem && isSwipeable && swipeSongToDismiss) { + val context = LocalContext.current + val playerConnection = LocalPlayerConnection.current ?: return + + val dismissState = rememberSwipeToDismissBoxState(confirmValueChange = { false }) + val colorScheme = MaterialTheme.colorScheme + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val target = dismissState.targetValue + var swipeStartTime = 0L + LaunchedEffect(target) { + when (target) { + SwipeToDismissBoxValue.StartToEnd -> { + val swipeEndTime = System.currentTimeMillis() + if (swipeEndTime - swipeStartTime > 1000) { + Toast.makeText(context, R.string.play_next, Toast.LENGTH_SHORT) + .show() + playerConnection.playNext(listOf(item.toMediaItem())) + } + } + + SwipeToDismissBoxValue.EndToStart -> { + val swipeEndTime = System.currentTimeMillis() + if (swipeEndTime - swipeStartTime > 1000) { + Toast.makeText(context, R.string.add_to_queue, Toast.LENGTH_SHORT) + .show() + playerConnection.addToQueue(listOf(item.toMediaItem())) + } + } + + else -> { + swipeStartTime = + System.currentTimeMillis() + } + } + } + val color by + animateColorAsState( + when (dismissState.targetValue) { + SwipeToDismissBoxValue.Settled -> Color.Transparent + SwipeToDismissBoxValue.StartToEnd -> colorScheme.primary + SwipeToDismissBoxValue.EndToStart -> colorScheme.primary + }, label = "" + ) + val icon = when (target) { + SwipeToDismissBoxValue.StartToEnd -> R.drawable.playlist_play + SwipeToDismissBoxValue.EndToStart -> R.drawable.queue_music + else -> null + } + when (target) { + SwipeToDismissBoxValue.StartToEnd -> Arrangement.Start + SwipeToDismissBoxValue.EndToStart -> Arrangement.End + else -> null + }?.let { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Transparent) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .background(color) + .align( + when (target) { + SwipeToDismissBoxValue.StartToEnd -> Alignment.CenterStart + SwipeToDismissBoxValue.EndToStart -> Alignment.CenterEnd + else -> Alignment.Center + } + ) + .padding(horizontal = 8.dp) + ) { + icon?.let { + Icon( + painter = painterResource(it), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterEnd), + tint = Color.White + ) + } + } + } + } + } + ) { + BaseListItemContent( + item = item, + modifier = modifier, + albumIndex = albumIndex, + badges = badges, + isActive = isActive, + isPlaying = isPlaying, + trailingContent = trailingContent + ) + } + } else { + BaseListItemContent( + item = item, + modifier = modifier, + albumIndex = albumIndex, + badges = badges, + isActive = isActive, + isPlaying = isPlaying, + trailingContent = trailingContent + ) + } +} + +@Composable +private fun BaseListItemContent( + item: YTItem, + modifier: Modifier, + albumIndex: Int?, + badges: @Composable RowScope.() -> Unit, + isActive: Boolean, + isPlaying: Boolean, + trailingContent: @Composable RowScope.() -> Unit +) { + ListItem( + title = item.title, + subtitle = when (item) { + is SongItem -> joinByBullet( + item.artists.joinToString { it.name }, + makeTimeString(item.duration?.times(1000L)) + ) + is AlbumItem -> joinByBullet( + item.artists?.joinToString { it.name }, + item.year?.toString() + ) + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) + }, + badges = badges, + thumbnailContent = { + ItemThumbnail( + thumbnailUrl = item.thumbnail, + albumIndex = albumIndex, + isActive = isActive, + isPlaying = isPlaying, + shape = if (item is ArtistItem) CircleShape else RoundedCornerShape( + ThumbnailCornerRadius + ), + modifier = Modifier.Companion.size(ListThumbnailSize) + ) + }, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive + ) +} + + +@Composable +fun YouTubeGridItem( + item: YTItem, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope? = null, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val song by database.song(item.id).collectAsState(initial = null) + val album by database.album(item.id).collectAsState(initial = null) + + if (item is SongItem && song?.song?.liked == true || + item is AlbumItem && album?.album?.bookmarkedAt != null + ) { + Icon.Favorite() + } + if (item.explicit) { + Icon.Explicit() + } + if (item is SongItem && song?.song?.inLibrary != null) { + Icon.Library() + } + if (item is SongItem) { + val downloads by LocalDownloadUtil.current.downloads.collectAsState() + Icon.Download(downloads[item.id]?.state) + } + }, + thumbnailRatio: Float = if (item is SongItem) 16f / 9 else 1f, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, + shape: Shape = RoundedCornerShape(ThumbnailCornerRadius) +) = GridItem( + title = { + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = if (item is ArtistItem) TextAlign.Center else TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .clip(shape) + ) + }, + subtitle = { + val subtitle = when (item) { + is SongItem -> joinByBullet( + item.artists.joinToString { it.name }, + makeTimeString(item.duration?.times(1000L)) + ) + is AlbumItem -> joinByBullet( + item.artists?.joinToString { it.name }, + item.year?.toString() + ) + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) + } + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + }, + badges = badges, + thumbnailContent = { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@GridItem + + ItemThumbnail( + thumbnailUrl = item.thumbnail, + isActive = isActive, + isPlaying = isPlaying, + shape = if (item is ArtistItem) CircleShape else RoundedCornerShape( + ThumbnailCornerRadius + ), + ) + + AlbumPlayButton( + visible = item is AlbumItem && !isActive, + onClick = { + coroutineScope?.launch(Dispatchers.IO) { + var albumWithSongs = database.albumWithSongs(item.id).first() + if (albumWithSongs?.songs.isNullOrEmpty()) { + YouTube.album(item.id).onSuccess { albumPage -> + database.transaction { + insert(albumPage) + } + albumWithSongs = database.albumWithSongs(item.id).first() + }.onFailure { + reportException(it) + } + } + albumWithSongs?.let { + withContext(Dispatchers.Main) { + playerConnection.playQueue( + LocalAlbumRadio(it) + ) + } + } + } + } + ) + SongPlayButton( + visible = item is SongItem && !isActive, + ) + }, + thumbnailRatio = thumbnailRatio, + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + +@Composable +fun ItemThumbnail( + thumbnailUrl: Any?, + isActive: Boolean, + isPlaying: Boolean, + shape: Shape, + modifier: Modifier = Modifier, + albumIndex: Int? = null, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + ) { + if (albumIndex != null) { + AnimatedVisibility( + visible = !isActive, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge + ) + } + } else { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(shape) + ) + } + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(), + exit = fadeOut() + ) { + // Animasyonun resim üzerinde daha iyi görünmesi için yarı saydam bir arka plan + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = if (albumIndex != null) Color.Transparent else Color.Black.copy( + alpha = 0.5f // Alpha değerini biraz artırdık + ), + shape = shape + ), + contentAlignment = Alignment.Center + ) { + AnimatedSoundWave( + isPlaying = isPlaying, + barColor = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White + ) + } + } + } +} + +@Composable +fun PlaylistThumbnail( + thumbnails: List, + size: Dp, + placeHolder: @Composable () -> Unit, + shape: Shape, +) { + when (thumbnails.size) { + 0 -> placeHolder() + + 1 -> if (thumbnails[0].startsWith("/storage")) { + AsyncImage( + model = thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size) + .clip(shape) + ) + } else { + AsyncImage( + model = thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size) + .clip(shape) + ) + } + + else -> Box( + modifier = Modifier + .size(size) + .clip(shape) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(shape) + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).fastForEachIndexed { index, alignment -> + if (thumbnails.getOrNull(index)?.startsWith("/storage") == true) { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(thumbnails[index], true) }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(size / 2) + ) + } else { + AsyncImage( + model = thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(size / 2) + ) + } + } + } + } + } +} + +@Composable +fun BoxScope.AlbumPlayButton( + visible: Boolean, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = ActiveBoxAlpha)) + .clickable(onClick = onClick) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White + ) + } + } +} + +@Composable +fun BoxScope.SongPlayButton( + visible: Boolean, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.Center) + .padding(8.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = ActiveBoxAlpha)) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + modifier = Modifier + .size(45.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = ActiveBoxAlpha)) + .align(Alignment.Center) + ) + } + } +} + +private object Icon { + @Composable + fun Favorite() { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + + @Composable + fun Library() { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + + @Composable + fun Download(state: Int?) { + when (state) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + + STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} + } + } + + @Composable + fun Explicit() { + Icon( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/ListMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/ListMenu.kt new file mode 100644 index 0000000..c3d51bd --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/ListMenu.kt @@ -0,0 +1,153 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.exoplayer.offline.Download +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.R + +val ListMenuItemHeight = 56.dp + +@Composable +fun ListMenu( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: LazyListScope.() -> Unit +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +fun LazyListScope.ListMenuItem( + modifier: Modifier = Modifier, + @DrawableRes icon: Int, + tint: @Composable () -> Color = { LocalContentColor.current }, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit +) = ListMenuItem( + modifier = modifier, + icon = { + Icon( + painter = painterResource(icon), + tint = tint(), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + }, + title = title, + enabled = enabled, + onClick = onClick +) + +fun LazyListScope.ListMenuItem( + modifier: Modifier = Modifier, + icon: @Composable () -> Unit, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit +) { + item { + Row( + modifier = modifier + .fillMaxWidth() + .height(ListMenuItemHeight) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(title), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + } + } +} + +fun LazyListScope.DownloadListMenu( + @Download.State state: Int?, + onRemoveDownload: () -> Unit, + onDownload: () -> Unit, +) { + when (state) { + Download.STATE_COMPLETED -> { + ListMenuItem( + icon = R.drawable.offline, + title = R.string.remove_download, + onClick = onRemoveDownload + ) + } + Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { + ListMenuItem( + icon = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + title = R.string.downloading, + onClick = onRemoveDownload + ) + } + else -> { + ListMenuItem( + icon = R.drawable.download, + title = R.string.download, + onClick = onDownload + ) + } + } +} + +fun LazyListScope.SleepTimerListMenu( + modifier: Modifier = Modifier, + sleepTimerTimeLeft: Long, + enabled: Boolean = true, + onClick: () -> Unit +) { + item { + Row( + modifier = modifier + .fillMaxWidth() + .height(ListMenuItemHeight) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(R.drawable.bedtime), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(16.dp)) + Text( + text = if (enabled) makeTimeString(sleepTimerTimeLeft) else stringResource(id = R.string.sleep_timer), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/LoudlyBottomNavBar.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/LoudlyBottomNavBar.kt new file mode 100644 index 0000000..e8929d9 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/LoudlyBottomNavBar.kt @@ -0,0 +1,196 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.screens.Screens + +// Data class representing navigation bar elements +private data class NavItem( + val route: String, + val iconResId: Int, + val activeIconResId: Int, + val labelResId: Int +) + +// List of navigation elements +private val navItems = listOf( + NavItem(Screens.Home.route, R.drawable.home_outlined, R.drawable.home_filled, R.string.home), + NavItem(Screens.Explore.route, R.drawable.explore_outlined, R.drawable.explore_filled, R.string.explore), + NavItem(Screens.Library.route, R.drawable.library_music_outlined, R.drawable.library_music_filled, R.string.filter_library) +) + +/** + * Custom-designed animated and scroll-sensitive bottom navigation bar, inspired by PixelPlayer. + */ +@Composable +fun LoudlyBottomNavBar( + navController: NavController, + currentDestination: NavDestination?, + isScrolled: Boolean, + onSearchClick: () -> Unit +) { + val liquidAnimationSpec = spring( + dampingRatio = 0.8f, + stiffness = Spring.StiffnessMedium + ) + + // Taller container for PixelPlayer style + val containerHeight by animateDpAsState(targetValue = 72.dp, animationSpec = liquidAnimationSpec, label = "containerHeight") + // Larger icons for Expressive look + val iconSize by animateDpAsState(targetValue = 32.dp, animationSpec = liquidAnimationSpec, label = "iconSize") + val cardPadding by animateDpAsState(if (isScrolled) 16.dp else 24.dp, liquidAnimationSpec, label = "cardPadding") + + // PixelPlayer uses very rounded shapes (pill-like) + val navCardShape = RoundedCornerShape(percent = 50) + + val cardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.95f) + ) + // No border or subtle border + val cardBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + val cardElevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = cardPadding) + .height(containerHeight + 16.dp), // Extra space for float effect + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + // Main Floating Island Card + Card( + modifier = Modifier + .height(containerHeight) + .weight(1f), + shape = navCardShape, + colors = cardColors, + border = cardBorder, + elevation = cardElevation + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + navItems.forEach { navItem -> + val isSelected = currentDestination?.hierarchy?.any { it.route == navItem.route } == true + NavButton( + item = navItem, + isSelected = isSelected, + iconSize = iconSize, + onClick = { + if (!isSelected) { + navController.navigate(navItem.route) { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + } + ) + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Search Button - Floating Bubble + Card( + modifier = Modifier + .size(containerHeight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onSearchClick + ), + shape = CircleShape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + border = cardBorder, + elevation = cardElevation + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + painter = painterResource(id = R.drawable.search), + contentDescription = stringResource(R.string.search), + modifier = Modifier.size(iconSize), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } +} + +/** + * Composable representing each button in the navigation bar. + */ +@Composable +private fun NavButton( + item: NavItem, + isSelected: Boolean, + iconSize: Dp, + onClick: () -> Unit +) { + // PixelPlayer uses high contrast for selected items + val tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + + // Animate scale/size if needed, but simple color change is standard for M3 + Column( + modifier = Modifier + .fillMaxHeight() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = if (isSelected) item.activeIconResId else item.iconResId), + contentDescription = stringResource(id = item.labelResId), + modifier = Modifier.size(iconSize), + tint = tint + ) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/Lyrics.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/Lyrics.kt new file mode 100644 index 0000000..4bdd7b1 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/Lyrics.kt @@ -0,0 +1,395 @@ +package com.babelsoftware.loudly.ui.component + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Fullscreen +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.DarkModeKey +import com.babelsoftware.loudly.constants.LyricFontSizeKey +import com.babelsoftware.loudly.constants.LyricTrimKey +import com.babelsoftware.loudly.constants.LyricsTextPositionKey +import com.babelsoftware.loudly.constants.MultilineLrcKey +import com.babelsoftware.loudly.constants.PlayerBackgroundStyle +import com.babelsoftware.loudly.constants.PlayerBackgroundStyleKey +import com.babelsoftware.loudly.constants.PureBlackKey +import com.babelsoftware.loudly.constants.ShowLyricsKey +import com.babelsoftware.loudly.constants.TranslateLyricsKey +import com.babelsoftware.loudly.constants.fullScreenLyricsKey +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.lyrics.LyricsEntry +import com.babelsoftware.loudly.lyrics.LyricsUtils +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.component.shimmer.TextPlaceholder +import com.babelsoftware.loudly.ui.menu.LyricsMenu +import com.babelsoftware.loudly.ui.screens.settings.DarkMode +import com.babelsoftware.loudly.ui.screens.settings.LyricsPosition +import com.babelsoftware.loudly.ui.utils.fadingEdge +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.BuildConfig +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.time.Duration.Companion.seconds + +@Composable +fun Lyrics( + sliderPositionProvider: () -> Long?, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val menuState = LocalMenuState.current + val density = LocalDensity.current + var showLyrics by rememberPreference(ShowLyricsKey, false) + val landscapeOffset = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val lyricsFontSize by rememberPreference(LyricFontSizeKey, 20) + + val lyricsTextPosition by rememberEnumPreference(LyricsTextPositionKey, LyricsPosition.CENTER) + + var fullScreenLyrics by rememberPreference(fullScreenLyricsKey, defaultValue = false) + + var translationEnabled by rememberPreference(TranslateLyricsKey, false) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) + val translating by playerConnection.translating.collectAsState() + val lyrics = remember(lyricsEntity, translating) { + if (translating) null + else lyricsEntity?.lyrics + } + val multilineLrc = rememberPreference(MultilineLrcKey, defaultValue = true) + val lyricTrim = rememberPreference(LyricTrimKey, defaultValue = false) + + val playerBackground by rememberEnumPreference( + key = PlayerBackgroundStyleKey, + defaultValue = PlayerBackgroundStyle.DEFAULT + ) + + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + + val lines = remember(lyrics) { + if (lyrics == null || lyrics == LyricsEntity.Companion.LYRICS_NOT_FOUND) emptyList() + else if (lyrics.startsWith("[")) listOf(LyricsEntry.Companion.HEAD_LYRICS_ENTRY) + + LyricsUtils.parseLyrics(lyrics, lyricTrim.value, multilineLrc.value) + else lyrics.lines().mapIndexed { index, line -> LyricsEntry(index * 100L, line) } + } + val isSynced = remember(lyrics) { + !lyrics.isNullOrEmpty() && lyrics.startsWith("[") + } + + val textColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.secondary + else -> + if (useDarkTheme) + MaterialTheme.colorScheme.onSurface + else + if (pureBlack && darkTheme == DarkMode.ON && isSystemInDarkTheme) + Color.White + else + MaterialTheme.colorScheme.onPrimary + } + + var currentLineIndex by remember { + mutableIntStateOf(-1) + } + + var deferredCurrentLineIndex by rememberSaveable { + mutableIntStateOf(0) + } + + var lastPreviewTime by rememberSaveable { + mutableLongStateOf(0L) + } + var isSeeking by remember { + mutableStateOf(false) + } + + LaunchedEffect(lyrics) { + if (lyrics.isNullOrEmpty() || !lyrics.startsWith("[")) { + currentLineIndex = -1 + return@LaunchedEffect + } + while (isActive) { + delay(50) + val sliderPosition = sliderPositionProvider() + isSeeking = sliderPosition != null + currentLineIndex = LyricsUtils.findCurrentLineIndex( + lines, + sliderPosition ?: playerConnection.player.currentPosition + ) + } + } + + LaunchedEffect(isSeeking, lastPreviewTime) { + if (isSeeking) { + lastPreviewTime = 0L + } else if (lastPreviewTime != 0L) { + delay(LyricsPreviewTime) + lastPreviewTime = 0L + } + } + + val lazyListState = rememberLazyListState() + + LaunchedEffect(currentLineIndex, lastPreviewTime) { + /** + * Count number of new lines in a lyric + */ + fun countNewLine(str: String) = str.count { it == '\n' } + + /** + * Calculate the lyric offset Based on how many lines (\n chars) + */ + fun calculateOffset() = with(density) { + if (landscapeOffset) { + 16.dp.toPx().toInt() * countNewLine(lines[currentLineIndex].text) + } else { + 20.dp.toPx().toInt() * countNewLine(lines[currentLineIndex].text) + } + } + + if (!isSynced) return@LaunchedEffect + if (currentLineIndex != -1) { + deferredCurrentLineIndex = currentLineIndex + if (lastPreviewTime == 0L) { + if (isSeeking) { + lazyListState.scrollToItem(currentLineIndex, + with(density) { 36.dp.toPx().toInt() } + calculateOffset()) + } else { + lazyListState.animateScrollToItem(currentLineIndex, + with(density) { 36.dp.toPx().toInt() } + calculateOffset()) + } + } + } + } + + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxSize() + .padding(bottom = 12.dp) + ) { + LazyColumn( + state = lazyListState, + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .add(WindowInsets(top = maxHeight / 2, bottom = maxHeight / 2)) + .asPaddingValues(), + modifier = Modifier.Companion + .fadingEdge(vertical = 64.dp) + .nestedScroll(remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + lastPreviewTime = System.currentTimeMillis() + return super.onPostScroll(consumed, available, source) + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + lastPreviewTime = System.currentTimeMillis() + return super.onPostFling(consumed, available) + } + } + }) + ) { + val displayedCurrentLineIndex = if (isSeeking) deferredCurrentLineIndex else currentLineIndex + + if (lyrics == null) { + item { + ShimmerHost { + repeat(10) { + Box( + contentAlignment = when (lyricsTextPosition) { + LyricsPosition.LEFT -> Alignment.CenterStart + LyricsPosition.CENTER -> Alignment.Center + LyricsPosition.RIGHT -> Alignment.CenterEnd + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + ) { + TextPlaceholder() + } + } + } + } + } else { + itemsIndexed( + items = lines + ) { index, item -> + Text( + text = item.text, + fontSize = lyricsFontSize.sp, + color = textColor, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isSynced) { + playerConnection.player.seekTo(item.time) + lastPreviewTime = 0L + } + .padding(horizontal = 24.dp, vertical = 8.dp) + .alpha(if (!isSynced || index == displayedCurrentLineIndex) 1f else 0.5f) + ) + } + } + } + + if (lyrics == LyricsEntity.Companion.LYRICS_NOT_FOUND) { + Text( + text = stringResource(R.string.lyrics_not_found), + fontSize = 20.sp, + color = textColor, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .alpha(0.5f) + ) + } + + mediaMetadata?.let { mediaMetadata -> + Row( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 12.dp) + ) { + IconButton( + onClick = { + showLyrics = false + fullScreenLyrics = true + } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + tint = textColor + ) + } + if (BuildConfig.FLAVOR != "foss") { + IconButton( + onClick = { + translationEnabled = !translationEnabled + } + ) { + Icon( + painter = painterResource(id = R.drawable.translate), + contentDescription = null, + tint = textColor.copy(alpha = if (translationEnabled) 1f else 0.3f) + ) + } + } + if (fullScreenLyrics) { + IconButton( + onClick = { fullScreenLyrics = false } + ) { + Icon( + imageVector = Icons.Rounded.Fullscreen, + contentDescription = null, + tint = textColor + ) + } + } else { + IconButton( + onClick = { fullScreenLyrics = true } + ) { + Icon( + imageVector = Icons.Rounded.Fullscreen, + contentDescription = null, + tint = textColor + ) + } + } + IconButton( + onClick = { + menuState.show { + LyricsMenu( + lyricsProvider = { lyricsEntity }, + mediaMetadataProvider = { mediaMetadata }, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painterResource(id = R.drawable.more_horiz), + contentDescription = null, + tint = textColor + ) + } + } + } + } +} + +const val animateScrollDuration = 300L +val LyricsPreviewTime = 4.seconds \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTile.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTile.kt new file mode 100644 index 0000000..4ba1df4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTile.kt @@ -0,0 +1,56 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun NavigationTile( + title: String, + @DrawableRes icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.padding(6.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable(onClick = onClick) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTitle.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTitle.kt new file mode 100644 index 0000000..cbab1c4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/NavigationTitle.kt @@ -0,0 +1,77 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.R + +@Composable +fun NavigationTitle( + title: String, + modifier: Modifier = Modifier, + label: String? = null, + thumbnail: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .clickable(enabled = onClick != null) { + onClick?.invoke() + } + .padding(horizontal = 12.dp, vertical = 16.dp) + ) { + thumbnail?.invoke() + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + label?.let { label -> + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + + if (onClick != null) { + Icon( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/PlayerSlider.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/PlayerSlider.kt new file mode 100644 index 0000000..309f55b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/PlayerSlider.kt @@ -0,0 +1,117 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.lerp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerSliderTrack( + sliderState: SliderState, + modifier: Modifier = Modifier, + colors: SliderColors = SliderDefaults.colors(), + trackHeight: Dp = 10.dp +) { + val inactiveTrackColor = colors.inactiveTrackColor + val activeTrackColor = colors.activeTrackColor + val inactiveTickColor = colors.inactiveTickColor + val activeTickColor = colors.activeTickColor + + val valueRange = sliderState.valueRange + + Canvas( + modifier + .fillMaxWidth() + .height(trackHeight) + ) { + drawTrack( + stepsToTickFractions(sliderState.steps), + 0f, + calcFraction( + valueRange.start, + valueRange.endInclusive, + sliderState.value.coerceIn(valueRange.start, valueRange.endInclusive) + ), + inactiveTrackColor, + activeTrackColor, + inactiveTickColor, + activeTickColor, + trackHeight + ) + } +} + +private fun DrawScope.drawTrack( + tickFractions: FloatArray, + activeRangeStart: Float, + activeRangeEnd: Float, + inactiveTrackColor: Color, + activeTrackColor: Color, + inactiveTickColor: Color, + activeTickColor: Color, + trackHeight: Dp = 2.dp +) { + val isRtl = layoutDirection == LayoutDirection.Rtl + val sliderLeft = Offset(0f, center.y) + val sliderRight = Offset(size.width, center.y) + val sliderStart = if (isRtl) sliderRight else sliderLeft + val sliderEnd = if (isRtl) sliderLeft else sliderRight + val tickSize = 2.0.dp.toPx() + val trackStrokeWidth = trackHeight.toPx() + drawLine( + inactiveTrackColor, + sliderStart, + sliderEnd, + trackStrokeWidth, + StrokeCap.Round + ) + val sliderValueEnd = Offset( + sliderStart.x + + (sliderEnd.x - sliderStart.x) * activeRangeEnd, + center.y + ) + + val sliderValueStart = Offset( + sliderStart.x + + (sliderEnd.x - sliderStart.x) * activeRangeStart, + center.y + ) + + drawLine( + activeTrackColor, + sliderValueStart, + sliderValueEnd, + trackStrokeWidth, + StrokeCap.Round + ) + + for (tick in tickFractions) { + val outsideFraction = tick > activeRangeEnd || tick < activeRangeStart + drawCircle( + color = if (outsideFraction) inactiveTickColor else activeTickColor, + center = Offset(lerp(sliderStart, sliderEnd, tick).x, center.y), + radius = tickSize / 2f + ) + } +} + +private fun stepsToTickFractions(steps: Int): FloatArray { + return if (steps == 0) floatArrayOf() else FloatArray(steps + 2) { it.toFloat() / (steps + 1) } +} + +private fun calcFraction(a: Float, b: Float, pos: Float) = + (if (b - a == 0f) 0f else (pos - a) / (b - a)).coerceIn(0f, 1f) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/PlayingIndicator.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/PlayingIndicator.kt new file mode 100644 index 0000000..aadb807 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/PlayingIndicator.kt @@ -0,0 +1,112 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.random.Random + + +@Composable +fun PlayingIndicator( + color: Color, + modifier: Modifier = Modifier, + bars: Int = 3, + barWidth: Dp = 4.dp, + cornerRadius: Dp = ThumbnailCornerRadius, +) { + val animatables = remember { + List(bars) { + Animatable(0.1f) + } + } + + LaunchedEffect(Unit) { + delay(300) + animatables.forEach { animatable -> + launch { + while (true) { + animatable.animateTo(Random.nextFloat() * 0.9f + 0.1f) + delay(50) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Bottom, + modifier = modifier + ) { + animatables.forEach { animatable -> + Canvas( + modifier = Modifier + .fillMaxHeight() + .width(barWidth) + ) { + drawRoundRect( + color = color, + topLeft = Offset(x = 0f, y = size.height * (1 - animatable.value)), + size = size.copy(height = animatable.value * size.height), + cornerRadius = CornerRadius(cornerRadius.toPx()) + ) + } + } + } +} + +@Composable +fun PlayingIndicatorBox( + modifier: Modifier = Modifier, + isActive: Boolean, + playWhenReady: Boolean, + color: Color = Color.White, +) { + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + ) { + if (playWhenReady) { + PlayingIndicator( + color = color, + modifier = Modifier.height(24.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = color + ) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/Preference.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/Preference.kt new file mode 100644 index 0000000..912eb41 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/Preference.kt @@ -0,0 +1,257 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp + +@Composable +fun PreferenceEntry( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + description: String? = null, + content: (@Composable () -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, + isEnabled: Boolean = true, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + shape = MaterialTheme.shapes.medium + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = isEnabled && onClick != null, + onClick = onClick ?: {} + ) + .alpha(if (isEnabled) 1f else 0.5f) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + if (icon != null) { + Box( + modifier = Modifier.padding(horizontal = 4.dp) + ) { + icon() + } + + Spacer(Modifier.width(12.dp)) + } + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + title() + } + + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + + content?.invoke() + } + + if (trailingContent != null) { + Spacer(Modifier.width(12.dp)) + trailingContent() + } + } + } +} + +@Composable +fun ListPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + icon: (@Composable () -> Unit)? = null, + selectedValue: T, + values: List, + valueText: @Composable (T) -> String, + onValueSelected: (T) -> Unit, + isEnabled: Boolean = true, +) { + var showDialog by remember { + mutableStateOf(false) + } + if (showDialog) { + ListDialog( + onDismiss = { showDialog = false } + ) { + items(values) { value -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + shape = MaterialTheme.shapes.medium + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + showDialog = false + onValueSelected(value) + } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + RadioButton( + selected = value == selectedValue, + onClick = null + ) + + Text( + text = valueText(value), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } + } + + PreferenceEntry( + modifier = modifier, + title = title, + description = valueText(selectedValue), + icon = icon, + onClick = { showDialog = true }, + isEnabled = isEnabled + ) +} + +@Composable +inline fun > EnumListPreference( + modifier: Modifier = Modifier, + noinline title: @Composable () -> Unit, + noinline icon: (@Composable () -> Unit)?, + selectedValue: T, + noinline valueText: @Composable (T) -> String, + noinline onValueSelected: (T) -> Unit, + isEnabled: Boolean = true, +) { + ListPreference( + modifier = modifier, + title = title, + icon = icon, + selectedValue = selectedValue, + values = enumValues().toList(), + valueText = valueText, + onValueSelected = onValueSelected, + isEnabled = isEnabled + ) +} + +@Composable +fun SwitchPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + description: String? = null, + icon: (@Composable () -> Unit)? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true, +) { + PreferenceEntry( + modifier = modifier, + title = title, + description = description, + icon = icon, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + }, + onClick = { onCheckedChange(!checked) }, + isEnabled = isEnabled + ) +} + +@Composable +fun EditTextPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + icon: (@Composable () -> Unit)? = null, + value: String, + onValueChange: (String) -> Unit, + singleLine: Boolean = true, + isInputValid: (String) -> Boolean = { it.isNotEmpty() }, + isEnabled: Boolean = true, +) { + var showDialog by remember { + mutableStateOf(false) + } + + if (showDialog) { + TextFieldDialog( + initialTextFieldValue = TextFieldValue( + text = value, + selection = TextRange(value.length) + ), + singleLine = singleLine, + isInputValid = isInputValid, + onDone = onValueChange, + onDismiss = { showDialog = false } + ) + } + + PreferenceEntry( + modifier = modifier, + title = title, + description = value, + icon = icon, + onClick = { showDialog = true }, + isEnabled = isEnabled + ) +} + +@Composable +fun PreferenceGroupTitle( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(16.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/SearchBar.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/SearchBar.kt new file mode 100644 index 0000000..47c76eb --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/SearchBar.kt @@ -0,0 +1,348 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", "DEPRECATION") + +package com.babelsoftware.loudly.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.SearchBarDefaults.TonalElevation +import androidx.compose.material3.Surface +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.contentColorFor +import androidx.compose.material3.internal.Strings +import androidx.compose.material3.internal.getString +import androidx.compose.material3.tokens.MotionTokens +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.util.lerp +import com.babelsoftware.loudly.constants.AppBarHeight +import kotlin.math.max +import kotlin.math.roundToInt + +@ExperimentalMaterial3Api +@Composable +fun TopSearch( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + shape: Shape = SearchBarDefaults.inputFieldShape, + colors: SearchBarColors = + SearchBarDefaults.colors( + containerColor = + MaterialTheme.colorScheme.surfaceContainer), + tonalElevation: Dp = TonalElevation, + windowInsets: WindowInsets = WindowInsets.systemBars, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + focusRequester: FocusRequester = remember { FocusRequester() }, + content: @Composable ColumnScope.() -> Unit, +) { + val heightOffsetLimit = + with(LocalDensity.current) { + -(AppBarHeight.toPx() + WindowInsets.systemBars.getTop(this)) + } + SideEffect { + if (scrollBehavior.state.heightOffsetLimit != heightOffsetLimit) { + scrollBehavior.state.heightOffsetLimit = heightOffsetLimit + } + } + + val animationProgress: Float by animateFloatAsState( + targetValue = if (active) 1f else 0f, + animationSpec = + tween( + durationMillis = AnimationDurationMillis, + easing = MotionTokens.EasingLegacyCubicBezier, + ), + label = "", + ) + + val defaultInputFieldShape = SearchBarDefaults.inputFieldShape + val defaultFullScreenShape = SearchBarDefaults.fullScreenShape + val animatedShape by remember { + derivedStateOf { + when { + shape == defaultInputFieldShape -> { + val animatedRadius = SearchBarCornerRadius * (1 - animationProgress) + RoundedCornerShape(CornerSize(animatedRadius)) + } + + animationProgress == 1f -> defaultFullScreenShape + else -> shape + } + } + } + + val topInset = windowInsets.asPaddingValues().calculateTopPadding() + val startInset = windowInsets.asPaddingValues().calculateStartPadding(LocalLayoutDirection.current) + val endInset = windowInsets.asPaddingValues().calculateEndPadding(LocalLayoutDirection.current) + + val topPadding = SearchBarVerticalPadding + topInset + val animatedSurfaceTopPadding = lerp(topPadding, 0.dp, animationProgress) + val animatedInputFieldPadding by remember { + derivedStateOf { + PaddingValues( + start = startInset * animationProgress, + top = topPadding * animationProgress, + end = endInset * animationProgress, + bottom = SearchBarVerticalPadding * animationProgress, + ) + } + } + + BoxWithConstraints( + modifier = + modifier + .offset { + IntOffset(x = 0, y = scrollBehavior.state.heightOffset.roundToInt()) + }, + propagateMinConstraints = true, + ) { + val height: Dp + val width: Dp + val startPadding: Dp + val endPadding: Dp + with(LocalDensity.current) { + val startWidth = constraints.maxWidth.toFloat() + val startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx()) + .coerceAtMost(constraints.maxHeight) + .toFloat() + val endWidth = constraints.maxWidth.toFloat() + val endHeight = constraints.maxHeight.toFloat() + + height = lerp(startHeight, endHeight, animationProgress).toDp() + width = lerp(startWidth, endWidth, animationProgress).toDp() + startPadding = lerp((SearchBarHorizontalPadding + startInset).roundToPx().toFloat(), 0f, animationProgress).toDp() + endPadding = lerp((SearchBarHorizontalPadding + endInset).roundToPx().toFloat(), 0f, animationProgress).toDp() + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .background( + color = MaterialTheme.colorScheme.surface + ) + ) + + Surface( + shape = animatedShape, + color = colors.containerColor, + contentColor = contentColorFor(colors.containerColor), + tonalElevation = tonalElevation, + modifier = + Modifier + .padding( + top = animatedSurfaceTopPadding, + start = startPadding, + end = endPadding, + ).size(width = width, height = height), + ) { + Column { + SearchBarInputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + modifier = Modifier.padding(animatedInputFieldPadding), + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = colors.inputFieldColors, + interactionSource = interactionSource, + focusRequester = focusRequester, + ) + + if (animationProgress > 0) { + Column(Modifier.alpha(animationProgress)) { + HorizontalDivider(color = colors.dividerColor) + content() + } + } + } + } + } + + BackHandler(enabled = active) { + onActiveChange(false) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchBarInputField( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + colors: TextFieldColors = SearchBarDefaults.inputFieldColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val focused = interactionSource.collectIsFocusedAsState().value + + val searchSemantics = getString(Strings.SearchBarSearch) + val suggestionsAvailableSemantics = getString(Strings.SuggestionsAvailable) + val textColor = LocalTextStyle.current.color.takeOrElse { + colors.textColor(enabled, isError = false, focused = focused) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(InputFieldHeight) + ) { + if (leadingIcon != null) { + Spacer(Modifier.width(SearchBarIconOffsetX)) + leadingIcon() + } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + onActiveChange(true) + } + } + } + .semantics { + contentDescription = searchSemantics + if (active) { + stateDescription = suggestionsAvailableSemantics + } + } + .onKeyEvent { + if (it.key == Key.Enter) { + onSearch(query.text) + return@onKeyEvent true + } + false + }, + enabled = enabled, + singleLine = true, + textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)), + cursorBrush = SolidColor(colors.cursorColor(isError = false)), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch(query.text) }), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = query.text, + innerTextField = innerTextField, + enabled = enabled, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = placeholder, + shape = SearchBarDefaults.inputFieldShape, + colors = colors, + contentPadding = PaddingValues(), + container = {}, + ) + } + ) + + if (trailingIcon != null) { + trailingIcon() + Spacer(Modifier.width(SearchBarIconOffsetX)) + } + } +} + +val InputFieldHeight = 48.dp +private val SearchBarCornerRadius: Dp = InputFieldHeight / 2 +internal val SearchBarVerticalPadding: Dp = 8.dp +internal val SearchBarHorizontalPadding: Dp = 12.dp + +val SearchBarIconOffsetX: Dp = 4.dp + +private const val AnimationDurationMillis: Int = MotionTokens.DurationMedium2.toInt() diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/SortHeader.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/SortHeader.kt new file mode 100644 index 0000000..6c131d0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/SortHeader.kt @@ -0,0 +1,99 @@ +package com.babelsoftware.loudly.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.babelsoftware.loudly.constants.PlaylistSongSortType +import com.babelsoftware.loudly.R + +@Composable +inline fun > SortHeader( + sortType: T, + sortDescending: Boolean, + crossinline onSortTypeChange: (T) -> Unit, + crossinline onSortDescendingChange: (Boolean) -> Unit, + crossinline sortTypeText: (T) -> Int, + modifier: Modifier = Modifier, + showDescending: Boolean? = true, +) { + var menuExpanded by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(vertical = 8.dp) + ) { + Text( + text = stringResource(sortTypeText(sortType)), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false) + ) { + menuExpanded = !menuExpanded + } + .padding(horizontal = 4.dp, vertical = 8.dp) + ) + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.widthIn(min = 172.dp) + ) { + enumValues().forEach { type -> + DropdownMenuItem( + text = { + Text( + text = stringResource(sortTypeText(type)), + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + }, + trailingIcon = { + Icon( + painter = painterResource(if (sortType == type) R.drawable.radio_button_checked else R.drawable.radio_button_unchecked), + contentDescription = null + ) + }, + onClick = { + onSortTypeChange(type) + menuExpanded = false + } + ) + } + } + + if (sortType != PlaylistSongSortType.CUSTOM && showDescending == true) { + ResizableIconButton( + icon = if (sortDescending) R.drawable.arrow_downward else R.drawable.arrow_upward, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(32.dp) + .padding(8.dp), + onClick = { onSortDescendingChange(!sortDescending) }, + ) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ButtonPlaceholder.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ButtonPlaceholder.kt new file mode 100644 index 0000000..4ee685f --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ButtonPlaceholder.kt @@ -0,0 +1,21 @@ +package com.babelsoftware.loudly.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip + +@Composable +fun ButtonPlaceholder( + modifier: Modifier = Modifier, +) { + Spacer(modifier + .height(ButtonDefaults.MinHeight) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurface)) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/GridItemPlaceholder.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/GridItemPlaceholder.kt new file mode 100644 index 0000000..836f084 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/GridItemPlaceholder.kt @@ -0,0 +1,49 @@ +package com.babelsoftware.loudly.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius + +@Composable +fun GridItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth: Boolean = false, +) { + Column( + modifier = if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight) + } + ) { + Spacer( + modifier = if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.Companion.height(GridThumbnailHeight) + } + .aspectRatio(1f) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + TextPlaceholder() + + TextPlaceholder() + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ListItemPlaceholder.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ListItemPlaceholder.kt new file mode 100644 index 0000000..ef6acd1 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ListItemPlaceholder.kt @@ -0,0 +1,45 @@ +package com.babelsoftware.loudly.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.ListThumbnailSize +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius + +@Composable +fun ListItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(ListItemHeight) + .padding(horizontal = 6.dp), + ) { + Spacer( + modifier = Modifier + .padding(6.dp) + .size(ListThumbnailSize) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + TextPlaceholder() + TextPlaceholder() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ShimmerHost.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ShimmerHost.kt new file mode 100644 index 0000000..fd17bf2 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/ShimmerHost.kt @@ -0,0 +1,59 @@ +package com.babelsoftware.loudly.ui.component.shimmer + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import com.valentinilk.shimmer.defaultShimmerTheme +import com.valentinilk.shimmer.shimmer + +@Composable +fun ShimmerHost( + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + modifier = modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), + blendMode = BlendMode.DstIn + ) + }, + content = content + ) +} + +val ShimmerTheme = defaultShimmerTheme.copy( + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 800, + easing = LinearEasing, + delayMillis = 250, + ), + repeatMode = RepeatMode.Restart + ), + shaderColors = listOf( + Color.Unspecified.copy(alpha = 0.25f), + Color.Unspecified.copy(alpha = 0.50f), + Color.Unspecified.copy(alpha = 0.25f), + ), +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/TextPlaceholder.kt b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/TextPlaceholder.kt new file mode 100644 index 0000000..f29ad8a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/component/shimmer/TextPlaceholder.kt @@ -0,0 +1,28 @@ +package com.babelsoftware.loudly.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.random.Random + +@Composable +fun TextPlaceholder( + modifier: Modifier = Modifier, + height: Dp = 16.dp, +) { + Spacer( + modifier = modifier + .padding(vertical = 4.dp) + .background(MaterialTheme.colorScheme.onSurface) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + .height(height) + ) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/AddToPlaylistDialog.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/AddToPlaylistDialog.kt new file mode 100644 index 0000000..d574a93 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/AddToPlaylistDialog.kt @@ -0,0 +1,264 @@ +package com.babelsoftware.loudly.ui.menu + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.ListThumbnailSize +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.ListItem +import com.babelsoftware.loudly.ui.component.PlaylistListItem +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.R +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.utils.parseCookieString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Composable +fun AddToPlaylistDialog( + navController: NavController, + isVisible: Boolean, + initialTextFieldValue: String? = null, + noSyncing: Boolean = false, + onGetSong: suspend (Playlist) -> List, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + var playlists by remember { + mutableStateOf(emptyList()) + } + val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + var showAddPlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var syncedPlaylist: Boolean by remember { + mutableStateOf(false) + } + + var showDuplicateDialog by remember { + mutableStateOf(false) + } + var selectedPlaylist by remember { + mutableStateOf(null) + } + var songIds by remember { + mutableStateOf?>(null) + } + var duplicates by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.editablePlaylistsByCreateDateAsc().collect { + playlists = it.asReversed() + } + } + + if (isVisible) { + ListDialog( + onDismiss = onDismiss + ) { + item { + ListItem( + title = stringResource(R.string.create_playlist), + thumbnailContent = { + Image( + imageVector = Icons.Rounded.Add, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.Companion.size(ListThumbnailSize) + ) + }, + modifier = Modifier.clickable { + showAddPlaylistDialog = true + } + ) + } + + items(playlists) { playlist -> + PlaylistListItem( + playlist = playlist, + thumbnail = Icons.AutoMirrored.Rounded.QueueMusic, + modifier = Modifier.clickable { + selectedPlaylist = playlist + coroutineScope.launch(Dispatchers.IO) { + if (songIds == null) { + songIds = onGetSong(playlist) + } + duplicates = database.playlistDuplicates(playlist.id, songIds!!) + if (duplicates.isNotEmpty()) { + showDuplicateDialog = true + } else { + onDismiss() + database.addSongToPlaylist(playlist, songIds!!) + } + } + } + ) + } + } + } + + if (showAddPlaylistDialog) { + TextFieldDialog( + icon = { Icon(imageVector = Icons.Rounded.Add, contentDescription = null) }, + title = { Text(text = stringResource(R.string.create_playlist)) }, + initialTextFieldValue = TextFieldValue(initialTextFieldValue ?: ""), + onDismiss = { showAddPlaylistDialog = false }, + onDone = { playlistName -> + coroutineScope.launch(Dispatchers.IO) { + val browseId = if (syncedPlaylist) + YouTube.createPlaylist(playlistName) + else null + + database.query { + insert( + PlaylistEntity( + name = playlistName, + browseId = browseId, + bookmarkedAt = LocalDateTime.now(), + isEditable = true, + isLocal = !syncedPlaylist + ) + ) + } + } + }, + extraContent = { + if (isLoggedIn && isInternetAvailable(context)) { + Row( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 40.dp) + ) { + Column { + Text( + text = stringResource(R.string.sync_playlist), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.allows_for_sync_witch_youtube), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(0.7f) + ) + } + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End + ) { + if (isInternetAvailable(context)) { + Switch( + enabled = !noSyncing, + checked = syncedPlaylist, + onCheckedChange = { + syncedPlaylist = !syncedPlaylist + }, + ) + } + } + } + } + } + ) + } + + if (showDuplicateDialog) { + DefaultDialog( + title = { Text(stringResource(R.string.duplicates)) }, + buttons = { + TextButton( + onClick = { + showDuplicateDialog = false + onDismiss() + database.transaction { + addSongToPlaylist( + selectedPlaylist!!, + songIds!!.filter { + !duplicates.contains(it) + } + ) + } + } + ) { + Text(stringResource(R.string.skip_duplicates)) + } + + TextButton( + onClick = { + showDuplicateDialog = false + onDismiss() + database.transaction { + addSongToPlaylist(selectedPlaylist!!, songIds!!) + } + } + ) { + Text(stringResource(R.string.add_anyway)) + } + + TextButton( + onClick = { + showDuplicateDialog = false + } + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + onDismiss = { + showDuplicateDialog = false + } + ) { + Text( + text = if (duplicates.size == 1) { + stringResource(R.string.duplicates_description_single) + } else { + stringResource(R.string.duplicates_description_multiple, duplicates.size) + }, + textAlign = TextAlign.Start, + modifier = Modifier.align(Alignment.Start) + ) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/AlbumMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/AlbumMenu.kt new file mode 100644 index 0000000..de0172d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/AlbumMenu.kt @@ -0,0 +1,451 @@ +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import androidx.media3.exoplayer.offline.Download.STATE_STOPPED +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.ListThumbnailSize +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.ui.component.AlbumListItem +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Composable +fun AlbumMenu( + originalAlbum: Album, + navController: NavController, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val scope = rememberCoroutineScope() + val libraryAlbum by database.album(originalAlbum.id).collectAsState(initial = originalAlbum) + val album = libraryAlbum ?: originalAlbum + var songs by remember { + mutableStateOf(emptyList()) + } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableIntStateOf(STATE_STOPPED) + } + + val allInLibrary = remember(songs) { + songs.all { it.song.inLibrary != null } + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) + STATE_COMPLETED + else if (songs.all { + downloads[it.id]?.state == STATE_QUEUED + || downloads[it.id]?.state == STATE_DOWNLOADING + || downloads[it.id]?.state == STATE_COMPLETED + }) + STATE_DOWNLOADING + else + STATE_STOPPED + } + } + + var refetchIconDegree by remember { mutableFloatStateOf(0f) } + + val rotationAnimation by animateFloatAsState( + targetValue = refetchIconDegree, + animationSpec = tween(durationMillis = 800), + label = "" + ) + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { playlistId -> + album.album.playlistId?.let { addPlaylistId -> + YouTube.addPlaylistToPlaylist(playlistId, addPlaylistId) + } + } + } + songs.map { it.id } + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items( + items = album.artists, + key = { it.id } + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.Companion + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(CircleShape) + ) + } + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + } + } + } + } + + AlbumListItem( + album = album, + showLikedIcon = false, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + update(album.album.toggleLike()) + } + } + ) { + Icon( + painter = painterResource(if (album.album.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (album.album.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_play), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.play_next), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + showChoosePlaylistDialog = true + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_playlist), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/browse/${album.album.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + onDismiss() + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + + if (allInLibrary) { + ListMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_all_from_library + ) { + database.transaction { + songs.forEach { + inLibrary(it.id, null) + } + } + } + } else { + ListMenuItem( + icon = R.drawable.library_add, + title = R.string.add_all_to_library + ) { + database.transaction { + songs.forEach { + inLibrary(it.id, LocalDateTime.now()) + } + } + } + } + + DownloadListMenu( + state = downloadState, + onDownload = { + songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) + ListMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (album.artists.size == 1) { + navController.navigate("artist/${album.artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + ListMenuItem( + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation) + ) + }, + title = R.string.refetch + ) { + refetchIconDegree -= 360 + scope.launch(Dispatchers.IO) { + YouTube.album(album.id).onSuccess { + database.transaction { + update(album.album, it) + } + } + } + } + ListMenuItem( + icon = R.drawable.music_note, + title = R.string.listen_youtube_music + ) { + val intent = Intent( + Intent.ACTION_VIEW, + "https://music.youtube.com/browse/${album.album.id}".toUri() + ) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/ArtistMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/ArtistMenu.kt new file mode 100644 index 0000000..9ade04b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/ArtistMenu.kt @@ -0,0 +1,223 @@ +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.ArtistSongSortType +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.ArtistListItem +import com.babelsoftware.loudly.ui.component.ListMenuItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.core.net.toUri +import com.babelsoftware.loudly.ui.component.ListMenu + +@Composable +fun ArtistMenu( + originalArtist: Artist, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val artistState = database.artist(originalArtist.id).collectAsState(initial = originalArtist) + val artist = artistState.value ?: originalArtist + + ArtistListItem(artist = artist, showLikedIcon = false, badges = {}, trailingContent = { + IconButton( + onClick = { + database.transaction { + update(artist.artist.toggleLike()) + } + }) { + Icon( + painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (artist.artist.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + }) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (artist.songCount > 0) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + coroutineScope.launch { + val songs = withContext(Dispatchers.IO) { + database.artistSongs( + artist.id, ArtistSongSortType.CREATE_DATE, true + ).first().map { it.toMediaItem() } + } + playerConnection.playQueue( + ListQueue( + title = artist.artist.name, items = songs + ) + ) + } + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.play), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + coroutineScope.launch { + val songs = withContext(Dispatchers.IO) { + database.artistSongs( + artist.id, ArtistSongSortType.CREATE_DATE, true + ).first().map { it.toMediaItem() }.shuffled() + } + playerConnection.playQueue( + ListQueue( + title = artist.artist.name, items = songs + ) + ) + } + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.shuffle), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + if (artist.artist.isYouTubeArtist) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, "https://music.youtube.com/channel/${artist.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.music_note, title = R.string.listen_youtube_music + ) { + val intent = Intent( + Intent.ACTION_VIEW, "https://music.youtube.com/channel/${artist.id}".toUri() + ) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/FolderMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/FolderMenu.kt new file mode 100644 index 0000000..efd3c88 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/FolderMenu.kt @@ -0,0 +1,97 @@ +package com.babelsoftware.loudly.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.db.entities.Event +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.SongFolderItem +import com.babelsoftware.loudly.ui.utils.DirectoryTree +import com.babelsoftware.loudly.R +import kotlin.collections.forEach + +@Composable +fun FolderMenu( + folder: DirectoryTree, + event: Event? = null, + navController: NavController, + onDismiss: () -> Unit, +) { + val playerConnection = LocalPlayerConnection.current ?: return + + val allFolderSongs = folder.toList() + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { + if (allFolderSongs.isEmpty()) return@AddToPlaylistDialog emptyList() + allFolderSongs.map { it.id } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + SongFolderItem( + folderTitle = folder.currentDir, + modifier = Modifier, + subtitle = pluralStringResource( + R.plurals.n_song, + folder.toList().size, + folder.toList().size + ), + ) + + HorizontalDivider() + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + onDismiss() + allFolderSongs.forEach { + playerConnection.playNext(it.toMediaItem()) + } + } + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + allFolderSongs.forEach { + playerConnection.addToQueue((it.toMediaItem())) + } + } + ListMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/LyricsMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/LyricsMenu.kt new file mode 100644 index 0000000..e82ac46 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/LyricsMenu.kt @@ -0,0 +1,374 @@ +package com.babelsoftware.loudly.ui.menu + +import android.app.SearchManager +import android.content.Intent +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.loudly.viewmodels.LyricsMenuViewModel +import com.babelsoftware.loudly.R + + +@Composable +fun LyricsMenu( + lyricsProvider: () -> LyricsEntity?, + mediaMetadataProvider: () -> MediaMetadata, + onDismiss: () -> Unit, + viewModel: LyricsMenuViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + onDismiss = { showEditDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = mediaMetadataProvider().title) }, + initialTextFieldValue = TextFieldValue(lyricsProvider()?.lyrics.orEmpty()), + singleLine = false, + onDone = { + database.query { + upsert( + LyricsEntity( + id = mediaMetadataProvider().id, + lyrics = it + ) + ) + } + } + ) + } + + var showSearchDialog by rememberSaveable { + mutableStateOf(false) + } + var showSearchResultDialog by rememberSaveable { + mutableStateOf(false) + } + + val searchMediaMetadata = remember(showSearchDialog) { + mediaMetadataProvider() + } + val (titleField, onTitleFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().title + ) + ) + } + val (artistField, onArtistFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().artists.joinToString { it.name } + ) + ) + } + + var showDeleteLyric by remember { + mutableStateOf(false) + } + + if (showDeleteLyric) { + DefaultDialog( + onDismiss = { showDeleteLyric = false }, + content = { + Text( + text = stringResource( + R.string.delete_lyric_confirm, + mediaMetadataProvider().title + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showDeleteLyric = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showDeleteLyric = false + onDismiss() + + lyricsProvider()?.let { + database.query { + delete(it) + } + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + if (showSearchDialog) { + DefaultDialog( + modifier = Modifier.verticalScroll(rememberScrollState()), + onDismiss = { showSearchDialog = false }, + icon = { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + }, + title = { Text(stringResource(R.string.search_lyrics)) }, + buttons = { + TextButton( + onClick = { showSearchDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + showSearchDialog = false + onDismiss() + try { + context.startActivity( + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra( + SearchManager.QUERY, + "${artistField.text} ${titleField.text} lyrics" + ) + } + ) + } catch (_: Exception) { + } + } + ) { + Text(stringResource(R.string.search_online)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + viewModel.search( + searchMediaMetadata.id, + titleField.text, + artistField.text, + searchMediaMetadata.duration + ) + showSearchResultDialog = true + } + ) { + Text(stringResource(android.R.string.ok)) + } + } + ) { + OutlinedTextField( + value = titleField, + onValueChange = onTitleFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_title)) } + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = artistField, + onValueChange = onArtistFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_artists)) } + ) + } + } + + if (showSearchResultDialog) { + val results by viewModel.results.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + var expandedItemIndex by rememberSaveable { + mutableIntStateOf(-1) + } + + ListDialog( + onDismiss = { showSearchResultDialog = false } + ) { + itemsIndexed(results) { index, result -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onDismiss() + viewModel.cancelSearch() + database.query { + upsert( + LyricsEntity( + id = searchMediaMetadata.id, + lyrics = result.lyrics + ) + ) + } + } + .padding(12.dp) + .animateContentSize() + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = result.lyrics, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (index == expandedItemIndex) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = result.providerName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1 + ) + if (result.lyrics.startsWith("[")) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .padding(start = 4.dp) + .size(18.dp) + ) + } + } + } + + IconButton( + onClick = { + expandedItemIndex = if (expandedItemIndex == index) -1 else index + } + ) { + Icon( + painter = painterResource(if (index == expandedItemIndex) R.drawable.expand_less else R.drawable.expand_more), + contentDescription = null + ) + } + } + } + + if (isLoading) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator() + } + } + } + + if (!isLoading && results.isEmpty()) { + item { + Text( + text = context.getString(R.string.lyrics_not_found), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + ) + } + } + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + ListMenuItem( + icon = R.drawable.cached, + title = R.string.refetch + ) { + onDismiss() + viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) + } + ListMenuItem( + icon = R.drawable.search, + title = R.string.search, + ) { + showSearchDialog = true + } + if (lyricsProvider() != null) { + ListMenuItem( + icon = R.drawable.delete, + title = R.string.delete, + ) { + showDeleteLyric = true + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/MediaMetadataMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/MediaMetadataMenu.kt new file mode 100644 index 0000000..1eb7f71 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/MediaMetadataMenu.kt @@ -0,0 +1,436 @@ +package com.babelsoftware.loudly.ui.menu + + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.ui.component.BottomSheetState +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.MediaMetadataListItem +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Composable +fun MediaMetadataMenu( + mediaMetadata: MediaMetadata, + navController: NavController, + bottomSheetState: BottomSheetState, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val librarySong by database.song(mediaMetadata.id).collectAsState(initial = null) + + val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id) + .collectAsState(initial = null) + + val artists = remember(mediaMetadata.artists) { + mediaMetadata.artists.filter { it.id != null } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + val coroutineScope = rememberCoroutineScope() + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { browseId -> + YouTube.addToPlaylist(browseId, mediaMetadata.id) + } + } + listOf(mediaMetadata.id) + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items(artists) { artist -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + bottomSheetState.collapseSoft() + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + + MediaMetadataListItem( + mediaMetadata = mediaMetadata, + badges = {}, + trailingContent = { + val song by database.song(mediaMetadata.id).collectAsState(initial = null) + + IconButton( + onClick = { + database.query { + val currentSong = song + if (currentSong == null) { + insert(mediaMetadata, SongEntity::toggleLike) + } else { + update(currentSong.song.toggleLike()) + } + } + } + ) { + Icon( + painter = painterResource(if (song?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border), + tint = if (song?.song?.liked == true) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (mediaMetadata.isLocal) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.playNext(mediaMetadata.toMediaItem()) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_play), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.play_next), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } else { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.service.startRadioSeamlessly() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.start_radio), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + showChoosePlaylistDialog = true + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_playlist), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + if (!mediaMetadata.isLocal) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/watch?v=${mediaMetadata.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } else { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.addToQueue((mediaMetadata.toMediaItem())) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_queue), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + if (!mediaMetadata.isLocal) { + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + onDismiss() + playerConnection.playNext(mediaMetadata.toMediaItem()) + } + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + playerConnection.addToQueue(mediaMetadata.toMediaItem()) + } + DownloadListMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(mediaMetadata) + } + val downloadRequest = + DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false + ) + } + ) + } + if (librarySong?.song?.inLibrary != null) { + ListMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library, + ) { + database.query { + inLibrary(mediaMetadata.id, null) + } + } + } else { + ListMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library, + ) { + database.transaction { + insert(mediaMetadata) + inLibrary(mediaMetadata.id, LocalDateTime.now()) + } + } + } + if (!mediaMetadata.isLocal && artists.isNotEmpty()) { + ListMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + bottomSheetState.collapseSoft() + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + if (!mediaMetadata.isLocal && mediaMetadata.album != null) { + ListMenuItem( + icon = R.drawable.album, + title = R.string.view_album + ) { + navController.navigate("album/${mediaMetadata.album.id}") + bottomSheetState.collapseSoft() + onDismiss() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/PlayerMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/PlayerMenu.kt new file mode 100644 index 0000000..df82001 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/PlayerMenu.kt @@ -0,0 +1,831 @@ +@file:Suppress("NAME_SHADOWING") + +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import android.media.audiofx.AudioEffect +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.VolumeDown +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.AddLink +import androidx.compose.material.icons.rounded.Album +import androidx.compose.material.icons.rounded.Download +import androidx.compose.material.icons.rounded.DownloadDone +import androidx.compose.material.icons.rounded.Equalizer +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PlaylistAdd +import androidx.compose.material.icons.rounded.Radio +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import androidx.media3.common.PlaybackParameters +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.SliderStyle +import com.babelsoftware.loudly.constants.SliderStyleKey +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.ui.component.BottomSheetState +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.PlayerSliderTrack +import com.babelsoftware.loudly.ui.screens.share.SocialShareScreen +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberEnumPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import me.saket.squiggles.SquigglySlider +import java.time.LocalDateTime +import kotlin.math.log2 +import kotlin.math.pow +import kotlin.math.round +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerMenu( + mediaMetadata: MediaMetadata?, + navController: NavController, + bottomSheetState: BottomSheetState, + onShowDetailsDialog: () -> Unit = {}, + onDismiss: () -> Unit, +) { + mediaMetadata ?: return + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val playerVolume = playerConnection.service.playerVolume.collectAsState() + val activityResultLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } + val librarySong by database.song(mediaMetadata.id).collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id) + .collectAsState(initial = null) + + val artists = remember(mediaMetadata.artists) { + mediaMetadata.artists.filter { it.id != null } + } + + val sleepTimerEnabled = remember( + playerConnection.service.sleepTimer.triggerTime, + playerConnection.service.sleepTimer.pauseWhenSongEnd + ) { + playerConnection.service.sleepTimer.isActive + } + + var sleepTimerTimeLeft by remember { + mutableLongStateOf(0L) + } + + LaunchedEffect(librarySong?.song?.liked) { + librarySong?.let { + downloadUtil.autoDownloadIfLiked(it.song) + } + } + + LaunchedEffect(sleepTimerEnabled) { + if (sleepTimerEnabled) { + while (isActive) { + sleepTimerTimeLeft = if (playerConnection.service.sleepTimer.pauseWhenSongEnd) { + playerConnection.player.duration - playerConnection.player.currentPosition + } else { + playerConnection.service.sleepTimer.triggerTime - System.currentTimeMillis() + } + delay(1000L) + } + } + } + + var showSocialShareDialog by remember { mutableStateOf(false) } + + if (showSocialShareDialog) { + SocialShareScreen( + mediaMetadata = mediaMetadata, + onDismiss = { showSocialShareDialog = false } + ) + } + + var showSleepTimerDialog by remember { + mutableStateOf(false) + } + + var sleepTimerValue by remember { + mutableFloatStateOf(30f) + } + + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.DEFAULT) + + if (showSleepTimerDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showSleepTimerDialog = false }, + icon = { + Icon( + painter = painterResource(R.drawable.bedtime), + contentDescription = null + ) + }, + title = { Text(stringResource(R.string.sleep_timer)) }, + confirmButton = { + TextButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.sleepTimer.start(sleepTimerValue.roundToInt()) + } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { showSleepTimerDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pluralStringResource( + R.plurals.minute, + sleepTimerValue.roundToInt(), + sleepTimerValue.roundToInt() + ), + style = MaterialTheme.typography.bodyLarge + ) + + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + steps = (120 - 5) / 5 - 1, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = SliderDefaults.colors() + ) + }, + ) + } + + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + ) + } + + SliderStyle.COMPOSE -> { + Slider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + steps = (120 - 5) / 5 - 1, + ) + } + } + + OutlinedButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.sleepTimer.start(-1) + } + ) { + Text(stringResource(R.string.end_of_song)) + } + } + } + ) + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + database.transaction { + insert(mediaMetadata) + } + + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { YouTube.addToPlaylist(it, mediaMetadata.id) } + } + + listOf(mediaMetadata.id) + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items(artists) { artist -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + bottomSheetState.collapseSoft() + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + var showTempoPitchDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showTempoPitchDialog) { + TempoPitchDialog( + onDismiss = { showTempoPitchDialog = false } + ) + } + + LazyColumn( + contentPadding = PaddingValues( + top = 16.dp, + bottom = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + 16.dp + ) + ) { + item { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.VolumeDown, + contentDescription = stringResource(R.string.volume), + modifier = Modifier.size(24.dp) + ) + Slider( + value = playerVolume.value, + onValueChange = { playerConnection.service.playerVolume.value = it }, + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + Icon( + imageVector = Icons.AutoMirrored.Rounded.VolumeUp, + contentDescription = stringResource(R.string.volume), + modifier = Modifier.size(24.dp) + ) + } + } + + item { + Spacer(modifier = Modifier.height(24.dp)) + } + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (!mediaMetadata.isLocal) { + ActionChip( + title = stringResource(R.string.start_radio), + icon = Icons.Rounded.Radio, + onClick = { + playerConnection.service.startRadioSeamlessly() + onDismiss() + }, + modifier = Modifier.weight(1f) + ) + } else { + val inLibrary = librarySong?.song?.inLibrary != null + ActionChip( + title = if (inLibrary) stringResource(R.string.add_to_library) else stringResource(R.string.add_to_library), + painterIcon = if (inLibrary) R.drawable.library_add_check else R.drawable.library_add, + isVector = false, + onClick = { + database.transaction { + if (inLibrary) { + inLibrary(mediaMetadata.id, null) + } else { + insert(mediaMetadata) + inLibrary(mediaMetadata.id, LocalDateTime.now()) + } + } + }, + modifier = Modifier.weight(1f) + ) + } + + ActionChip( + title = stringResource(R.string.add_to_playlist), + icon = Icons.Rounded.PlaylistAdd, + onClick = { showChoosePlaylistDialog = true }, + modifier = Modifier.weight(1f) + ) + + if (!mediaMetadata.isLocal) { + val downloadState = download?.state + ActionChip( + title = when (downloadState) { + Download.STATE_COMPLETED -> stringResource(R.string.download) + Download.STATE_DOWNLOADING -> stringResource(R.string.downloading) + else -> stringResource(R.string.download) + }, + icon = when (downloadState) { + Download.STATE_COMPLETED -> Icons.Rounded.DownloadDone + else -> Icons.Rounded.Download + }, + isLoading = downloadState == Download.STATE_DOWNLOADING, + onClick = { + if (downloadState == Download.STATE_COMPLETED) { + DownloadService.sendRemoveDownload(context, ExoDownloadService::class.java, mediaMetadata.id, false) + } else if (downloadState == null) { + database.transaction { insert(mediaMetadata) } + val downloadRequest = DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload(context, ExoDownloadService::class.java, downloadRequest, false) + } + }, + modifier = Modifier.weight(1f) + ) + } else { + ActionChip( + title = stringResource(R.string.equalizer), + icon = Icons.Rounded.Equalizer, + onClick = { + val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, playerConnection.player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + if (intent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(intent) + } + onDismiss() + }, + modifier = Modifier.weight(1f) + ) + } + + ActionChip( + title = stringResource(R.string.share), + icon = Icons.Rounded.Share, + onClick = { + showSocialShareDialog = true + }, + modifier = Modifier.weight(1f) + ) + } + } + + item { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp)) + } + + if (!mediaMetadata.isLocal) { + val inLibrary = librarySong?.song?.inLibrary != null + item { + MenuActionRow( + title = if (inLibrary) stringResource(R.string.remove_from_library) else stringResource(R.string.add_to_library), + painterIcon = if (inLibrary) R.drawable.library_add_check else R.drawable.library_add, + isVector = false + ) { + database.transaction { + if (inLibrary) { + inLibrary(mediaMetadata.id, null) + } else { + insert(mediaMetadata) + inLibrary(mediaMetadata.id, LocalDateTime.now()) + } + } + } + } + } + + if (artists.isNotEmpty()) { + item { + MenuActionRow( + title = stringResource(R.string.view_artist), + icon = Icons.Rounded.Person + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + bottomSheetState.collapseSoft() + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + } + + if (mediaMetadata.album != null) { + item { + MenuActionRow( + title = stringResource(R.string.view_album), + icon = Icons.Rounded.Album + ) { + navController.navigate("album/${mediaMetadata.album.id}") + bottomSheetState.collapseSoft() + onDismiss() + } + } + } + + if (!mediaMetadata.isLocal) { + item { + MenuActionRow( + title = stringResource(R.string.equalizer), + icon = Icons.Rounded.Equalizer + ) { + val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, playerConnection.player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + if (intent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(intent) + } + onDismiss() + } + } + } + + item { + val summary = if (sleepTimerEnabled) makeTimeString(sleepTimerTimeLeft) else "" + MenuActionRow( + title = stringResource(R.string.sleep_timer), + summary = summary, + icon = Icons.Rounded.Timer + ) { + if (sleepTimerEnabled) playerConnection.service.sleepTimer.clear() + else showSleepTimerDialog = true + } + } + + item { + MenuActionRow( + title = stringResource(R.string.tempo_and_pitch), + painterIcon = R.drawable.speed, + isVector = false + ) { + showTempoPitchDialog = true + } + } + + if (!mediaMetadata.isLocal) { + item { + MenuActionRow( + title = stringResource(R.string.listen_youtube_music), + icon = Icons.Rounded.AddLink + ) { + val intent = Intent(Intent.ACTION_VIEW, "https://music.youtube.com/watch?v=${mediaMetadata.id}".toUri()) + context.startActivity(intent) + } + } + } + + item { + MenuActionRow( + title = stringResource(R.string.details), + icon = Icons.Rounded.Info + ) { + onShowDetailsDialog() + onDismiss() + } + } + } +} + +@Composable +fun ActionChip( + title: String, + icon: ImageVector? = null, + painterIcon: Int? = null, + isVector: Boolean = true, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false +) { + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) { + MaterialTheme.colorScheme.surface.copy(alpha = 0.2f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + + Card( + onClick = onClick, + modifier = modifier.height(80.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + ) { + Column( + modifier = Modifier.fillMaxSize().padding(vertical = 8.dp, horizontal = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + if (isVector && icon != null) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(24.dp) + ) + } else if (painterIcon != null) { + Icon( + painter = painterResource(painterIcon), + contentDescription = title, + modifier = Modifier.size(24.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee() + ) + } + } +} + +@Composable +fun MenuActionRow( + title: String, + summary: String = "", + icon: ImageVector? = null, + painterIcon: Int? = null, + isVector: Boolean = true, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable(onClick = onClick) + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isVector && icon != null) { + Icon( + imageVector = icon, + contentDescription = title, + tint = MaterialTheme.colorScheme.secondary + ) + } else if (painterIcon != null) { + Icon( + painter = painterResource(painterIcon), + contentDescription = title, + tint = MaterialTheme.colorScheme.secondary + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + if (summary.isNotEmpty()) { + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } +} + + +@Composable +fun TempoPitchDialog( + onDismiss: () -> Unit, +) { + val playerConnection = LocalPlayerConnection.current ?: return + var tempo by remember { + mutableFloatStateOf(playerConnection.player.playbackParameters.speed) + } + var transposeValue by remember { + mutableIntStateOf(round(12 * log2(playerConnection.player.playbackParameters.pitch)).toInt()) + } + val updatePlaybackParameters = { + playerConnection.player.playbackParameters = + PlaybackParameters(tempo, 2f.pow(transposeValue.toFloat() / 12)) + } + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = onDismiss, + title = { + Text(stringResource(R.string.tempo_and_pitch)) + }, + dismissButton = { + TextButton( + onClick = { + tempo = 1f + transposeValue = 0 + updatePlaybackParameters() + } + ) { + Text(stringResource(R.string.reset)) + } + }, + confirmButton = { + TextButton( + onClick = onDismiss + ) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { + Column { + ValueAdjuster( + icon = R.drawable.speed, + currentValue = tempo, + values = (0..35).map { round((0.25f + it * 0.05f) * 100) / 100 }, + onValueUpdate = { + tempo = it + updatePlaybackParameters() + }, + valueText = { "x$it" }, + modifier = Modifier.padding(bottom = 12.dp) + ) + ValueAdjuster( + icon = R.drawable.discover_tune, + currentValue = transposeValue, + values = (-12..12).toList(), + onValueUpdate = { + transposeValue = it + updatePlaybackParameters() + }, + valueText = { "${if (it > 0) "+" else ""}$it" } + ) + } + } + ) +} + +@Composable +fun ValueAdjuster( + @DrawableRes icon: Int, + currentValue: T, + values: List, + onValueUpdate: (T) -> Unit, + valueText: (T) -> String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(28.dp) + ) + + IconButton( + enabled = currentValue != values.first(), + onClick = { + onValueUpdate(values[values.indexOf(currentValue) - 1]) + } + ) { + Icon( + painter = painterResource(R.drawable.remove), + contentDescription = null + ) + } + + Text( + text = valueText(currentValue), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.width(80.dp) + ) + + IconButton( + enabled = currentValue != values.last(), + onClick = { + onValueUpdate(values[values.indexOf(currentValue) + 1]) + } + ) { + Icon( + painter = painterResource(R.drawable.add), + contentDescription = null + ) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/PlaylistMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/PlaylistMenu.kt new file mode 100644 index 0000000..acae982 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/PlaylistMenu.kt @@ -0,0 +1,453 @@ +package com.babelsoftware.loudly.ui.menu + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.PlaylistSong +import com.babelsoftware.loudly.db.entities.PlaylistSongMap +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.PlaylistListItem +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.utils.completed +import com.babelsoftware.loudly.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun PlaylistMenu( + playlist: Playlist, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val dbPlaylist by database.playlist(playlist.id).collectAsState(initial = playlist) + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.playlistSongs(playlist.id).collect { + songs = it.map(PlaylistSong::song) + } + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it.id]?.state == Download.STATE_QUEUED + || downloads[it.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.id]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + var showEditDialog by remember { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue( + playlist.playlist.name, + TextRange(playlist.playlist.name.length) + ), + onDone = { name -> + onDismiss() + database.query { + update(playlist.playlist.copy(name = name)) + } + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { YouTube.renamePlaylist(it, name) } + } + } + ) + } + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist.playlist.name + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showRemoveDownloadDialog = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + var showDeletePlaylistDialog by remember { + mutableStateOf(false) + } + + if (showDeletePlaylistDialog) { + DefaultDialog( + onDismiss = { showDeletePlaylistDialog = false }, + content = { + Text( + text = stringResource(R.string.delete_playlist_confirm, playlist.playlist.name), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showDeletePlaylistDialog = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showDeletePlaylistDialog = false + onDismiss() + database.query { + delete(playlist.playlist) + } + + if (!playlist.playlist.isLocal) { + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { YouTube.deletePlaylist(it) } + } + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + val m3uLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl") + ) { uri: Uri? -> + uri?.let { + CoroutineScope(Dispatchers.IO).launch { + var result = "#EXTM3U\n" + songs.forEach { s -> + val se = s.song + result += "#EXTINF:${se.duration},${s.artists.joinToString(";") { it.name }} - ${s.title}\n" + result += if (se.isLocal) se.localPath else "https://music.youtube.com/watch?v=${se.id}\n" + } + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(result.toByteArray(Charsets.UTF_8)) + } + } + } + } + + PlaylistListItem( + playlist = playlist, + thumbnail = Icons.AutoMirrored.Rounded.QueueMusic, + trailingContent = { + if (!playlist.playlist.isEditable) { + IconButton( + onClick = { + database.query { + dbPlaylist?.playlist?.toggleLike()?.let { update(it) } + } + } + ) { + Icon( + painter = painterResource(if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (dbPlaylist?.playlist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + } + ) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.map { it.toMediaItem() } + )) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.play), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map { it.toMediaItem() } + )) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.shuffle), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_queue), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + + DownloadListMenu( + state = downloadState, + onDownload = { + songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + showRemoveDownloadDialog = true + } + ) + + ListMenuItem( + icon = R.drawable.delete, + title = R.string.delete + ) { + showDeletePlaylistDialog = true + } + + if (playlist.playlist.browseId != null) { + ListMenuItem( + icon = R.drawable.sync, + title = R.string.sync + ) { + onDismiss() + coroutineScope.launch(Dispatchers.IO) { + val playlistPage = + YouTube.playlist(playlist.playlist.browseId).completed().getOrNull() + ?: return@launch + database.transaction { + clearPlaylist(playlist.id) + playlistPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { position, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = position + ) + } + .forEach(::insert) + } + } + } + } + ListMenuItem( + icon = R.drawable.backup, + title = R.string.playlist_m3u_export + ) { + m3uLauncher.launch("playlist.m3u") + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/QueueSelectionMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/QueueSelectionMenu.kt new file mode 100644 index 0000000..d604316 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/QueueSelectionMenu.kt @@ -0,0 +1,190 @@ +package com.babelsoftware.loudly.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.extensions.metadata +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.R +import kotlin.collections.get + +@Composable +fun QueueSelectionMenu( + navController: NavController, + selection: List, + onExitSelectionMode: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + LaunchedEffect(selection) { + if (selection.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = when { + selection.all { downloads[it.mediaItem.metadata?.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED + selection.all { downloads[it.mediaItem.metadata?.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING + else -> Download.STATE_STOPPED + } + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { + database.transaction { + selection.forEach { item -> + insert(item.mediaItem.metadata!!) + } + } + selection.mapNotNull { it.mediaItem.metadata?.id } + }, + onDismiss = { + showChoosePlaylistDialog = false + onDismiss() + onExitSelectionMode() + } + ) + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.map { it.mediaItem }, + ), + ) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.map { it.mediaItem }.shuffled(), + ) + ) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next, + ) { + onDismiss() + playerConnection.playNext(selection.map { it.mediaItem }) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(selection.map { it.mediaItem }) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + + DownloadListMenu( + state = downloadState, + onDownload = { + selection.forEach { + val mediaMetadata = it.mediaItem.metadata ?: return@forEach + database.query { + insert(mediaMetadata) + } + val downloadRequest = + DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + selection.forEach { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + it.mediaItem.metadata!!.id, + false + ) + } + } + ) + + ListMenuItem( + icon = R.drawable.playlist_remove, + title = R.string.remove_from_queue + ) { + selection.sortedBy { + -it.firstPeriodIndex + }.forEach { item -> + playerConnection.player.removeMediaItem(item.firstPeriodIndex) + } + onDismiss() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/SongMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/SongMenu.kt new file mode 100644 index 0000000..4c730fb --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/SongMenu.kt @@ -0,0 +1,596 @@ +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.ListThumbnailSize +import com.babelsoftware.loudly.db.entities.Event +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.PlaylistSong +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.loudly.viewmodels.CachePlaylistViewModel +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun SongMenu( + originalSong: Song, + event: Event? = null, + navController: NavController, + playlistSong: PlaylistSong? = null, + playlist: Playlist? = null, + onDismiss: () -> Unit, + isFromCache: Boolean = false, +) { + val downloadUtil = LocalDownloadUtil.current + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val songState = database.song(originalSong.id).collectAsState(initial = originalSong) + val song = songState.value ?: originalSong + val download by LocalDownloadUtil.current.getDownload(originalSong.id) + .collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + + val scope = rememberCoroutineScope() + var refetchIconDegree by remember { mutableFloatStateOf(0f) } + + val cacheViewModel = viewModel() + + val rotationAnimation by animateFloatAsState( + targetValue = refetchIconDegree, + animationSpec = tween(durationMillis = 800), + label = "" + ) + + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_song)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue( + song.song.title, + TextRange(song.song.title.length) + ), + onDone = { title -> + onDismiss() + database.query { + update(song.song.copy(title = title)) + } + } + ) + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + LaunchedEffect(song.song.liked) { + downloadUtil.autoDownloadIfLiked(song.song) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { playlistId -> + coroutineScope.launch(Dispatchers.IO) { + playlistId.playlist.browseId?.let { browseId -> + YouTube.addToPlaylist(browseId, song.id) + } + } + listOf(song.id) + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items( + items = song.artists, + key = { it.id } + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.Companion + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(CircleShape) + ) + } + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + } + } + } + } + + SongListItem( + song = song, + showLikedIcon = false, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + update(song.song.toggleLike()) + } + } + ) { + Icon( + painter = painterResource(if (song.song.liked) R.drawable.favorite else R.drawable.favorite_border), + tint = if (song.song.liked) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (song.song.isLocal) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.playNext(song.toMediaItem()) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_play), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.play_next), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } else { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.playQueue(YouTubeQueue.Companion.radio(song.toMediaMetadata())) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.start_radio), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + if (playlistSong != null) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + database.transaction { + coroutineScope.launch { + playlist?.id?.let { playlistId -> + if (playlistSong.map.setVideoId != null) { + YouTube.removeFromPlaylist( + playlistId, + playlistSong.map.songId, + playlistSong.map.setVideoId + ) + } + } + } + move( + playlistSong.map.playlistId, + playlistSong.map.position, + Int.MAX_VALUE + ) + delete(playlistSong.map.copy(position = Int.MAX_VALUE)) + } + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_remove), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.remove_from_playlist), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } else { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + showChoosePlaylistDialog = true + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_playlist), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + if (!song.song.isLocal) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/watch?v=${song.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } else { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.addToQueue((song.toMediaItem())) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_queue), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + if (!song.song.isLocal) { + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + onDismiss() + playerConnection.playNext(song.toMediaItem()) + } + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + onDismiss() + playerConnection.addToQueue((song.toMediaItem())) + } + ListMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + + if (isFromCache) { + ListMenuItem( + icon = R.drawable.cached, + title = R.string.remove_from_cache + ) { + onDismiss() + cacheViewModel.removeSongFromCache(song.id) + } + } + DownloadListMenu( + state = download?.state, + onDownload = { + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + ) + + ListMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (song.artists.size == 1) { + navController.navigate("artist/${song.artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + if (song.song.albumId != null) { + ListMenuItem( + icon = R.drawable.album, + title = R.string.view_album + ) { + onDismiss() + navController.navigate("album/${song.song.albumId}") + } + } + ListMenuItem( + icon = R.drawable.music_note, + title = R.string.listen_youtube_music + ) { + val intent = Intent( + Intent.ACTION_VIEW, + "https://music.youtube.com/watch?v=${song.id}".toUri() + ) + context.startActivity(intent) + } + ListMenuItem( + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation) + ) + }, + title = R.string.refetch + ) { + refetchIconDegree -= 360 + scope.launch(Dispatchers.IO) { + YouTube.queue(listOf(song.id)).onSuccess { + val newSong = it.firstOrNull() + if (newSong != null) { + database.transaction { + update(song, newSong.toMediaMetadata()) + } + } + } + } + } + } + if (song.song.inLibrary == null) { + ListMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library + ) { + database.query { + update(song.song.toggleLibrary()) + } + } + } else { + ListMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library + ) { + database.query { + update(song.song.toggleLibrary()) + } + } + } + if (event != null) { + ListMenuItem( + icon = R.drawable.delete, + title = R.string.remove_from_history + ) { + onDismiss() + database.query { + delete(event) + } + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/SongSelectionMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/SongSelectionMenu.kt new file mode 100644 index 0000000..c8d2e04 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/SongSelectionMenu.kt @@ -0,0 +1,263 @@ +package com.babelsoftware.loudly.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.viewmodels.CachePlaylistViewModel +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.time.LocalDateTime + +@Composable +fun SongSelectionMenu( + navController: NavController, + selection: List, + onDismiss: () -> Unit, + onExitSelectionMode: () -> Unit, + onRemoveFromQueue: (() -> Unit)? = null, + onRemoveFromHistory: (() -> Unit)? = null, + isFromCache: Boolean = false, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + + val allInLibrary by remember(selection) { + mutableStateOf(selection.isNotEmpty() && selection.all { it.song.inLibrary != null }) + } + val allLiked by remember(selection) { + mutableStateOf(selection.isNotEmpty() && selection.all { it.song.liked }) + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + val cacheViewModel = viewModel() + + LaunchedEffect(selection) { + if (selection.isEmpty()) { + onDismiss() + } else { + downloadUtil.downloads.collect { downloads -> + downloadState = when { + selection.all { downloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED + selection.all { downloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING, STATE_COMPLETED) } -> STATE_DOWNLOADING + else -> Download.STATE_STOPPED + } + } + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { + selection.map { + runBlocking { + withContext(Dispatchers.IO) { + it.song.id + } + } + } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + ListMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + ListMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.map { it.toMediaItem() }, + ), + ) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.shuffled().map { it.toMediaItem() }, + ), + ) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(selection.map { it.toMediaItem() }) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + DownloadListMenu( + state = downloadState, + onDownload = { + selection.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onRemoveDownload = { + selection.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + }, + ) + + if (isFromCache) { + ListMenuItem( + icon = R.drawable.cached, + title = R.string.remove_from_cache + ) { + selection.forEach { song -> + onDismiss() + cacheViewModel.removeSongFromCache(song.id) + } + } + } + + if (allInLibrary) { + ListMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library, + ) { + database.query { + selection.forEach { song -> + inLibrary(song.id, null) + } + } + } + } else { + ListMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library, + ) { + database.transaction { + selection.forEach { song -> + inLibrary(song.id, LocalDateTime.now()) + } + } + } + } + + ListMenuItem( + icon = if (allLiked) R.drawable.favorite else R.drawable.favorite_border, + tint = { if (allLiked) MaterialTheme.colorScheme.error else LocalContentColor.current }, + title = if (allLiked) R.string.action_remove_like_all else R.string.action_like_all, + ) { + database.query { + if (allLiked) { + selection.forEach { song -> + update(song.song.copy(liked = false)) + } + } else { + selection.forEach { song -> + val likedSong = song.song.copy(liked = true) + update(likedSong) + downloadUtil.autoDownloadIfLiked(likedSong) + } + } + } + } + + if (onRemoveFromQueue != null) { + ListMenuItem( + icon = R.drawable.playlist_remove, + title = R.string.remove_from_playlist, + ) { + onDismiss() + onRemoveFromQueue() + onExitSelectionMode() + } + } + if (onRemoveFromHistory != null) { + ListMenuItem( + icon = R.drawable.delete, + title = R.string.remove_from_history, + ) { + onDismiss() + onRemoveFromHistory() + onExitSelectionMode() + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeAlbumMenu.kt new file mode 100644 index 0000000..f6aec89 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeAlbumMenu.kt @@ -0,0 +1,381 @@ +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.YouTubeAlbumRadio +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.reportException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun YouTubeAlbumMenu( + albumItem: AlbumItem, + navController: NavController, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val album by database.albumWithSongs(albumItem.id).collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + database.album(albumItem.id).collect { album -> + if (album == null) { + YouTube.album(albumItem.id).onSuccess { albumPage -> + database.transaction { + insert(albumPage) + } + }.onFailure { + reportException(it) + } + } + } + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(album) { + val songs = album?.songs?.map { it.id } ?: return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it]?.state == Download.STATE_QUEUED + || downloads[it]?.state == Download.STATE_DOWNLOADING + || downloads[it]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { playlistId -> + album?.album?.playlistId?.let { addPlaylistId -> + YouTube.addPlaylistToPlaylist(playlistId, addPlaylistId) + } + } + } + album?.songs?.map { it.id }.orEmpty() + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items( + items = album?.artists.orEmpty(), + key = { it.id } + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.Companion + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + showSelectArtistDialog = false + onDismiss() + navController.navigate("artist/${artist.id}") + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + YouTubeListItem( + item = albumItem, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + album?.album?.toggleLike()?.let(::update) + } + } + ) { + Icon( + painter = painterResource(if (album?.album?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (album?.album?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeAlbumRadio(albumItem.playlistId)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.start_radio), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + showChoosePlaylistDialog = true + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_playlist), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, albumItem.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + album?.songs + ?.map { it.toMediaItem() } + ?.let(playerConnection::playNext) + onDismiss() + } + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + album?.songs + ?.map { it.toMediaItem() } + ?.let(playerConnection::addToQueue) + onDismiss() + } + DownloadListMenu( + state = downloadState, + onDownload = { + album?.songs?.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + album?.songs?.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) + albumItem.artists?.let { artists -> + ListMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + ListMenuItem( + icon = R.drawable.music_note, + title = R.string.listen_youtube_music + ) { + val intent = Intent(Intent.ACTION_VIEW, albumItem.shareLink.toUri()) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeArtistMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeArtistMenu.kt new file mode 100644 index 0000000..7732cfe --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeArtistMenu.kt @@ -0,0 +1,213 @@ +@file:Suppress("NAME_SHADOWING") + +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import java.time.LocalDateTime +import androidx.core.net.toUri + +@Composable +fun YouTubeArtistMenu( + artist: ArtistItem, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val libraryArtist by database.artist(artist.id).collectAsState(initial = null) + + YouTubeListItem( + item = artist, + trailingContent = { + IconButton( + onClick = { + database.query { + val libraryArtist = libraryArtist + if (libraryArtist != null) { + update(libraryArtist.artist.toggleLike()) + } else { + insert( + ArtistEntity( + id = artist.id, + name = artist.title, + channelId = artist.channelId, + thumbnailUrl = artist.thumbnail, + bookmarkedAt = LocalDateTime.now() + ).toggleLike() + ) + } + } + } + ) { + Icon( + painter = painterResource(if (libraryArtist?.artist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (libraryArtist?.artist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + + } + ) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + artist.radioEndpoint?.let { watchEndpoint -> + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue(watchEndpoint)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.start_radio), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + artist.shuffleEndpoint?.let { watchEndpoint -> + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue(watchEndpoint)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.shuffle), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, artist.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.music_note, + title = R.string.listen_youtube_music + ) { + val intent = Intent(Intent.ACTION_VIEW, artist.shareLink.toUri()) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubePlaylistMenu.kt new file mode 100644 index 0000000..583368c --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubePlaylistMenu.kt @@ -0,0 +1,373 @@ +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.utils.completed +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.db.entities.PlaylistSongMap +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.core.net.toUri +import androidx.navigation.NavController + +@Composable +fun YouTubePlaylistMenu( + navController: NavController, + playlist: PlaylistItem, + songs: List = emptyList(), + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val dbPlaylist by database.playlistByBrowseId(playlist.id).collectAsState(initial = null) + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { targetPlaylist -> + val allSongs = songs.ifEmpty { + YouTube.playlist(targetPlaylist.id).completed().getOrNull()?.songs.orEmpty() + }.map { + it.toMediaMetadata() + } + database.transaction { + allSongs.forEach(::insert) + } + coroutineScope.launch(Dispatchers.IO) { + targetPlaylist.playlist.browseId?.let { playlistId -> + YouTube.addPlaylistToPlaylist(playlistId, targetPlaylist.id) + } + } + allSongs.map { it.id } + }, + onDismiss = { showChoosePlaylistDialog = false }, + ) + + var showDeletePlaylistDialog by remember { + mutableStateOf(false) + } + if (showDeletePlaylistDialog) { + DefaultDialog(onDismiss = { showDeletePlaylistDialog = false }, content = { + Text( + text = stringResource(R.string.delete_playlist_confirm, playlist.title), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, buttons = { + TextButton( + onClick = { + showDeletePlaylistDialog = false + }) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { + showDeletePlaylistDialog = false + onDismiss() + database.transaction { + deletePlaylistById(playlist.id) + } + }) { + Text(text = stringResource(android.R.string.ok)) + } + }) + } + + YouTubeListItem( + item = playlist, trailingContent = { + if (playlist.id != "LM" && !playlist.isEditable) { + IconButton( + onClick = { + if (dbPlaylist?.playlist == null) { + database.transaction { + val playlistEntity = PlaylistEntity( + name = playlist.title, + browseId = playlist.id, + thumbnailUrl = playlist.thumbnail, + isEditable = false, + remoteSongCount = playlist.songCountText?.let { + Regex("""\d+""").find( + it + )?.value?.toIntOrNull() + }, + playEndpointParams = playlist.playEndpoint?.params, + shuffleEndpointParams = playlist.shuffleEndpoint.params, + radioEndpointParams = playlist.radioEndpoint?.params + ).toggleLike() + insert(playlistEntity) + coroutineScope.launch(Dispatchers.IO) { + songs.ifEmpty { + YouTube.playlist(playlist.id).completed() + .getOrNull()?.songs.orEmpty() + }.map { it.toMediaMetadata() }.onEach(::insert) + .mapIndexed { index, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistEntity.id, + position = index + ) + }.forEach(::insert) + } + } + } else { + database.transaction { + update(dbPlaylist!!.playlist.toggleLike()) + } + } + }) { + Icon( + painter = painterResource(if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (dbPlaylist?.playlist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + }) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + playlist.radioEndpoint?.let { radioEndpoint -> + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.start_radio), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + playlist.playEndpoint?.let { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue(it)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.play), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + if (playlist.id != "LM") { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, playlist.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } else { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.shuffle), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.shuffle, title = R.string.shuffle + ) { + playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) + onDismiss() + } + ListMenuItem( + icon = R.drawable.playlist_play, title = R.string.play_next + ) { + coroutineScope.launch { + songs.ifEmpty { + withContext(Dispatchers.IO) { + YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() + } + }.let { songs -> + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + } + onDismiss() + } + ListMenuItem( + icon = R.drawable.queue_music, title = R.string.add_to_queue + ) { + coroutineScope.launch { + songs.ifEmpty { + withContext(Dispatchers.IO) { + YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() + } + }.let { songs -> + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + } + onDismiss() + } + ListMenuItem( + icon = R.drawable.playlist_add, title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + ListMenuItem( + icon = R.drawable.music_note, title = R.string.listen_youtube_music + ) { + val intent = Intent(Intent.ACTION_VIEW, playlist.shareLink.toUri()) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongMenu.kt new file mode 100644 index 0000000..1f2f50e --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongMenu.kt @@ -0,0 +1,408 @@ +package com.babelsoftware.loudly.ui.menu + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.ListThumbnailSize +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.DownloadListMenu +import com.babelsoftware.loudly.ui.component.ListDialog +import com.babelsoftware.loudly.ui.component.ListItem +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.loudly.utils.joinByBullet +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Composable +fun YouTubeSongMenu( + song: SongItem, + navController: NavController, + onDismiss: () -> Unit, +) { + val downloadUtil = LocalDownloadUtil.current + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val librarySong by database.song(song.id).collectAsState(initial = null) + val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + val artists = remember { + song.artists.mapNotNull { + it.id?.let { artistId -> + MediaMetadata.Artist(id = artistId, name = it.name) + } + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + LaunchedEffect(librarySong?.song?.liked) { + librarySong?.let { + downloadUtil.autoDownloadIfLiked(it.song) + } + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + database.transaction { + insert(song.toMediaMetadata()) + } + + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { browseId -> + YouTube.addToPlaylist(browseId, song.id) + } + } + + listOf(song.id) + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items(artists) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.Companion + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + ListItem( + title = song.title, + subtitle = joinByBullet( + song.artists.joinToString { it.name }, + song.duration?.let { makeTimeString(it * 1000L) } + ), + thumbnailContent = { + AsyncImage( + model = song.thumbnail, + contentDescription = null, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + }, + trailingContent = { + IconButton( + onClick = { + database.transaction { + librarySong.let { librarySong -> + if (librarySong == null) { + insert(song.toMediaMetadata(), SongEntity::toggleLike) + } else { + update(librarySong.song.toggleLike()) + } + } + } + } + ) { + Icon( + painter = painterResource(if (librarySong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border), + tint = if (librarySong?.song?.liked == true) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue.Companion.radio(song.toMediaMetadata())) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.start_radio), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + showChoosePlaylistDialog = true + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_playlist), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, song.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + ListMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + playerConnection.playNext(song.toMediaItem()) + onDismiss() + } + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue + ) { + playerConnection.addToQueue((song.toMediaItem())) + onDismiss() + } + if (librarySong?.song?.inLibrary != null) { + ListMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library + ) { + database.query { + inLibrary(song.id, null) + } + } + } else { + ListMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library + ) { + database.transaction { + insert(song.toMediaMetadata()) + inLibrary(song.id, LocalDateTime.now()) + } + } + } + DownloadListMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(song.toMediaMetadata()) + } + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + ) + if (artists.isNotEmpty()) { + ListMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + song.album?.let { album -> + ListMenuItem( + icon = R.drawable.album, + title = R.string.view_album + ) { + navController.navigate("album/${album.id}") + onDismiss() + } + } + ListMenuItem( + icon = R.drawable.music_note, + title = R.string.listen_youtube_music + ) { + val intent = Intent(Intent.ACTION_VIEW, song.shareLink.toUri()) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongSelectionMenu.kt b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongSelectionMenu.kt new file mode 100644 index 0000000..5f8aa99 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/menu/YouTubeSongSelectionMenu.kt @@ -0,0 +1,129 @@ +package com.babelsoftware.loudly.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.ListMenu +import com.babelsoftware.loudly.ui.component.ListMenuItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.loudly.R +import java.time.LocalDateTime + +@Composable +fun YouTubeSongSelectionMenu( + navController: NavController, + selection: List, + onDismiss: () -> Unit, + onExitSelectionMode: () -> Unit, +) { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { + val mediaMetadata = selection.map { + it.toMediaMetadata() + } + database.transaction { + mediaMetadata.forEach(::insert) + } + selection.map { it.id } + }, + onDismiss = { showChoosePlaylistDialog = false }, + ) + + ListMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + ListMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.map { it.toMediaItem() }, + ), + ) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + items = selection.shuffled().map { it.toMediaItem() }, + ), + ) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next, + ) { + onDismiss() + playerConnection.playNext(selection.map { it.toMediaItem() }) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(selection.map { it.toMediaItem() }) + onExitSelectionMode() + } + + ListMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + + ListMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library, + ) { + database.query { + selection.forEach { song -> + insert(song.toMediaMetadata()) + } + selection.forEach { song -> + inLibrary(song.id, LocalDateTime.now()) + } + } + onDismiss() + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/player/MiniPlayer.kt b/app/src/main/java/com/babelsoftware/loudly/ui/player/MiniPlayer.kt new file mode 100644 index 0000000..84cf318 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/player/MiniPlayer.kt @@ -0,0 +1,256 @@ +package com.babelsoftware.loudly.ui.player + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.MiniPlayerAction +import com.babelsoftware.loudly.constants.MiniPlayerActionButtonKey +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.utils.dataStore +import kotlinx.coroutines.flow.map + +@Composable +fun MiniPlayer( + position: Long, + duration: Long, + isScrolled: Boolean, + onExpand: () -> Unit, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val isPlaying by playerConnection.isPlaying.collectAsState() + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + val canSkipNext by playerConnection.canSkipNext.collectAsState() + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + + val context = LocalContext.current + val downloadUtil = LocalDownloadUtil.current + val database = LocalDatabase.current + + val download by downloadUtil.getDownload(mediaMetadata?.id ?: "") + .collectAsState(initial = null) + + val actionButtonType by context.dataStore.data + .map { preferences -> + val actionName = preferences[MiniPlayerActionButtonKey] ?: MiniPlayerAction.Like.name + try { + MiniPlayerAction.valueOf(actionName) + } catch (e: IllegalArgumentException) { + MiniPlayerAction.Like + } + } + .collectAsState(initial = MiniPlayerAction.Like) + + val liquidAnimationSpec = spring( + dampingRatio = 0.8f, + stiffness = Spring.StiffnessMedium + ) + + val cardHeight by animateDpAsState(targetValue = 60.dp, animationSpec = liquidAnimationSpec, "cardHeight") + val cardShape = RoundedCornerShape(16.dp) + val cardPadding by animateDpAsState(if (isScrolled) 8.dp else 12.dp, liquidAnimationSpec, label = "cardPadding") + + val progress = if (duration > 0) (position.toFloat() / duration).coerceIn(0f, 1f) else 0f + + val cardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f) + ) + val cardBorder = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + val cardElevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + + Card( + modifier = modifier + .fillMaxWidth() + .height(cardHeight) + .padding(horizontal = cardPadding) + .pointerInput(Unit) { + detectTapGestures(onTap = { onExpand() }) + }, + shape = cardShape, + colors = cardColors, + border = cardBorder, + elevation = cardElevation + ) { + Box(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + mediaMetadata?.let { + AsyncImage( + model = it.thumbnailUrl, + contentDescription = "Album Art", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } + + Column(modifier = Modifier.weight(1f)) { + mediaMetadata?.let { + Text( + text = it.title, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(), + ) + Text( + text = it.artists.joinToString { artist -> artist.name }, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(), + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + when(actionButtonType) { + MiniPlayerAction.Like -> { + IconButton(onClick = { playerConnection.toggleLike() }) { + Icon( + painter = if (currentSong?.song?.liked == true) painterResource(R.drawable.favorite) else painterResource(R.drawable.favorite_border), + tint = if (currentSong?.song?.liked == true) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + contentDescription = "Like", + modifier = Modifier.size(26.dp) + ) + } + } + MiniPlayerAction.Next -> { + IconButton(enabled = canSkipNext, onClick = { playerConnection.player.seekToNext() }) { + Icon( + painter = painterResource(R.drawable.skip_next), + contentDescription = "Next", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(28.dp) + ) + } + } + MiniPlayerAction.Previous -> { + IconButton(enabled = canSkipPrevious, onClick = { playerConnection.player.seekToPrevious() }) { + Icon( + painter = painterResource(R.drawable.skip_previous), + contentDescription = "Previous", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(28.dp) + ) + } + } + MiniPlayerAction.Download -> { + if (mediaMetadata?.isLocal == false) { + val downloadState = download?.state + val isDownloaded = downloadState == Download.STATE_COMPLETED + val isDownloading = downloadState == Download.STATE_DOWNLOADING + + IconButton( + enabled = !isDownloading, + onClick = { + mediaMetadata?.let { meta -> + if (isDownloaded) { + DownloadService.sendRemoveDownload(context, ExoDownloadService::class.java, meta.id, false) + } else { + database.transaction { insert(meta) } + val downloadRequest = DownloadRequest.Builder(meta.id, meta.id.toUri()) + .setCustomCacheKey(meta.id) + .setData(meta.title.toByteArray()) + .build() + DownloadService.sendAddDownload(context, ExoDownloadService::class.java, downloadRequest, false) + } + } + } + ) { + if (isDownloading) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Icon( + painter = if (isDownloaded) painterResource(R.drawable.download_done) else painterResource(R.drawable.download), + contentDescription = if (isDownloaded) "Remove Download" else "Download", + tint = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(28.dp) + ) + } + } + } + } + MiniPlayerAction.None -> { /* Do not show anything */ } + } + + IconButton(onClick = { playerConnection.player.togglePlayPause() }) { + Icon( + painter = painterResource(if (isPlaying) R.drawable.pause else R.drawable.play), + contentDescription = "Play/Pause", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + modifier = Modifier.size(32.dp) + ) + } + } + } + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .align(Alignment.BottomCenter), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + trackColor = Color.Transparent, + strokeCap = StrokeCap.Round + ) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/player/PlaybackError.kt b/app/src/main/java/com/babelsoftware/loudly/ui/player/PlaybackError.kt new file mode 100644 index 0000000..b4ffc75 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/player/PlaybackError.kt @@ -0,0 +1,106 @@ +package com.babelsoftware.loudly.ui.player + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.media3.common.PlaybackException +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.DarkModeKey +import com.babelsoftware.loudly.constants.PlayerBackgroundStyle +import com.babelsoftware.loudly.constants.PlayerBackgroundStyleKey +import com.babelsoftware.loudly.ui.screens.settings.DarkMode +import com.babelsoftware.loudly.utils.rememberEnumPreference + +@Composable +fun PlaybackError( + error: PlaybackException, + retry: () -> Unit, +) { + val playerBackground by rememberEnumPreference( + key = PlayerBackgroundStyleKey, + defaultValue = PlayerBackgroundStyle.DEFAULT + ) + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + val textColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.secondary + else -> if (useDarkTheme) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimary + } + + val (errorMessage, errorDescription) = remember(error.errorCode) { + when (error.errorCode) { + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> + Pair( + R.string.error_network_title, + R.string.error_network_description + ) + PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED -> + Pair( + R.string.error_unsupported_format_title, + R.string.error_unsupported_format_description + ) + PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> + Pair( + R.string.error_behind_live_window_title, + R.string.error_behind_live_window_description + ) + else -> { + Pair( + R.string.error_playback_failed_title, + R.string.error_playback_failed_description + ) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onTap = { retry() }) + } + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + + Column { + Text( + text = stringResource(errorMessage), + color = textColor, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(id = errorDescription), + color = textColor.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodyMedium, + fontSize = 12.sp + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/player/Player.kt b/app/src/main/java/com/babelsoftware/loudly/ui/player/Player.kt new file mode 100644 index 0000000..b14d98e --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/player/Player.kt @@ -0,0 +1,882 @@ +@file:Suppress("NAME_SHADOWING") + +package com.babelsoftware.loudly.ui.player + +import android.content.Intent +import android.content.res.Configuration +import android.graphics.drawable.BitmapDrawable +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.media3.common.C +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Player.STATE_ENDED +import androidx.media3.common.Player.STATE_READY +import androidx.navigation.NavController +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.DarkModeKey +import com.babelsoftware.loudly.constants.PlayerBackgroundStyle +import com.babelsoftware.loudly.constants.PlayerBackgroundStyleKey +import com.babelsoftware.loudly.constants.PlayerHorizontalPadding +import com.babelsoftware.loudly.constants.PlayerStyle +import com.babelsoftware.loudly.constants.PlayerStyleKey +import com.babelsoftware.loudly.constants.PureBlackKey +import com.babelsoftware.loudly.constants.QueuePeekHeight +import com.babelsoftware.loudly.constants.ShowLyricsKey +import com.babelsoftware.loudly.constants.SliderStyle +import com.babelsoftware.loudly.constants.SliderStyleKey +import com.babelsoftware.loudly.constants.fullScreenLyricsKey +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.extensions.toggleRepeatMode +import com.babelsoftware.loudly.extensions.toggleShuffleMode +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.ui.component.AsyncLocalImage +import com.babelsoftware.loudly.ui.component.BottomSheet +import com.babelsoftware.loudly.ui.component.BottomSheetState +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.PlayerSliderTrack +import com.babelsoftware.loudly.ui.component.ResizableIconButton +import com.babelsoftware.loudly.ui.component.rememberBottomSheetState +import com.babelsoftware.loudly.ui.menu.PlayerMenu +import com.babelsoftware.loudly.ui.screens.settings.DarkMode +import com.babelsoftware.loudly.ui.theme.extractGradientColors +import com.babelsoftware.loudly.ui.utils.imageCache +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import me.saket.squiggles.SquigglySlider + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomSheetPlayer( + state: BottomSheetState, + navController: NavController, + isScrolled: Boolean, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + + val menuState = LocalMenuState.current + val isSystemInDarkTheme = isSystemInDarkTheme() + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) + val haptic = LocalHapticFeedback.current + val useBlackBackground = remember(isSystemInDarkTheme, darkTheme, pureBlack) { + val useDarkTheme = if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + useDarkTheme && pureBlack + } + if (useBlackBackground && state.value > state.collapsedBound) { + lerp(MaterialTheme.colorScheme.surfaceContainer, Color.Black, state.progress) + } else { + MaterialTheme.colorScheme.surfaceContainer + } + + val showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) + + val fullScreenLyrics by rememberPreference(fullScreenLyricsKey, defaultValue = true) + + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.DEFAULT) + + val playbackState by playerConnection.playbackState.collectAsState() + val isPlaying by playerConnection.isPlaying.collectAsState() + val repeatMode by playerConnection.repeatMode.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val queueTitle by playerConnection.queueTitle.collectAsState() + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + + val context = LocalContext.current + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + + val playerBackground by rememberEnumPreference( + key = PlayerBackgroundStyleKey, + defaultValue = PlayerBackgroundStyle.DEFAULT + ) + + val (playerStyle) = rememberEnumPreference(PlayerStyleKey, defaultValue = PlayerStyle.NEW) + + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + + val onBackgroundColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.secondary + else -> + if (useDarkTheme) + MaterialTheme.colorScheme.onSurface + else + if (pureBlack && darkTheme == DarkMode.ON && isSystemInDarkTheme) + Color.White + else + MaterialTheme.colorScheme.onPrimary + } + + var gradientColors by remember { + mutableStateOf>(emptyList()) + } + + LaunchedEffect(mediaMetadata) { + if (playerBackground != PlayerBackgroundStyle.GRADIENT) return@LaunchedEffect + + withContext(Dispatchers.IO) { + val result = (ImageLoader(context).execute( + ImageRequest.Builder(context) + .data(mediaMetadata?.thumbnailUrl) + .allowHardware(false) + .build() + ).drawable as? BitmapDrawable)?.bitmap?.extractGradientColors() + + result?.let { + gradientColors = it + } + } + } + + var position by rememberSaveable(playbackState) { + mutableLongStateOf(playerConnection.player.currentPosition) + } + var duration by rememberSaveable(playbackState) { + mutableLongStateOf(playerConnection.player.duration) + } + var sliderPosition by remember { + mutableStateOf(null) + } + var showDetailsDialog by remember { mutableStateOf(false) } + if (showDetailsDialog) { + DetailsDialog( + onDismiss = { showDetailsDialog = false } + ) + } + + LaunchedEffect(playbackState) { + if (playbackState == STATE_READY) { + while (isActive) { + delay(100) + position = playerConnection.player.currentPosition + duration = playerConnection.player.duration + } + } + } + + val queueSheetState = rememberBottomSheetState( + dismissedBound = QueuePeekHeight + WindowInsets.systemBars.asPaddingValues() + .calculateBottomPadding(), + expandedBound = state.expandedBound, + ) + + val bottomSheetBackgroundColor = when { + pureBlack && darkTheme == DarkMode.ON -> Color.Black + useDarkTheme || playerBackground == PlayerBackgroundStyle.DEFAULT -> + MaterialTheme.colorScheme.surfaceContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + BottomSheet( + state = state, + modifier = modifier, + backgroundColor = bottomSheetBackgroundColor, + collapsedBackgroundColor = Color.Transparent, + onDismiss = { + playerConnection.player.stop() + playerConnection.player.clearMediaItems() + }, + collapsedContent = { + MiniPlayer( + position = position, + duration = duration, + isScrolled = isScrolled, + onExpand = { state.expand() } + ) + } + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (playerStyle != PlayerStyle.UI_2_0) { + if (playerBackground == PlayerBackgroundStyle.BLUR) { + if (mediaMetadata?.isLocal == true) { + mediaMetadata.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it?.localPath) }, + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .fillMaxSize() + .blur(100.dp) + ) + } + } else { + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .fillMaxSize() + .blur(100.dp) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + ) + } else if (playerBackground == PlayerBackgroundStyle.GRADIENT && gradientColors.size >= 2) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.verticalGradient(gradientColors)) + ) + } + if (playerBackground == PlayerBackgroundStyle.BLURMOV) { + val infiniteTransition = rememberInfiniteTransition(label = "") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 100000, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ), label = "" + ) + if (mediaMetadata?.isLocal == true) { + mediaMetadata?.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it.localPath) }, + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .fillMaxSize() + .blur(100.dp) + .alpha(0.8f) + .background(if (useBlackBackground) Color.Black.copy(alpha = 0.5f) else Color.Transparent) + .rotate(rotation) + ) + } + } else { + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .fillMaxSize() + .blur(100.dp) + .alpha(0.8f) + .background(if (useBlackBackground) Color.Black.copy(alpha = 0.5f) else Color.Transparent) + .rotate(rotation) + ) + } + + if (showLyrics) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + ) + } + } + } + + + when (playerStyle) { + PlayerStyle.UI_2_0 -> { + PlayerUI20( + position = position, + duration = duration, + navController = navController, + bottomSheetState = state + ) + } + else -> { + val controlsContent: @Composable ColumnScope.(MediaMetadata) -> Unit = { mediaMetadata -> + val playPauseRoundness by animateDpAsState( + targetValue = if (isPlaying) 24.dp else 36.dp, + animationSpec = tween(durationMillis = 100, easing = LinearEasing), + label = "playPauseRoundness", + ) + + if (fullScreenLyrics) { + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding) + ) { + Row { + Box( + modifier = Modifier + .weight(1f) + ) { + AnimatedContent( + targetState = mediaMetadata.title, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { title -> + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = onBackgroundColor, + modifier = + Modifier + .basicMarquee() + .clickable(enabled = mediaMetadata.album != null && !mediaMetadata.isLocal) { + navController.navigate("album/${mediaMetadata.album!!.id}") + state.collapseSoft() + }, + ) + } + + Row( + modifier = Modifier.offset(y = 25.dp) + ) { + mediaMetadata.artists.fastForEachIndexed { index, artist -> + AnimatedContent( + targetState = artist.name, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { name -> + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + color = onBackgroundColor, + maxLines = 1, + modifier = + Modifier.clickable(enabled = artist.id != null && !mediaMetadata.isLocal) { + navController.navigate("artist/${artist.id}") + state.collapseSoft() + }, + ) + } + + if (index != mediaMetadata.artists.lastIndex) { + AnimatedContent( + targetState = ", ", + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { comma -> + Text( + text = comma, + style = MaterialTheme.typography.titleMedium, + color = onBackgroundColor, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.width(10.dp)) + + Box( + modifier = Modifier + .offset(y = 5.dp) + .size(36.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.primary) + ) { + ResizableIconButton( + icon = if (currentSong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .align(Alignment.Center) + .size(24.dp), + onClick = playerConnection::toggleLike + ) + } + + Spacer(modifier = Modifier.width(7.dp)) + + Box( + modifier = Modifier + .offset(y = 5.dp) + .size(36.dp) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.primary) + ) { + ResizableIconButton( + icon = R.drawable.more_vert, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + onClick = { + menuState.show { + PlayerMenu( + mediaMetadata = mediaMetadata, + navController = navController, + bottomSheetState = state, + onShowDetailsDialog = { showDetailsDialog = true }, + onDismiss = menuState::dismiss + ) + } + } + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = SliderDefaults.colors() + ) + }, + modifier = Modifier.padding(horizontal = PlayerHorizontalPadding) + ) + } + + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + squigglesSpec = SquigglySlider.SquigglesSpec( + amplitude = if (isPlaying) 2.dp else 0.dp, + strokeWidth = 4.dp, + ), + modifier = Modifier.padding(horizontal = PlayerHorizontalPadding), + ) + } + + SliderStyle.COMPOSE -> { + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + modifier = Modifier.padding(horizontal = PlayerHorizontalPadding) + ) + } + } + Spacer(Modifier.height(4.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding + 4.dp) + ) { + Text( + text = makeTimeString(sliderPosition ?: position), + style = MaterialTheme.typography.labelMedium, + color = onBackgroundColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = if (duration != C.TIME_UNSET) makeTimeString(duration) else "", + style = MaterialTheme.typography.labelMedium, + color = onBackgroundColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding) + ) { + val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = R.drawable.shuffle, + modifier = Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center) + .alpha(if (shuffleModeEnabled) 1f else 0.5f), + color = onBackgroundColor, + onClick = playerConnection.player::toggleShuffleMode + ) + } + Box(modifier = Modifier.weight(1f)) { + if (playerStyle == PlayerStyle.NEW) { + ResizableIconButton( + icon = R.drawable.skip_previous, + enabled = canSkipPrevious, + color = onBackgroundColor, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = { + (playerConnection.player::seekToPrevious)() + } + ) + } else { + ResizableIconButton( + icon = R.drawable.skip_previous, + enabled = canSkipPrevious, + color = onBackgroundColor, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center) + .combinedClickable( + onClick = { + (playerConnection.player::seekToPrevious)() + }, + onLongClick = { + playerConnection.player.seekTo(playerConnection.player.currentPosition - 5000) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + ) + ) + } + } + + + Spacer(Modifier.width(8.dp)) + + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(playPauseRoundness)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { + if (playbackState == STATE_ENDED) { + playerConnection.player.seekTo(0, 0) + playerConnection.player.playWhenReady = true + } else { + playerConnection.player.togglePlayPause() + } + } + ) { + Image( + painter = painterResource(if (playbackState == STATE_ENDED) R.drawable.replay else if (isPlaying) R.drawable.pause else R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + ) + } + + Spacer(Modifier.width(8.dp)) + + Box(modifier = Modifier.weight(1f)) { + if (playerStyle == PlayerStyle.NEW) { + ResizableIconButton( + icon = R.drawable.skip_next, + enabled = canSkipNext, + color = onBackgroundColor, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = { + (playerConnection.player::seekToNext)() + } + ) + } else { + ResizableIconButton( + icon = R.drawable.skip_next, + enabled = canSkipNext, + color = onBackgroundColor, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center) + .combinedClickable( + onClick = { + (playerConnection.player::seekToNext)() + }, + onLongClick = { + playerConnection.player.seekTo(playerConnection.player.currentPosition + 5000) + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + ) + ) + } + } + + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = when (repeatMode) { + REPEAT_MODE_OFF, REPEAT_MODE_ALL -> R.drawable.repeat + REPEAT_MODE_ONE -> R.drawable.repeat_one + else -> throw IllegalStateException() + }, + modifier = Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center) + .alpha(if (repeatMode == REPEAT_MODE_OFF) 0.5f else 1f), + color = onBackgroundColor, + onClick = playerConnection.player::toggleRepeatMode + ) + } + } + } + } + when (LocalConfiguration.current.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + Row( + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(bottom = queueSheetState.collapsedBound) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + ) { + Thumbnail( + sliderPositionProvider = { sliderPosition }, + modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection), + showLyricsOnClick = true + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + ) { + Spacer(Modifier.weight(1f)) + + mediaMetadata?.let { + controlsContent(it) + } + + Spacer(Modifier.weight(1f)) + } + } + } + + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(bottom = queueSheetState.collapsedBound) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + ) { + Thumbnail( + sliderPositionProvider = { sliderPosition }, + modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection), + showLyricsOnClick = true + ) + } + + mediaMetadata?.let { + controlsContent(it) + } + + Spacer(Modifier.height(32.dp)) + } + } + } + + if (!showLyrics) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding) + .padding(top = 35.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ResizableIconButton( + icon = R.drawable.arrow_downward, + modifier = Modifier.size(25.dp), + color = onBackgroundColor, + onClick = { + state.collapseSoft() + } + ) + + if (!queueTitle.isNullOrEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.now_playing), + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + color = onBackgroundColor + ) + Text( + text = queueTitle.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + color = onBackgroundColor + ) + } + } + if (mediaMetadata?.isLocal == false) { + ResizableIconButton( + icon = R.drawable.share, + modifier = Modifier.size(25.dp), + color = onBackgroundColor, + onClick = { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/watch?v=${mediaMetadata?.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + ) + } else { + ResizableIconButton( + icon = R.drawable.info, + modifier = Modifier.size(25.dp), + color = onBackgroundColor, + onClick = { + showDetailsDialog = true + } + ) + } + } + } + } + + if (fullScreenLyrics) { + Queue( + state = queueSheetState, + backgroundColor = + if (useBlackBackground) { + Color.Black + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + navController = navController, + onBackgroundColor = onBackgroundColor, + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/player/PlayerUI20.kt b/app/src/main/java/com/babelsoftware/loudly/ui/player/PlayerUI20.kt new file mode 100644 index 0000000..847e5a3 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/player/PlayerUI20.kt @@ -0,0 +1,1496 @@ +package com.babelsoftware.loudly.ui.player + +import android.Manifest +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.BitmapDrawable +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.BatteryManager +import androidx.annotation.RequiresPermission +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.rounded.PlaylistRemove +import androidx.compose.material.icons.rounded.VpnLock +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.navigation.NavController +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.LyricFontSizeKey +import com.babelsoftware.loudly.constants.LyricsBackgroundDimKey +import com.babelsoftware.loudly.constants.ShowLyricsKey +import com.babelsoftware.loudly.constants.SliderStyle +import com.babelsoftware.loudly.constants.SliderStyleKey +import com.babelsoftware.loudly.extensions.metadata +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.extensions.toggleRepeatMode +import com.babelsoftware.loudly.extensions.toggleShuffleMode +import com.babelsoftware.loudly.lyrics.LyricsUiState +import com.babelsoftware.loudly.lyrics.LyricsUtils +import com.babelsoftware.loudly.lyrics.LyricsViewModel +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.playback.StreakInfo +import com.babelsoftware.loudly.ui.component.BottomSheetState +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.PlayerSliderTrack +import com.babelsoftware.loudly.ui.component.ResizableIconButton +import com.babelsoftware.loudly.ui.menu.AddToPlaylistDialog +import com.babelsoftware.loudly.ui.menu.PlayerMenu +import com.babelsoftware.loudly.ui.theme.extractGradientColors +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.saket.squiggles.SquigglySlider +import java.util.Locale +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +private const val HQ_BITRATE = 100000 + +@Composable +fun PlayerUI20( + position: Long, + duration: Long, + navController: NavController, + bottomSheetState: BottomSheetState, + modifier: Modifier = Modifier, + lyricsViewModel: LyricsViewModel = hiltViewModel() +) { + var showPlaylist by remember { mutableStateOf(false) } + var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val context = LocalContext.current + var dominantColor by remember { mutableStateOf(null) } + + LaunchedEffect(mediaMetadata?.id) { + val currentMediaMetadata = mediaMetadata + if (currentMediaMetadata == null) { + dominantColor = null + return@LaunchedEffect + } + withContext(Dispatchers.IO) { + val result = (ImageLoader(context).execute( + ImageRequest.Builder(context) + .data(currentMediaMetadata.thumbnailUrl) + .allowHardware(false) + .build() + ).drawable as? BitmapDrawable)?.bitmap?.extractGradientColors() + + dominantColor = result?.firstOrNull() + } + } + + Box(modifier = modifier.fillMaxSize()) { + val currentMediaMetadata = mediaMetadata + if (currentMediaMetadata != null) { + AsyncImage( + model = currentMediaMetadata.thumbnailUrl, + contentDescription = "Background Picture", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(80.dp) + .alpha(0.6f) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + ) + } + AnimatedVisibility( + visible = !showLyrics, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)) + ) { + AnimatedContent( + targetState = showPlaylist, + transitionSpec = { + if (targetState) { + slideInVertically { height -> height } + fadeIn() togetherWith + slideOutVertically { height -> -height } + fadeOut() + } else { + slideInVertically { height -> -height } + fadeIn() togetherWith + slideOutVertically { height -> height } + fadeOut() + } + }, + label = "Player/Playlist" + ) { isPlaylistVisible -> + if (isPlaylistVisible) { + PlaylistScreen( + onClose = { showPlaylist = false }, + dominantColor = dominantColor ?: MaterialTheme.colorScheme.primary, + navController = navController, + bottomSheetState = bottomSheetState + ) + } else { + NowPlayingScreen( + position = position, + duration = duration, + navController = navController, + bottomSheetState = bottomSheetState, + onShowPlaylist = { showPlaylist = true }, + onClose = { bottomSheetState.collapseSoft() }, + dominantColor = dominantColor, + onToggleLyrics = { showLyrics = !showLyrics } + ) + } + } + } + AnimatedVisibility( + visible = showLyrics, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut() + ) { + LyricsScreen( + position = position, + viewModel = lyricsViewModel, + onDismiss = { showLyrics = false } + ) + } + } +} + +@Composable +fun NowPlayingScreen( + position: Long, + duration: Long, + navController: NavController, + bottomSheetState: BottomSheetState, + onShowPlaylist: () -> Unit, + onClose: () -> Unit, + dominantColor: Color?, + onToggleLyrics: () -> Unit +) { + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val isPlaying by playerConnection.isPlaying.collectAsState() + var sliderPosition by remember { mutableStateOf(null) } + var showDetailsDialog by remember { mutableStateOf(false) } + + if (showDetailsDialog) { + DetailsDialog( + onDismiss = { showDetailsDialog = false } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectVerticalDragGestures { change, dragAmount -> + if (dragAmount < -50) { + onShowPlaylist() + change.consume() + } + } + } + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Vertical)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PlayerTopBar(onClose = onClose) + Spacer(modifier = Modifier.height(16.dp)) + + PlayerArtwork( + mediaMetadata = mediaMetadata, + modifier = Modifier + .weight(1f) + .padding(horizontal = 32.dp) + ) { direction -> + if (direction > 0) playerConnection.player.seekToPrevious() + else playerConnection.player.seekToNext() + } + + SongInfo( + mediaMetadata = mediaMetadata, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 24.dp) + ) + + ContextualInfoRow( + navController = navController, + bottomSheetState = bottomSheetState, + onShowPlaylist = onShowPlaylist, + onShowDetailsDialog = { showDetailsDialog = true } + ) + Spacer(modifier = Modifier.height(8.dp)) + + ControlsCard( + position = sliderPosition ?: position, + duration = duration, + isPlaying = isPlaying, + dominantColor = dominantColor, + onSeek = { newPosition -> + playerConnection.player.seekTo(newPosition) + sliderPosition = null + }, + onToggleLyrics = onToggleLyrics, + navController = navController + ) + } +} + +@Composable +private fun PlayerTopBar(onClose: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onClose) { + Icon(painterResource(R.drawable.arrow_back), contentDescription = "Geri", tint = Color.White) + } + } +} + +@Composable +private fun PlayerArtwork( + mediaMetadata: MediaMetadata?, + modifier: Modifier = Modifier, + onSwipe: (direction: Int) -> Unit +) { + var offsetX by remember { mutableFloatStateOf(0f) } + val alpha by animateFloatAsState( + targetValue = 1f - (offsetX.coerceIn(-300f, 300f).absoluteValue / 300f) * 0.7f, + label = "ArtworkAlpha" + ) + + AnimatedContent( + targetState = mediaMetadata, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "Artwork", + modifier = modifier + .aspectRatio(1f) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + when { + offsetX > 150 -> onSwipe(1) + offsetX < -150 -> onSwipe(-1) + } + offsetX = 0f + }, + onHorizontalDrag = { _, dragAmount -> + offsetX += dragAmount + } + ) + } + .graphicsLayer { + shadowElevation = 16.dp.toPx() + shape = RoundedCornerShape(24.dp) + clip = true + } + ) { currentArtworkMetadata -> + AsyncImage( + model = currentArtworkMetadata?.thumbnailUrl, + contentDescription = "Album Thumbnail", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .alpha(alpha) + .offset(x = (offsetX / 2).dp) + ) + } +} + +@Composable +private fun SongInfo(mediaMetadata: MediaMetadata?, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AnimatedContent( + targetState = mediaMetadata?.title, + transitionSpec = { + (slideInVertically { it } + fadeIn()) togetherWith (slideOutVertically { -it } + fadeOut()) + }, + label = "Title" + ) { title -> + Text( + text = title ?: "", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.basicMarquee() + ) + } + Spacer(modifier = Modifier.height(4.dp)) + AnimatedContent( + targetState = mediaMetadata?.artists?.joinToString { it.name }, + transitionSpec = { + (slideInVertically { it } + fadeIn()) togetherWith (slideOutVertically { -it } + fadeOut()) + }, + label = "Artist" + ) { artist -> + Text( + text = artist ?: "", + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.basicMarquee() + ) + } + } +} + +@Composable +fun ContextualInfoRow( + navController: NavController, + bottomSheetState: BottomSheetState, + onShowPlaylist: () -> Unit, + onShowDetailsDialog: () -> Unit +) { + val playerConnection = LocalPlayerConnection.current ?: return + val menuState = LocalMenuState.current + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val currentFormat by playerConnection.currentFormat.collectAsState(initial = null) + + val showArtistChip = mediaMetadata?.artists?.any { it.id != null } == true + val bitrate = currentFormat?.bitrate + val isHq = bitrate != null && bitrate > HQ_BITRATE + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (showArtistChip || bitrate != null) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f) + ), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(IntrinsicSize.Min) + ) { + if (showArtistChip) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + navController.navigate("artist/${mediaMetadata!!.artists.first { it.id != null }.id}") + bottomSheetState.collapseSoft() + } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Icon( + painter = painterResource(R.drawable.artist), + contentDescription = stringResource(R.string.view_artist), + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.artist), + color = Color.White.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + } + } + if (bitrate != null && showArtistChip) { + HorizontalDivider( + modifier = Modifier + .fillMaxHeight() + .width(1.dp), + color = Color.White.copy(alpha = 0.25f) + ) + } + if (bitrate != null) { + val qualityRowModifier = if (!isHq) { + Modifier.clickable { + navController.navigate("settings/player") + bottomSheetState.collapseSoft() + } + } else { + Modifier + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = qualityRowModifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Icon( + painter = painterResource(if (isHq) R.drawable.high_quality else R.drawable.standart_quality), + contentDescription = "Quality", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = if (isHq) "HD" else "SD", + color = Color.White.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + } + } + } + } + } + + Spacer(Modifier.width(8.dp)) + + InfoChip( + text = stringResource(R.string.queue), + icon = R.drawable.queue_music, + color = Color.White, + onClick = onShowPlaylist + ) + + Spacer(Modifier.width(8.dp)) + + InfoChip( + icon = R.drawable.more_vert, + text = stringResource(R.string.menu), + color = Color.White, + onClick = { + menuState.show { + PlayerMenu( + mediaMetadata = mediaMetadata, + navController = navController, + bottomSheetState = bottomSheetState, + onShowDetailsDialog = onShowDetailsDialog, + onDismiss = menuState::dismiss + ) + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ControlsCard( + position: Long, + duration: Long, + isPlaying: Boolean, + dominantColor: Color?, + onSeek: (Long) -> Unit, + onToggleLyrics: () -> Unit, + navController: NavController +) { + var sliderPosition by remember { mutableStateOf(null) } + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.COMPOSE) + + Card( + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f) + ), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)), + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 16.dp, horizontal = 24.dp) + ) { + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = sliderPosition ?: position.toFloat(), + valueRange = 0f..(if (duration > 0) duration.toFloat() else 1f), + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + sliderPosition?.let { onSeek(it.toLong()) } + sliderPosition = null + }, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = SliderDefaults.colors() + ) + }, + modifier = Modifier.fillMaxWidth() + ) + } + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = sliderPosition ?: position.toFloat(), + valueRange = 0f..(if (duration > 0) duration.toFloat() else 1f), + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + sliderPosition?.let { onSeek(it.toLong()) } + sliderPosition = null + }, + squigglesSpec = SquigglySlider.SquigglesSpec( + amplitude = if (isPlaying) 2.dp else 0.dp, + strokeWidth = 3.dp, + ), + modifier = Modifier.fillMaxWidth() + ) + } + SliderStyle.COMPOSE -> { + Slider( + value = sliderPosition ?: position.toFloat(), + valueRange = 0f..(if (duration > 0) duration.toFloat() else 1f), + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + sliderPosition?.let { onSeek(it.toLong()) } + sliderPosition = null + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = makeTimeString(sliderPosition?.toLong() ?: position), + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.7f) + ) + Text( + text = makeTimeString(duration), + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.7f) + ) + } + Spacer(Modifier.height(16.dp)) + + MainControls(isPlaying = isPlaying, dominantColor = dominantColor) + Spacer(Modifier.height(16.dp)) + + SecondaryControlsRow( + dominantColor = dominantColor, + onToggleLyrics = onToggleLyrics, + navController = navController + ) + } + } +} + +@Composable +private fun MainControls(isPlaying: Boolean, dominantColor: Color?) { + val playerConnection = LocalPlayerConnection.current!! + val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() + val repeatMode by playerConnection.repeatMode.collectAsState() + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + val haptic = LocalHapticFeedback.current + val playbackState by playerConnection.playbackState.collectAsState() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceAround + ) { + ResizableIconButton( + icon = R.drawable.shuffle, + modifier = Modifier.size(28.dp), + color = if (shuffleModeEnabled) dominantColor ?: Color.White else Color.White.copy(alpha = 0.7f), + onClick = playerConnection.player::toggleShuffleMode + ) + + ResizableIconButton( + icon = R.drawable.skip_previous, + enabled = canSkipPrevious, + color = Color.White, + modifier = Modifier.size(40.dp), + onClick = playerConnection.player::seekToPrevious + ) + + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(Color.White) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (playbackState == Player.STATE_ENDED) { + playerConnection.player.seekTo(0, 0) + playerConnection.player.playWhenReady = true + } else { + playerConnection.player.togglePlayPause() + } + }, + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = isPlaying, + label = "PlayPauseIcon", + contentAlignment = Alignment.Center + ) { playing -> + Icon( + painter = painterResource(if (playing) R.drawable.pause else R.drawable.play), + contentDescription = "Play/Pause", + tint = Color.Black, + modifier = Modifier.size(40.dp) + ) + } + } + + ResizableIconButton( + icon = R.drawable.skip_next, + enabled = canSkipNext, + color = Color.White, + modifier = Modifier.size(40.dp), + onClick = playerConnection.player::seekToNext + ) + + ResizableIconButton( + icon = when (repeatMode) { + Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ALL -> R.drawable.repeat + Player.REPEAT_MODE_ONE -> R.drawable.repeat_one + else -> throw IllegalStateException() + }, + modifier = Modifier.size(28.dp), + color = if (repeatMode != Player.REPEAT_MODE_OFF) dominantColor ?: Color.White else Color.White.copy(alpha = 0.7f), + onClick = playerConnection.player::toggleRepeatMode + ) + } +} + +@Composable +private fun SecondaryControlsRow( + dominantColor: Color?, + onToggleLyrics: () -> Unit, + navController: NavController +) { + val playerConnection = LocalPlayerConnection.current ?: return + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + var showChoosePlaylistDialog by remember { mutableStateOf(false) } + + if (mediaMetadata != null) { + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + database.transaction { + insert(mediaMetadata!!) + } + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { + YouTube.addToPlaylist(it, mediaMetadata!!.id) + } + } + listOf(mediaMetadata!!.id) + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val isLiked = currentSong?.song?.liked == true + val likeColor by animateColorAsState( + targetValue = if (isLiked) dominantColor ?: MaterialTheme.colorScheme.error else Color.White.copy(alpha = 0.7f), + label = "LikeColor" + ) + TextButton(onClick = { playerConnection.toggleLike() }) { + Icon( + painter = if (isLiked) painterResource(R.drawable.favorite) else painterResource(R.drawable.favorite_border), + contentDescription = stringResource(R.string.like), + tint = likeColor, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.like), + color = likeColor, + modifier = Modifier + .widthIn(max = 75.dp) + .basicMarquee() + ) + } + + TextButton(onClick = { showChoosePlaylistDialog = true }) { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = stringResource(R.string.add_to_playlist), + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.add_to_playlist), + color = Color.White.copy(alpha = 0.7f), + modifier = Modifier + .widthIn(max = 75.dp) + .basicMarquee() + ) + } + TextButton(onClick = onToggleLyrics) { + Icon( + painter = painterResource(R.drawable.lyrics), + contentDescription = stringResource(R.string.lyrics), + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.lyrics), + color = Color.White.copy(alpha = 0.7f), + modifier = Modifier + .widthIn(max = 75.dp) + .basicMarquee() + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeBackground( + dismissValue: SwipeToDismissBoxValue, + icon: ImageVector, + text: String, + alignment: Alignment, + color: Color +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color) + .padding(horizontal = 24.dp), + contentAlignment = alignment + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(8.dp)) + Text( + text = text, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlaylistScreen( + onClose: () -> Unit, + dominantColor: Color, + navController: NavController, + bottomSheetState: BottomSheetState +) { + val playerConnection = LocalPlayerConnection.current ?: return + val queueWindows by playerConnection.queueWindows.collectAsState() + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() + val lazyListState = rememberLazyListState() + val queueTitle by playerConnection.queueTitle.collectAsState() + + LaunchedEffect(currentWindowIndex) { + if (currentWindowIndex != -1) { + lazyListState.animateScrollToItem(currentWindowIndex) + } + } + + val backgroundColor = MaterialTheme.colorScheme.surface + + Box( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .pointerInput(Unit) { + detectVerticalDragGestures { change, dragAmount -> + if (dragAmount > 50) { // Threshold for downward swipe + onClose() + change.consume() + } + } + } + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 280.dp, bottom = 16.dp) + ) { + itemsIndexed(queueWindows, key = { _, window -> window.mediaItem.mediaId }) { index, window -> + window.mediaItem.metadata?.let { metadata -> + val currentItem by rememberUpdatedState(window.mediaItem) + val dismissState = rememberSwipeToDismissBoxState() + + LaunchedEffect(dismissState.targetValue) { + if (dismissState.targetValue != SwipeToDismissBoxValue.Settled) { + when (dismissState.targetValue) { + SwipeToDismissBoxValue.StartToEnd -> { + val artistId = currentItem.metadata?.artists?.firstOrNull { it.id != null }?.id + if (artistId != null) { + navController.navigate("artist/$artistId") + bottomSheetState.collapseSoft() + } + } + SwipeToDismissBoxValue.EndToStart -> { + playerConnection.player.removeMediaItem(index) + } + else -> {} + } + dismissState.reset() + } + } + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + val direction = dismissState.dismissDirection + when (direction) { + SwipeToDismissBoxValue.StartToEnd -> { + SwipeBackground( + dismissValue = direction, + icon = Icons.Default.Person, + text = stringResource(R.string.view_artist), + alignment = Alignment.CenterStart, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + } + SwipeToDismissBoxValue.EndToStart -> { + SwipeBackground( + dismissValue = direction, + icon = Icons.Rounded.PlaylistRemove, + text = stringResource(R.string.remove_from_queue), + alignment = Alignment.CenterEnd, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + ) + } + SwipeToDismissBoxValue.Settled -> {} + } + }, + modifier = Modifier.animateItem() + ) { + SimplePlaylistItem( + metadata = metadata, + isActive = index == currentWindowIndex, + activeColor = dominantColor, + onClick = { + if (index == currentWindowIndex) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.player.seekToDefaultPosition(index) + if (!playerConnection.player.playWhenReady) { + playerConnection.player.playWhenReady = true + } + } + } + ) + } + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, backgroundColor) + ) + ) + ) + + val parallaxOffset = if (lazyListState.firstVisibleItemIndex == 0) { + lazyListState.firstVisibleItemScrollOffset * 0.5f + } else { + 0f + } + + Box(modifier = Modifier.graphicsLayer { translationY = parallaxOffset }) { + CurvedHeader( + onClose = onClose, + queueWindows = queueWindows, + queueTitle = queueTitle + ) + } + } +} + +@Composable +fun SimplePlaylistItem( + metadata: MediaMetadata, + isActive: Boolean, + activeColor: Color, + onClick: () -> Unit +) { + val inactiveColor = MaterialTheme.colorScheme.onSurface + val textColor by animateColorAsState( + targetValue = if (isActive) activeColor else inactiveColor, + label = "TextColor" + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = metadata.title, + color = textColor, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge + ) + } + Spacer(Modifier.width(16.dp)) + Text( + text = metadata.duration?.let { makeTimeString(it * 1000L) } ?: "0:00", + color = textColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +fun CurvedHeader( + onClose: () -> Unit, + queueWindows: List, + queueTitle: String? +) { + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val context = LocalContext.current + + val headerColor = MaterialTheme.colorScheme.surface + val onHeaderColor = MaterialTheme.colorScheme.onSurface + + Box( + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .clip(CurvedHeaderShape()) + .background(headerColor) + ) { + val currentMediaMetadata = mediaMetadata + if (currentMediaMetadata != null) { + AsyncImage( + model = currentMediaMetadata.thumbnailUrl, + contentDescription = "Albüm Kapağı", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .alpha(if (isSystemInDarkTheme()) 0.4f else 1f) + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, headerColor.copy(alpha = 0.8f), headerColor), + startY = 150f + ) + ) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), + verticalArrangement = Arrangement.SpaceBetween + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onClose) { + Icon(painterResource(id = R.drawable.arrow_back), contentDescription = "Geri", tint = onHeaderColor) + } + InfoChip( + icon = R.drawable.share, + text = stringResource(R.string.share_list), + color = onHeaderColor, + onClick = { + val shareText = buildString { + appendLine(queueTitle ?: "Playlist") + appendLine() + queueWindows.forEach { window -> + window.mediaItem.metadata?.let { metadata -> + appendLine("${metadata.title} - ${metadata.artists.joinToString { it.name }}") + } + } + } + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareText) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + } + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val currentMediaMetadata = mediaMetadata + if (currentMediaMetadata != null) { + Text( + text = queueTitle ?: "Playlist", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = onHeaderColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = currentMediaMetadata.artists.joinToString { it.name }, + style = MaterialTheme.typography.titleMedium, + color = onHeaderColor.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +class CurvedHeaderShape : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width, size.height - 60f) + quadraticTo( + x1 = size.width / 2f, y1 = size.height + 60f, + x2 = 0f, y2 = size.height - 60f + ) + close() + } + return Outline.Generic(path) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InfoChip( + modifier: Modifier = Modifier, + icon: Int? = null, + text: String? = null, + color: Color = LocalContentColor.current, + onClick: () -> Unit +) { + Card( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f) + ), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)), + modifier = modifier + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) { + if (icon != null) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = color + ) + } + if (text != null) { + if (icon != null) Spacer(Modifier.width(4.dp)) + Text( + text = text, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + color = color + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LyricsControls( + onDismiss: () -> Unit, + viewModel: LyricsViewModel +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val playerConnection = LocalPlayerConnection.current ?: return + val lyricsEntity by playerConnection.currentLyrics.collectAsState() + + var fontSize by rememberPreference(LyricFontSizeKey, 22) + var backgroundDim by rememberPreference(LyricsBackgroundDimKey, 0) + var showLanguageSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + val onSurfaceColor = Color.White + val onSurfaceVariantColor = Color.White.copy(alpha = 0.7f) + + Card( + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.2f) + ), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.25f)), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("A-", color = onSurfaceVariantColor, modifier = Modifier.clickable { if (fontSize > 14) fontSize -= 2 }) + SquigglySlider( + value = fontSize.toFloat(), + onValueChange = { fontSize = it.roundToInt() }, + valueRange = 14f..40f, + modifier = Modifier.weight(1f) + ) + Text("A+", color = onSurfaceVariantColor, modifier = Modifier.clickable { if (fontSize < 40) fontSize += 2 }) + + Spacer(modifier = Modifier.width(16.dp)) + + Icon(painterResource(R.drawable.dark_mode), contentDescription = "Dim", tint = onSurfaceVariantColor) + SquigglySlider( + value = backgroundDim.toFloat(), + onValueChange = { backgroundDim = it.roundToInt() }, + valueRange = 0f..100f, + modifier = Modifier.weight(1f) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + IconButton(onClick = { showLanguageSheet = true }) { + Icon( + painter = painterResource(R.drawable.translate), + contentDescription = stringResource(R.string.translate), + tint = onSurfaceVariantColor + ) + } + IconButton(onClick = { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, lyricsEntity?.lyrics ?: "") + type = "text/plain" + } + context.startActivity(Intent.createChooser(sendIntent, null)) + }) { + Icon(painterResource(R.drawable.share), contentDescription = stringResource(R.string.share), tint = onSurfaceVariantColor) + } + IconButton(onClick = { + clipboardManager.setText(AnnotatedString(lyricsEntity?.lyrics ?: "")) + }) { + Icon(painterResource(R.drawable.content_copy), contentDescription = stringResource(R.string.copy), tint = onSurfaceVariantColor) + } + IconButton(onClick = { playerConnection.saveCurrentLyrics() }) { + Icon(painterResource(R.drawable.save), contentDescription = "Save Offline", tint = onSurfaceVariantColor) + } + IconButton(onClick = onDismiss) { + Icon(painterResource(R.drawable.arrow_upward), contentDescription = "Back to Player", tint = onSurfaceColor) + } + } + } + if (showLanguageSheet) { + ModalBottomSheet( + onDismissRequest = { showLanguageSheet = false }, + sheetState = sheetState + ) { + LanguageSelectionSheet( + languages = viewModel.targetLanguages, + onLanguageSelected = { languageCode -> + viewModel.translateLyrics(languageCode) + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + showLanguageSheet = false + } + } + } + ) + } + } +} + +@Composable +fun LanguageSelectionSheet( + languages: List, + onLanguageSelected: (String) -> Unit +) { + LazyColumn { + items(languages) { languageCode -> + ListItem( + headlineContent = { + Text(text = Locale(languageCode).displayLanguage) + }, + modifier = Modifier.clickable { onLanguageSelected(languageCode) } + ) + } + } +} + +@Composable +private fun LyricsScreen( + position: Long, + viewModel: LyricsViewModel, + modifier: Modifier = Modifier, + onDismiss: () -> Unit +) { + val lyricsUiState by viewModel.lyricsState.collectAsState() + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + LaunchedEffect(mediaMetadata?.id) { + mediaMetadata?.let { + viewModel.loadLyricsFor(it) + } + } + + val fontSize by rememberPreference(LyricFontSizeKey, 22) + val backgroundDim by rememberPreference(LyricsBackgroundDimKey, 0) + val onSurfaceColor = Color.White + val dimColor = if (isSystemInDarkTheme()) Color.Black else MaterialTheme.colorScheme.surface + + Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)) + .background(dimColor.copy(alpha = backgroundDim.toFloat() / 100f)) + .clickable(onClick = onDismiss) + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + .windowInsetsPadding(WindowInsets.systemBars), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + when (val state = lyricsUiState) { + is LyricsUiState.Loading -> { + CircularProgressIndicator(color = onSurfaceColor) + } + is LyricsUiState.Error -> { + Text( + text = state.message, + color = onSurfaceColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + } + is LyricsUiState.Success -> { + val parsedLyrics = state.entries + if (parsedLyrics.isEmpty()) { + Text( + text = "No lyrics were found for this song.", + color = onSurfaceColor.copy(alpha = 0.7f), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + } else { + val listState = rememberLazyListState() + val currentLineIndex by remember(parsedLyrics, position) { + derivedStateOf { LyricsUtils.findCurrentLineIndex(parsedLyrics, position) } + } + + LaunchedEffect(currentLineIndex) { + if (currentLineIndex != -1 && currentLineIndex < listState.layoutInfo.totalItemsCount) { + listState.animateScrollToItem(index = currentLineIndex, scrollOffset = -250) + } + } + + LazyColumn( + state = listState, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize() + ) { + item { Spacer(modifier = Modifier.height(200.dp)) } + itemsIndexed(parsedLyrics) { index, line -> + if (line.isTranslation) return@itemsIndexed + val isActive = index == currentLineIndex + val color by animateColorAsState(targetValue = if (isActive) onSurfaceColor else onSurfaceColor.copy(alpha = 0.6f), label = "") + val dynamicFontSize by animateFloatAsState(targetValue = if (isActive) (fontSize + 4).toFloat() else fontSize.toFloat(), label = "") + val fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal + + Text( + text = line.text, + color = color, + fontSize = dynamicFontSize.sp, + fontWeight = fontWeight, + textAlign = TextAlign.Center, + lineHeight = (dynamicFontSize * 1.5).sp, + modifier = Modifier.clickable { playerConnection.player.seekTo(line.time) } + ) + } + item { Spacer(modifier = Modifier.height(200.dp)) } + } + } + } + } + } + LyricsControls(onDismiss = onDismiss, viewModel = viewModel) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/player/Queue.kt b/app/src/main/java/com/babelsoftware/loudly/ui/player/Queue.kt new file mode 100644 index 0000000..3322b32 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/player/Queue.kt @@ -0,0 +1,824 @@ +package com.babelsoftware.loudly.ui.player + +import android.annotation.SuppressLint +import android.text.format.Formatter +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachReversed +import androidx.compose.ui.window.DialogProperties +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.LockQueueKey +import com.babelsoftware.loudly.constants.PlayerStyle +import com.babelsoftware.loudly.constants.PlayerStyleKey +import com.babelsoftware.loudly.constants.ShowLyricsKey +import com.babelsoftware.loudly.constants.SliderStyle +import com.babelsoftware.loudly.constants.SliderStyleKey +import com.babelsoftware.loudly.extensions.metadata +import com.babelsoftware.loudly.extensions.move +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.ui.component.BottomSheet +import com.babelsoftware.loudly.ui.component.BottomSheetState +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.MediaMetadataListItem +import com.babelsoftware.loudly.ui.component.PlayerSliderTrack +import com.babelsoftware.loudly.ui.menu.MediaMetadataMenu +import com.babelsoftware.loudly.ui.menu.QueueSelectionMenu +import com.babelsoftware.loudly.utils.joinByBullet +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import me.saket.squiggles.SquigglySlider +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState +import kotlin.math.roundToInt + +@SuppressLint("AutoboxingStateCreation") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun Queue( + state: BottomSheetState, + backgroundColor: Color, + navController: NavController, + modifier: Modifier = Modifier, + onBackgroundColor: Color, +) { + val haptic = LocalHapticFeedback.current + val menuState = LocalMenuState.current + + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() + var lockQueue by rememberPreference(LockQueueKey, defaultValue = false) + + var inSelectMode by remember { + mutableStateOf(false) + } + val selection = remember { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + var showDetailsDialog by remember { mutableStateOf(false) } + if (showDetailsDialog) { + DetailsDialog( + onDismiss = { showDetailsDialog = false } + ) + } + + var showLyrics by rememberPreference(ShowLyricsKey, false) + + val (playerStyle) = rememberEnumPreference(PlayerStyleKey, defaultValue = PlayerStyle.NEW) + + var showSleepTimerDialog by remember { mutableStateOf(false) } + var sleepTimerValue by remember { mutableStateOf(30f) } + val sleepTimerEnabled = remember( + playerConnection.service.sleepTimer.triggerTime, + playerConnection.service.sleepTimer.pauseWhenSongEnd + ) { + playerConnection.service.sleepTimer.isActive + } + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.DEFAULT) + + var sleepTimerTimeLeft by remember { + mutableLongStateOf(0L) + } + + LaunchedEffect(sleepTimerEnabled) { + if (sleepTimerEnabled) { + while (isActive) { + sleepTimerTimeLeft = if (playerConnection.service.sleepTimer.pauseWhenSongEnd) { + playerConnection.player.duration - playerConnection.player.currentPosition + } else { + playerConnection.service.sleepTimer.triggerTime - System.currentTimeMillis() + } + delay(1000L) + } + } + } + + if (showSleepTimerDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showSleepTimerDialog = false }, + icon = { + Icon( + painter = painterResource(R.drawable.bedtime), + contentDescription = null + ) + }, + title = { Text(stringResource(R.string.sleep_timer)) }, + confirmButton = { + TextButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.sleepTimer.start(sleepTimerValue.roundToInt()) + } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { showSleepTimerDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pluralStringResource( + R.plurals.minute, + sleepTimerValue.roundToInt(), + sleepTimerValue.roundToInt() + ), + style = MaterialTheme.typography.bodyLarge + ) + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + steps = (120 - 5) / 5 - 1, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = SliderDefaults.colors() + ) + }, + ) + } + + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + ) + } + + SliderStyle.COMPOSE -> { + Slider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + steps = (120 - 5) / 5 - 1, + ) + } + } + + OutlinedButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.sleepTimer.start(-1) + } + ) { + Text(stringResource(R.string.end_of_song)) + } + } + } + ) + } + + BottomSheet( + state = state, + backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), + modifier = modifier, + collapsedContent = { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + if (playerStyle == PlayerStyle.NEW) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 30.dp) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ), + ) { + TextButton(onClick = { state.expandSoft() }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = onBackgroundColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.queue), + color = onBackgroundColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .sizeIn(maxWidth = 80.dp) + .basicMarquee() + ) + } + } + + TextButton( + onClick = { + if (sleepTimerEnabled) { + playerConnection.service.sleepTimer.clear() + } else { + showSleepTimerDialog = true + } + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.bedtime), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = onBackgroundColor + ) + Spacer(modifier = Modifier.width(4.dp)) + if (sleepTimerEnabled) { + Text( + text = makeTimeString(sleepTimerTimeLeft), + color = onBackgroundColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .sizeIn(maxWidth = 80.dp) + .basicMarquee() + ) + } else { + Text( + text = stringResource(R.string.sleep_timer), + color = onBackgroundColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .sizeIn(maxWidth = 80.dp) + .basicMarquee() + ) + } + } + } + + TextButton(onClick = { showLyrics = !showLyrics }) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.lyrics), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = onBackgroundColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.lyrics), + color = onBackgroundColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .sizeIn(maxWidth = 80.dp) + .basicMarquee() + ) + } + } + } + } else { + IconButton(onClick = { state.expandSoft() }) { + Icon( + painter = painterResource(R.drawable.expand_less), + tint = onBackgroundColor, + contentDescription = null, + ) + } + } + } + } + ) { + val queueTitle by playerConnection.queueTitle.collectAsState() + val queueWindows by playerConnection.queueWindows.collectAsState() + val mutableQueueWindows = remember { mutableStateListOf() } + val queueLength = remember(queueWindows) { + queueWindows.sumOf { it.mediaItem.metadata!!.duration } + } + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + var dragInfo by remember { + mutableStateOf?>(null) + } + val reorderableState = rememberReorderableLazyListState( + lazyListState = lazyListState, + scrollThresholdPadding = WindowInsets.systemBars.add( + WindowInsets( + top = ListItemHeight, + bottom = ListItemHeight + ) + ).asPaddingValues() + ) { from, to -> + val currentDragInfo = dragInfo + dragInfo = if (currentDragInfo == null) { + from.index to to.index + } else { + currentDragInfo.first to to.index + } + + mutableQueueWindows.move(from.index, to.index) + } + + LaunchedEffect(reorderableState.isAnyItemDragging) { + if (!reorderableState.isAnyItemDragging) { + dragInfo?.let { (from, to) -> + if (!playerConnection.player.shuffleModeEnabled) { + playerConnection.player.moveMediaItem(from, to) + } else { + playerConnection.player.setShuffleOrder( + DefaultShuffleOrder( + queueWindows.map { it.firstPeriodIndex }.toMutableList() + .move(from, to).toIntArray(), + System.currentTimeMillis() + ) + ) + } + dragInfo = null + } + } + } + + LaunchedEffect(queueWindows) { + mutableQueueWindows.apply { + clear() + addAll(queueWindows) + } + selection.fastForEachReversed { uidHash -> + if (queueWindows.find { it.uid.hashCode() == uidHash } == null) { + selection.remove(uidHash) + } + } + } + + LaunchedEffect(mutableQueueWindows) { + if (currentWindowIndex != -1) { + lazyListState.scrollToItem(currentWindowIndex) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(backgroundColor), + ) { + LazyColumn( + state = lazyListState, + contentPadding = WindowInsets.systemBars + .add( + WindowInsets( + top = ListItemHeight, + bottom = ListItemHeight + ) + ) + .asPaddingValues(), + modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) + ) { + itemsIndexed( + items = mutableQueueWindows, + key = { _, item -> item.uid.hashCode() } + ) { index, window -> + ReorderableItem( + state = reorderableState, + key = window.uid.hashCode() + ) { + val currentItem by rememberUpdatedState(window) + val dismissState = rememberSwipeToDismissBoxState( + positionalThreshold = { totalDistance -> totalDistance }, + confirmValueChange = { dismissValue -> + if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) { + playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) + } + true + } + ) + + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(window.uid.hashCode()) + } else { + selection.remove(window.uid.hashCode()) + } + } + + val content = @Composable { + MediaMetadataListItem( + mediaMetadata = window.mediaItem.metadata!!, + isActive = index == currentWindowIndex, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = window.uid.hashCode() in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + MediaMetadataMenu( + mediaMetadata = window.mediaItem.metadata!!, + navController = navController, + bottomSheetState = state, + onDismiss = menuState::dismiss, + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + + if (!lockQueue) { + IconButton( + onClick = { }, + modifier = Modifier.draggableHandle() + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null + ) + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(window.uid.hashCode() !in selection) + } else { + coroutineScope.launch(Dispatchers.Main) { + if (index == currentWindowIndex) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.player.seekToDefaultPosition( + window.firstPeriodIndex + ) + playerConnection.player.playWhenReady = true + } + } + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + ) + } + + if (!lockQueue && !inSelectMode) { + SwipeToDismissBox( + state = dismissState, + backgroundContent = {}, + content = { content() } + ) + } else { + content() + } + } + } + } + + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.95f) + ) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.Companion + .height(ListItemHeight) + .padding(horizontal = 6.dp) + ) { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + Text( + text = pluralStringResource( + R.plurals.n_selected, + selection.size, + selection.size + ), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = queueWindows.size == selection.size, + onCheckedChange = { + if (queueWindows.size == selection.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(queueWindows.map { it.uid.hashCode() }) + } + } + ) + } else { + Column( + modifier = Modifier + .padding(horizontal = 6.dp) + .weight(1f) + ) { + if (!queueTitle.isNullOrEmpty()) { + Text( + text = queueTitle.orEmpty(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Text( + text = joinByBullet( + pluralStringResource( + R.plurals.n_song, + queueWindows.size, + queueWindows.size + ), makeTimeString(queueLength * 1000L) + ), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() + + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)) + .fillMaxWidth() + .height( + ListItemHeight + + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() + ) + .align(Alignment.BottomCenter) + .clickable { + onExitSelectionMode() + state.collapseSoft() + } + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + .padding(12.dp) + ) { + IconButton( + modifier = Modifier.align(Alignment.CenterStart), + onClick = { + coroutineScope.launch { + lazyListState.animateScrollToItem( + if (playerConnection.player.shuffleModeEnabled) playerConnection.player.currentMediaItemIndex else 0 + ) + }.invokeOnCompletion { + playerConnection.player.shuffleModeEnabled = + !playerConnection.player.shuffleModeEnabled + } + } + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.alpha(if (shuffleModeEnabled) 1f else 0.5f) + ) + } + + Icon( + painter = painterResource(R.drawable.expand_more), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + + if (inSelectMode) { + IconButton( + enabled = selection.size > 0, + onClick = { + menuState.show { + QueueSelectionMenu( + navController = navController, + selection = selection.mapNotNull { uidHash -> + mutableQueueWindows.find { it.uid.hashCode() == uidHash } + }, + onExitSelectionMode = onExitSelectionMode, + onDismiss = menuState::dismiss, + ) + } + }, + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + } else { + IconButton( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = { + lockQueue = !lockQueue + } + ) { + Icon( + painter = if (lockQueue) painterResource(R.drawable.lock) else painterResource( + R.drawable.lock_open + ), + contentDescription = null, + ) + } + } + } + } +} + +@Composable +fun DetailsDialog( + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val currentFormat by playerConnection.currentFormat.collectAsState() + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = onDismiss, + icon = { Icon(painter = painterResource(R.drawable.info), contentDescription = null) }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { + Column( + modifier = Modifier + .sizeIn(minWidth = 280.dp, maxWidth = 560.dp) + .verticalScroll(rememberScrollState()) + ) { + listOf( + stringResource(R.string.song_title) to mediaMetadata?.title, + stringResource(R.string.song_artists) to mediaMetadata?.artists?.joinToString { it.name }, + stringResource(R.string.media_id) to mediaMetadata?.id, + stringResource(R.string.mime_type) to currentFormat?.mimeType, + stringResource(R.string.codecs) to currentFormat?.mimeType?.substringAfter("codecs=\"", "")?.substringBefore("\""), + stringResource(R.string.bitrate) to currentFormat?.bitrate?.let { "${it / 1000} Kbps" }, + stringResource(R.string.sample_rate) to currentFormat?.audioSampleRate?.let { "${"%.1f".format(it / 1000.0)} kHz" }, + stringResource(R.string.volume) to "${(playerConnection.player.volume * 100).toInt()}%", + stringResource(R.string.file_size) to currentFormat?.contentLength?.let { Formatter.formatShortFileSize(context, it) } + ).forEach { (label, text) -> + val displayText = if (text.isNullOrBlank()) stringResource(R.string.unknown) else text + Text( + text = label, + style = MaterialTheme.typography.labelMedium + ) + Text( + text = displayText, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(displayText)) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() + } + ) + Spacer(Modifier.height(8.dp)) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/player/Thumbnail.kt b/app/src/main/java/com/babelsoftware/loudly/ui/player/Thumbnail.kt new file mode 100644 index 0000000..780cd15 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/player/Thumbnail.kt @@ -0,0 +1,410 @@ +package com.babelsoftware.loudly.ui.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.PlayerHorizontalPadding +import com.babelsoftware.loudly.constants.PlayerStyle +import com.babelsoftware.loudly.constants.PlayerStyleKey +import com.babelsoftware.loudly.constants.ShowLyricsKey +import com.babelsoftware.loudly.constants.SwipeThumbnailKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadiusV2Key +import com.babelsoftware.loudly.ui.component.AsyncLocalImage +import com.babelsoftware.loudly.ui.component.Lyrics +import com.babelsoftware.loudly.ui.utils.imageCache +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.R +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +@Composable +fun Thumbnail( + sliderPositionProvider: () -> Long?, + modifier: Modifier = Modifier, + showLyricsOnClick: Boolean = false, + contentScale: ContentScale = ContentScale.Fit, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val currentView = LocalView.current + val context = LocalContext.current + + val error: PlaybackException? by playerConnection.error.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + var showLyrics by rememberPreference(ShowLyricsKey, false) + val swipeThumbnail by rememberPreference(SwipeThumbnailKey, true) + val thumbnailCornerRadiusV2 by rememberPreference(ThumbnailCornerRadiusV2Key, 6) + val (playerStyle) = rememberEnumPreference(PlayerStyleKey, defaultValue = PlayerStyle.NEW) + + val thumbnailAlpha by animateFloatAsState( + targetValue = if (showLyrics) 0.6f else 1f, + animationSpec = tween(300), + label = "thumbnailAlpha" + ) + + val thumbnailScale by animateFloatAsState( + targetValue = if (showLyrics) 0.9f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "thumbnailScale" + ) + + var offsetX by remember { mutableFloatStateOf(0f) } + + var currentMediaItem by remember { mutableStateOf(null) } + val layoutDirection = LocalLayoutDirection.current + var showSeekEffect by remember { mutableStateOf(false) } + var seekDirection by remember { mutableStateOf("") } + + LaunchedEffect(playerConnection.player.currentMediaItemIndex) { + currentMediaItem = playerConnection.player.currentMediaItem + } + + DisposableEffect(showLyrics) { + currentView.keepScreenOn = showLyrics + onDispose { currentView.keepScreenOn = false } + } + + Box(modifier = modifier) { + AnimatedVisibility( + visible = !showLyrics && error == null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = PlayerHorizontalPadding) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragCancel = { + offsetX = 0f + }, + onHorizontalDrag = { _, dragAmount -> + if (swipeThumbnail) { + offsetX += dragAmount + } + }, + onDragEnd = { + println(offsetX) + if (offsetX > 300) { + if (playerConnection.player.previousMediaItemIndex != -1) { + playerConnection.player.seekToPreviousMediaItem() + } + } else if (offsetX < -300) { + if (playerConnection.player.nextMediaItemIndex != -1) { + playerConnection.player.seekToNext() + } + } + offsetX = 0f + }, + ) + }, + ) { + if (mediaMetadata?.isLocal == false) { + if (playerStyle == PlayerStyle.NEW) { + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .offset { IntOffset(offsetX.roundToInt(), 0) } + .fillMaxWidth() + .aspectRatio(1f) + .graphicsLayer { + translationX = offsetX * 0.5f + alpha = thumbnailAlpha + scaleX = thumbnailScale + scaleY = thumbnailScale + } + .clip(RoundedCornerShape(thumbnailCornerRadiusV2 * 2)) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + val currentPosition = + playerConnection.player.currentPosition + if ((layoutDirection == LayoutDirection.Ltr && offset.x < size.width / 2) || + (layoutDirection == LayoutDirection.Rtl && offset.x > size.width / 2) + ) { + playerConnection.player.seekTo( + (currentPosition - 5000).coerceAtLeast( + 0 + ) + ) + seekDirection = + context.getString(R.string.seek_backward) + } else { + playerConnection.player.seekTo( + (currentPosition + 5000).coerceAtMost( + playerConnection.player.duration + ) + ) + seekDirection = + context.getString(R.string.seek_forward) + } + showSeekEffect = true + } + ) + } + ) + } else { + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .offset { IntOffset(offsetX.roundToInt(), 0) } + .fillMaxWidth() + .aspectRatio(1f) + .graphicsLayer { + translationX = offsetX * 0.5f + alpha = thumbnailAlpha + scaleX = thumbnailScale + scaleY = thumbnailScale + } + .clip(RoundedCornerShape(thumbnailCornerRadiusV2 * 2)) + .clickable(enabled = showLyricsOnClick) { showLyrics = !showLyrics } + ) + } + } else { + if (mediaMetadata?.isLocal == true) { + if (playerStyle == PlayerStyle.NEW) { + mediaMetadata.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it?.localPath, false) }, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .offset { IntOffset(offsetX.roundToInt(), 0) } + .fillMaxWidth() + .aspectRatio(1f) + .graphicsLayer { + translationX = offsetX * 0.5f + alpha = thumbnailAlpha + scaleX = thumbnailScale + scaleY = thumbnailScale + } + .clip(RoundedCornerShape(thumbnailCornerRadiusV2 * 2)) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + val currentPosition = + playerConnection.player.currentPosition + if ((layoutDirection == LayoutDirection.Ltr && offset.x < size.width / 2) || + (layoutDirection == LayoutDirection.Rtl && offset.x > size.width / 2) + ) { + playerConnection.player.seekTo( + (currentPosition - 5000).coerceAtLeast( + 0 + ) + ) + seekDirection = + context.getString(R.string.seek_backward) + } else { + playerConnection.player.seekTo( + (currentPosition + 5000).coerceAtMost( + playerConnection.player.duration + ) + ) + seekDirection = + context.getString(R.string.seek_forward) + } + showSeekEffect = true + } + ) + } + ) + } + } else { + mediaMetadata.let { + AsyncLocalImage( + image = { imageCache.getLocalThumbnail(it?.localPath, false) }, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .offset { IntOffset(offsetX.roundToInt(), 0) } + .fillMaxWidth() + .aspectRatio(1f) + .graphicsLayer { + translationX = offsetX * 0.5f + alpha = thumbnailAlpha + scaleX = thumbnailScale + scaleY = thumbnailScale + } + .clip(RoundedCornerShape(thumbnailCornerRadiusV2 * 2)) + .clickable(enabled = showLyricsOnClick) { + showLyrics = !showLyrics + } + ) + } + } + } + } + } + } + + LaunchedEffect(showSeekEffect) { + if (showSeekEffect) { + delay(1000) + showSeekEffect = false + } + } + + AnimatedVisibility( + visible = showSeekEffect, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center) + ) { + Text( + text = seekDirection, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier + .background(Color.Black.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) + } + + AnimatedVisibility( + visible = showLyrics && error == null, + enter = fadeIn() + expandHorizontally(expandFrom = Alignment.CenterHorizontally), + exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.CenterHorizontally), + modifier = Modifier.fillMaxSize() + ) { + Lyrics( + sliderPositionProvider = sliderPositionProvider, + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) + } + + AnimatedVisibility( + visible = !showLyrics && error != null, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it / 2 }, + modifier = Modifier + .padding(32.dp) + .align(Alignment.Center) + ) { + error?.let { playbackException -> + PlaybackError( + error = playbackException.message ?: context.getString(R.string.error_unknown), + retry = { playerConnection.player.prepare() }, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + ) + } + } + } +} + +@Composable +fun PlaybackError( + error: String, + retry: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(24.dp) + ) { + Text( + text = stringResource(R.string.error_unknown), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = retry, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text(text = stringResource(R.string.retry)) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/AlbumScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/AlbumScreen.kt new file mode 100644 index 0000000..64afb00 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/AlbumScreen.kt @@ -0,0 +1,1090 @@ +@file:Suppress("NAME_SHADOWING") + +package com.babelsoftware.loudly.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AlbumThumbnailSize +import com.babelsoftware.loudly.constants.PlayerStyle +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.AlbumWithSongs +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.LocalAlbumRadio +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.shimmer.ButtonPlaceholder +import com.babelsoftware.loudly.ui.component.shimmer.ListItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.component.shimmer.TextPlaceholder +import com.babelsoftware.loudly.ui.menu.AlbumMenu +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.viewmodels.AlbumViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlbumScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AlbumViewModel = hiltViewModel(), + playerStyle: PlayerStyle +) { + if (playerStyle == PlayerStyle.UI_2_0) { + AlbumScreenUI20(navController = navController, scrollBehavior = scrollBehavior, viewModel = viewModel) + } else { + AlbumScreenLegacy(navController = navController, scrollBehavior = scrollBehavior, viewModel = viewModel) + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AlbumScreenLegacy( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AlbumViewModel, +) { + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + + val scope = rememberCoroutineScope() + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val albumWithSongs by viewModel.albumWithSongs.collectAsState() + val otherVersions by viewModel.otherVersions.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + val state = rememberLazyListState() + val showTopBarTitle by remember { + derivedStateOf { + state.firstVisibleItemIndex > 0 + } + } + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, Int>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + LaunchedEffect(albumWithSongs) { + val songs = albumWithSongs?.songs?.map { it.id } + if (songs.isNullOrEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it]?.state == Download.STATE_QUEUED + || downloads[it]?.state == Download.STATE_DOWNLOADING + || downloads[it]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + LazyColumn( + state = state, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + val currentAlbumWithSongs = albumWithSongs + if (currentAlbumWithSongs != null && currentAlbumWithSongs.songs.isNotEmpty()) { + item { + Column( + modifier = Modifier.padding(12.dp) + ) { + AsyncImage( + model = currentAlbumWithSongs.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(alignment = Alignment.CenterHorizontally) + ) + + Spacer(Modifier.height(12.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizeText( + text = currentAlbumWithSongs.album.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + Text(buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ).toSpanStyle() + ) { + currentAlbumWithSongs.artists.fastForEachIndexed { index, artist -> + val link = LinkAnnotation.Clickable(artist.id) { + navController.navigate("artist/${artist.id}") + } + withLink(link) { + append(artist.name) + } + if (index != currentAlbumWithSongs.artists.lastIndex) { + append(", ") + } + } + } + }) + + if (currentAlbumWithSongs.album.year != null) { + Text( + text = currentAlbumWithSongs.album.year.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + LocalAlbumRadio(currentAlbumWithSongs) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.play) + ) + } + + Button( + onClick = { + playerConnection.playQueue( + LocalAlbumRadio(currentAlbumWithSongs.copy(songs = currentAlbumWithSongs.songs.shuffled())) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + + itemsIndexed( + items = currentAlbumWithSongs.songs, + key = { _, song -> song.id } + ) { index, song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(index) + } else { + selection.remove(index) + } + } + if (index == 0) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, + currentAlbumWithSongs.songs.size, + currentAlbumWithSongs.songs.size + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal + ) + } + } + SongListItem( + song = song, + albumIndex = index + 1, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = index in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(index !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + LocalAlbumRadio(currentAlbumWithSongs, startIndex = index) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + ) + } + + if (otherVersions.isNotEmpty()) { + item { + NavigationTitle( + title = stringResource(R.string.other_versions), + ) + } + item { + LazyRow { + items( + items = otherVersions, + key = { it.id }, + ) { item -> + YouTubeGridItem( + item = item, + isActive = mediaMetadata?.album?.id == item.id, + isPlaying = isPlaying, + coroutineScope = scope, + modifier = + Modifier + .combinedClickable( + onClick = { navController.navigate("album/${item.id}") }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } + } + } else { + item { + ShimmerHost { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } + + Spacer(Modifier.padding(8.dp)) + + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + + TopAppBar( + title = { + if (inSelectMode) { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + } else if (showTopBarTitle) { + Text(albumWithSongs?.album?.title.orEmpty()) + } + }, + navigationIcon = { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + } else { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == albumWithSongs?.songs?.size, + onCheckedChange = { + albumWithSongs?.let { currentAlbumWithSongs -> + if (selection.size == currentAlbumWithSongs.songs.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(currentAlbumWithSongs.songs.indices) + } + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { index -> + albumWithSongs?.songs?.getOrNull(index) + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else { + IconButton(onClick = { + albumWithSongs?.album?.let { album -> + database.query { update(album.toggleLike()) } + } + }) { + Icon( + painter = painterResource(if (albumWithSongs?.album?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + contentDescription = stringResource(R.string.favorite) + ) + } + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton( + onClick = { + albumWithSongs?.let { albumData -> + albumData.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + }, + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = stringResource(R.string.remove_download), + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton( + onClick = { + albumWithSongs?.let { albumData -> + albumData.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + } + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + ) + } + } + + else -> { + IconButton( + onClick = { + albumWithSongs?.let { albumData -> + albumData.songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder( + song.id, + song.id.toUri() + ) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + } + }, + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = stringResource(R.string.download) + ) + } + } + } + IconButton( + onClick = { + albumWithSongs?.let { albumData -> + menuState.show { + AlbumMenu( + originalAlbum = Album( + albumData.album, + albumData.artists + ), + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = stringResource(R.string.menu) + ) + } + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.9f) + ) + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun AlbumScreenUI20( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AlbumViewModel +) { + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val albumWithSongs by viewModel.albumWithSongs.collectAsState() + val otherVersions by viewModel.otherVersions.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { mutableIntStateOf(Download.STATE_STOPPED) } + + val state = rememberLazyListState() + val showTopBarTitle by remember { derivedStateOf { state.firstVisibleItemIndex > 0 } } + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, Int>(save = { it.toList() }, restore = { it.toMutableStateList() }) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + LaunchedEffect(albumWithSongs) { + val songs = albumWithSongs?.songs?.map { it.id } + if (songs.isNullOrEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) Download.STATE_COMPLETED + else if (songs.all { + downloads[it]?.state == Download.STATE_QUEUED + || downloads[it]?.state == Download.STATE_DOWNLOADING + || downloads[it]?.state == Download.STATE_COMPLETED + }) Download.STATE_DOWNLOADING + else Download.STATE_STOPPED + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.fillMaxSize()) { + val albumData = albumWithSongs + if (albumData != null) { + AsyncImage( + model = albumData.album.thumbnailUrl, + contentDescription = "Album Background", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(70.dp) + .alpha(0.55f) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + ) + } + + LazyColumn( + state = state, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item { + Spacer(modifier = Modifier.height(80.dp)) + } + if (albumData != null && albumData.songs.isNotEmpty()) { + item { + AlbumHeaderUI20( + albumWithSongs = albumData, + navController = navController, + downloadState = downloadState, + onDownloadClick = { + if (downloadState == Download.STATE_COMPLETED || downloadState == Download.STATE_DOWNLOADING) { + albumData.songs.forEach { song -> + DownloadService.sendRemoveDownload(context, ExoDownloadService::class.java, song.id, false) + } + } else { + albumData.songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload(context, ExoDownloadService::class.java, downloadRequest, false) + } + } + } + ) + } + + item { + Text( + text = stringResource(R.string.top_songs), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp, end = 16.dp) + ) + } + + itemsIndexed( + items = albumData.songs, + key = { _, song -> song.id } + ) { index, song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) selection.add(index) else selection.remove(index) + } + SongListItem( + song = song, + albumIndex = index + 1, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + if (inSelectMode) { + Checkbox(checked = index in selection, onCheckedChange = onCheckedChange) + } else { + IconButton(onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + }) { + Icon(painterResource(R.drawable.more_vert), contentDescription = null) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(index !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + LocalAlbumRadio(albumData, startIndex = index) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + ) + } + + if (otherVersions.isNotEmpty()) { + item { + NavigationTitle( + title = stringResource(R.string.other_versions), + modifier = Modifier.padding(top = 16.dp) + ) + } + item { + LazyRow(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + items( + items = otherVersions, + key = { it.id }, + ) { item -> + YouTubeGridItem( + item = item, + isActive = mediaMetadata?.album?.id == item.id, + isPlaying = isPlaying, + coroutineScope = rememberCoroutineScope(), + modifier = Modifier.combinedClickable( + onClick = { navController.navigate("album/${item.id}") }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + ) + } + } + } + } + + } else { + item { + ShimmerHost { + // Shimmer placeholders for UI 2.0 + } + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { + if (inSelectMode) { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + } else if (showTopBarTitle) { + Text(albumWithSongs?.album?.title.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, + navigationIcon = { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon(painter = painterResource(R.drawable.close), contentDescription = null) + } + } else { + IconButton(onClick = navController::navigateUp, onLongClick = navController::backToMain) { + Icon(painterResource(R.drawable.arrow_back), contentDescription = null) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == albumWithSongs?.songs?.size, + onCheckedChange = { + albumWithSongs?.let { data -> + if (selection.size == data.songs.size) selection.clear() + else { + selection.clear() + selection.addAll(data.songs.indices) + } + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { index -> albumWithSongs?.songs?.getOrNull(index) }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon(painterResource(R.drawable.more_vert), contentDescription = null) + } + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = if (showTopBarTitle) MaterialTheme.colorScheme.background.copy(alpha = 0.9f) else Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.9f) + ) + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } + } + } +} + +@Composable +private fun AlbumHeaderUI20( + albumWithSongs: AlbumWithSongs, + navController: NavController, + downloadState: Int, + onDownloadClick: () -> Unit +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current!! + val database = LocalDatabase.current + + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AsyncImage( + model = albumWithSongs.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth(0.7f) + .aspectRatio(1f) // Ensure it's square + .clip(RoundedCornerShape(32.dp)) + .graphicsLayer { + shadowElevation = 24.dp.toPx() + }, + contentScale = ContentScale.Crop + ) + + Spacer(Modifier.height(24.dp)) + + Text( + text = albumWithSongs.album.title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text(buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ).toSpanStyle() + ) { + albumWithSongs.artists.fastForEachIndexed { index, artist -> + val link = LinkAnnotation.Clickable(artist.id) { + navController.navigate("artist/${artist.id}") + } + withLink(link) { + append(artist.name) + } + if (index != albumWithSongs.artists.lastIndex) { + append(", ") + } + } + albumWithSongs.album.year?.let { + append(" • $it") + } + } + }) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + playerConnection.playQueue(LocalAlbumRadio(albumWithSongs.copy(songs = albumWithSongs.songs.shuffled()))) + }) { + Icon(painter = painterResource(R.drawable.shuffle), contentDescription = stringResource(R.string.shuffle)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + IconButton(onClick = { + database.query { update(albumWithSongs.album.toggleLike()) } + }) { + Icon( + painter = painterResource(if (albumWithSongs.album.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + contentDescription = stringResource(R.string.favorite) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton(onClick = onDownloadClick) { + Icon(painter = painterResource(R.drawable.offline), contentDescription = stringResource(R.string.remove_download)) + } + } + Download.STATE_DOWNLOADING -> { + IconButton(onClick = onDownloadClick) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(24.dp)) + } + } + else -> { + IconButton(onClick = onDownloadClick) { + Icon(painter = painterResource(R.drawable.download), contentDescription = stringResource(R.string.download)) + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + IconButton(onClick = { + menuState.show { + AlbumMenu( + originalAlbum = Album(albumWithSongs.album, albumWithSongs.artists), + navController = navController, + onDismiss = menuState::dismiss + ) + } + }) { + Icon(painter = painterResource(R.drawable.more_vert), contentDescription = stringResource(R.string.menu)) + } + } + } + + FloatingActionButton( + onClick = { playerConnection.playQueue(LocalAlbumRadio(albumWithSongs)) }, + shape = CircleShape, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp) + ) { + Icon(painter = painterResource(R.drawable.play), contentDescription = stringResource(R.string.play), modifier = Modifier.size(32.dp)) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/ExploreScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/ExploreScreen.kt new file mode 100644 index 0000000..54c9615 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/ExploreScreen.kt @@ -0,0 +1,221 @@ +package com.babelsoftware.loudly.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.shimmer.GridItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.component.shimmer.TextPlaceholder +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.theme.AppSpacing +import com.babelsoftware.loudly.ui.theme.FrostedCard +import com.babelsoftware.loudly.viewmodels.ExploreViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ExploreScreen( + navController: NavController, + viewModel: ExploreViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val explorePage by viewModel.explorePage.collectAsState() + + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() + + Box( + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding(), + bottom = LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding() + ), + verticalArrangement = Arrangement.spacedBy(AppSpacing.Default) + ) { + explorePage?.newReleaseAlbums?.let { newReleaseAlbums -> + item { + FrostedCard( + modifier = Modifier.padding(horizontal = AppSpacing.Default) + ) { + Column { + NavigationTitle( + title = stringResource(R.string.new_release_albums), + onClick = { + navController.navigate("new_release") + }, + modifier = Modifier.padding( + top = AppSpacing.Default, + start = AppSpacing.Default, + end = AppSpacing.Default + ) + ) + LazyRow( + contentPadding = PaddingValues( + horizontal = AppSpacing.Default, + vertical = AppSpacing.Default + ), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.Default) + ) { + items( + items = newReleaseAlbums, + key = { it.id }, + ) { album -> + YouTubeGridItem( + item = album, + isActive = mediaMetadata?.album?.id == album.id, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + modifier = + Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress + ) + menuState.show { + YouTubeAlbumMenu( + albumItem = album, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem() + ) + } + } + } + } + } + } + explorePage?.moodAndGenres?.let { moodAndGenres -> + item { + FrostedCard( + modifier = Modifier.padding(horizontal = AppSpacing.Default) + ) { + Column { + NavigationTitle( + title = stringResource(R.string.mood_and_genres), + onClick = { + navController.navigate("mood_and_genres") + }, + modifier = Modifier.padding( + top = AppSpacing.Default, + start = AppSpacing.Default, + end = AppSpacing.Default + ) + ) + LazyHorizontalGrid( + rows = GridCells.Fixed(2), + contentPadding = PaddingValues(AppSpacing.Default), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.Small), + verticalArrangement = Arrangement.spacedBy(AppSpacing.Small), + modifier = Modifier.height(140.dp), + ) { + items(moodAndGenres) { + MoodAndGenresButton( + title = it.title, + onClick = { + navController.navigate("youtube_browse/${it.endpoint.browseId}?params=${it.endpoint.params}") + }, + modifier = + Modifier + .padding(6.dp) + .width(180.dp) + ) + } + } + Spacer( + Modifier.height( + LocalPlayerAwareWindowInsets.current.asPaddingValues() + .calculateBottomPadding() + ) + ) + } + } + if (explorePage == null) { + ShimmerHost { + TextPlaceholder( + height = 36.dp, + modifier = + Modifier + .padding(vertical = 12.dp, horizontal = 12.dp) + .width(250.dp) + ) + Row { + repeat(2) { + GridItemPlaceHolder() + } + } + TextPlaceholder( + height = 36.dp, + modifier = + Modifier + .padding(vertical = 12.dp, horizontal = 12.dp) + .width(250.dp) + ) + repeat(4) { + Row { + repeat(2) { + TextPlaceholder( + height = MoodAndGenresButtonHeight, + modifier = + Modifier + .padding(horizontal = 6.dp) + .width(200.dp) + ) + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/HistoryScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/HistoryScreen.kt new file mode 100644 index 0000000..240720d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/HistoryScreen.kt @@ -0,0 +1,523 @@ +package com.babelsoftware.loudly.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEachReversed +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.HistorySource +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.db.entities.EventWithSong +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.HideOnScrollFAB +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.ui.theme.AppSpacing +import com.babelsoftware.loudly.ui.theme.FrostedCard +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.DateAgo +import com.babelsoftware.loudly.viewmodels.HistoryViewModel +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun HistoryScreen( + navController: NavController, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + val haptic = LocalHapticFeedback.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val historyPage by viewModel.historyPage + val historySource by viewModel.historySource.collectAsState() + + val (ytmSync) = rememberPreference(YtmSyncKey, true) + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } + + var isSearching by rememberSaveable { mutableStateOf(false) } + var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearching) { if (isSearching) focusRequester.requestFocus() } + if (isSearching) BackHandler { isSearching = false; query = TextFieldValue() } + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, Long>( + save = { it.toList() }, + restore = { it.toMutableStateList() }) + ) { mutableStateListOf() } + val onExitSelectionMode = { inSelectMode = false; selection.clear() } + if (inSelectMode) BackHandler(onBack = onExitSelectionMode) + + val eventsMap by viewModel.events.collectAsState() + val filteredEventsMap = remember(eventsMap, query) { + if (query.text.isEmpty()) eventsMap + else eventsMap + .mapValues { (_, songs) -> + songs.filter { song -> + song.song.title.contains(query.text, ignoreCase = true) || + song.song.artists.fastAny { + it.name.contains( + query.text, + ignoreCase = true + ) + } + } + } + .filterValues { it.isNotEmpty() } + } + val filteredEventIndex: Map by remember(filteredEventsMap) { + derivedStateOf { + filteredEventsMap.flatMap { it.value }.associateBy { it.event.id } + } + } + + val filteredRemoteContent = remember(historyPage, query) { + if (query.text.isEmpty()) { + historyPage?.sections + } else { + historyPage?.sections?.map { section -> + section.copy( + songs = section.songs.filter { song -> + song.title.contains(query.text, ignoreCase = true) || + song.artists.any { it.name.contains(query.text, ignoreCase = true) } + } + ) + }?.filter { it.songs.isNotEmpty() } + } + } + + LaunchedEffect(filteredEventsMap) { + selection.fastForEachReversed { eventId -> + if (filteredEventIndex[eventId] == null) { + selection.remove(eventId) + } + } + } + + val lazyListState = rememberLazyListState() + + Box(Modifier.fillMaxSize()) { + AnimatedGradientBackground() + Scaffold( + topBar = { + TopAppBar( + title = { + if (inSelectMode) { + Text( + pluralStringResource( + R.plurals.n_selected, + selection.size, + selection.size + ) + ) + } else if (isSearching) { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + trailingIcon = { + if (query.text.isNotEmpty()) { + IconButton(onClick = { query = TextFieldValue("") }) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + } else { + Text(stringResource(R.string.history)) + } + }, + navigationIcon = { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } else { + IconButton( + onClick = { + if (isSearching) { + isSearching = false; query = TextFieldValue() + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == filteredEventIndex.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == filteredEventIndex.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(filteredEventsMap.flatMap { group -> group.value.map { it.event.id } }) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { eventId -> filteredEventIndex[eventId]?.song }, + onDismiss = menuState::dismiss, + onRemoveFromHistory = { + val sel = + selection.mapNotNull { eventId -> filteredEventIndex[eventId]?.event } + database.query { sel.forEach { delete(it) } } + }, + onExitSelectionMode = onExitSelectionMode + ) + } + + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton(onClick = { isSearching = true }) { + Icon(painterResource(R.drawable.search), contentDescription = null) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.9f) + ) + ) + }, + floatingActionButton = { + if (!inSelectMode) { + val fabVisible = + if (historySource == HistorySource.LOCAL) filteredEventsMap.isNotEmpty() else filteredRemoteContent?.isNotEmpty() == true + HideOnScrollFAB( + visible = fabVisible, + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + val items = if (historySource == HistorySource.LOCAL) { + filteredEventIndex.values.map { it.song.toMediaItem() }.shuffled() + } else { + filteredRemoteContent?.flatMap { it.songs } + ?.map { it.toMediaItem() }?.shuffled() ?: emptyList() + } + playerConnection.playQueue( + ListQueue( + title = context.getString(if (historySource == HistorySource.LOCAL) R.string.history_queue_title_local else R.string.history_queue_title_online), + items = items + ) + ) + } + ) + } + }, + containerColor = Color.Transparent + ) { innerPadding -> + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues( + start = AppSpacing.Default, + end = AppSpacing.Default, + top = innerPadding.calculateTopPadding() + AppSpacing.Small, + bottom = innerPadding.calculateBottomPadding() + AppSpacing.Default + ), + verticalArrangement = Arrangement.spacedBy(AppSpacing.Default), + modifier = Modifier.fillMaxSize() + ) { + // Filtre çipleri en başta kalıyor + item { + if (ytmSync && isLoggedIn && isInternetAvailable(context)) { + ChipsRow( + chips = listOf( + HistorySource.LOCAL to stringResource(R.string.local_history), + HistorySource.REMOTE to stringResource(R.string.remote_history), + ), + currentValue = historySource, + onValueUpdate = { viewModel.historySource.value = it } + ) + } + } + + val isLocalEmpty = + historySource == HistorySource.LOCAL && filteredEventsMap.isEmpty() + val isRemoteEmpty = + historySource == HistorySource.REMOTE && filteredRemoteContent.isNullOrEmpty() + + if (isLocalEmpty || isRemoteEmpty) { + item { + val textRes = + if (isSearching) R.string.no_results_found else R.string.history_empty + EmptyPlaceholder( + icon = R.drawable.history, + text = stringResource(textRes), + modifier = Modifier.fillParentMaxSize().animateItem() + ) + } + } else { + // Liste boş değilse, kaynağa göre kartları çiziyoruz. + if (historySource == HistorySource.LOCAL) { + filteredEventsMap.forEach { (dateAgo, events) -> + item(key = "local-${dateAgo}") { // Key'e "local-" ön eki ekleyerek daha güvenli hale getirdik. + FrostedCard(modifier = Modifier.animateItem()) { + Column { + NavigationTitle( + title = when (dateAgo) { + DateAgo.Today -> stringResource(R.string.today) + DateAgo.Yesterday -> stringResource(R.string.yesterday) + DateAgo.ThisWeek -> stringResource(R.string.this_week) + DateAgo.LastWeek -> stringResource(R.string.last_week) + is DateAgo.Other -> dateAgo.date.format( + DateTimeFormatter.ofPattern("yyyy/MM") + ) + }, + modifier = Modifier.padding(16.dp) + ) + events.forEach { event -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) selection.add(event.event.id) + else selection.remove(event.event.id) + } + SongListItem( + song = event.song, + isActive = event.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = event.event.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton(onClick = { + menuState.show { + SongMenu( + originalSong = event.song, + event = event.event, + navController = navController, + onDismiss = menuState::dismiss + ) + } + }) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(event.event.id !in selection) + } else if (event.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.Companion.radio( + event.song.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress + ) + inSelectMode = true + onCheckedChange(true) + } + } + ).animateItem() + ) + } + } + } + } + } + } else { // historySource == HistorySource.REMOTE + filteredRemoteContent?.forEach { section -> + item(key = "remote-${section.title}") { // Key'e "remote-" ön eki eklendi. + FrostedCard(modifier = Modifier.animateItem()) { + Column { + NavigationTitle( + title = section.title, + modifier = Modifier.padding(16.dp) + ) + section.songs.forEach { song -> + YouTubeListItem( + item = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton(onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + }) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.Companion.radio( + song.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress + ) + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + } + ).animateItem() + ) + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/HomeScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..a53ec66 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/HomeScreen.kt @@ -0,0 +1,1129 @@ +package com.babelsoftware.loudly.ui.screens + +import android.annotation.SuppressLint +import android.graphics.RenderEffect +import android.net.Uri +import android.os.Build +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.innertube.models.YTItem +import com.babelsoftware.innertube.pages.HomePage +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.BuildConfig +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AccountNameKey +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.constants.HeaderImageKey +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.constants.ListThumbnailSize +import com.babelsoftware.loudly.constants.ShowContentFilterKey +import com.babelsoftware.loudly.constants.ShowRecentActivityKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.constants.showNoInternetDialogKey +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.LocalItem +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.RecentActivityType +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.isNewerVersion +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeAlbumRadio +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.AlbumGridItem +import com.babelsoftware.loudly.ui.component.ArtistGridItem +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.FeaturedContentCard +import com.babelsoftware.loudly.ui.component.HideOnScrollFAB +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.SongGridItem +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.YouTubeCardItem +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.shimmer.GridItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.component.shimmer.TextPlaceholder +import com.babelsoftware.loudly.ui.menu.AlbumMenu +import com.babelsoftware.loudly.ui.menu.ArtistMenu +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.menu.YouTubeArtistMenu +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.ui.utils.SnapLayoutInfoProvider +import com.babelsoftware.loudly.utils.getLatestReleaseInfo +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.HomeViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.util.Calendar +import kotlin.math.absoluteValue +import kotlin.math.min +import kotlin.random.Random + +@SuppressLint("SuspiciousIndentation") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + navController: NavController, + viewModel: HomeViewModel = hiltViewModel() +) { + var updateAvailable by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = Unit) { + updateAvailable = checkForUpdate() + } + + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val haptic = LocalHapticFeedback.current + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val quickPicks by viewModel.quickPicks.collectAsState() + val forgottenFavorites by viewModel.forgottenFavorites.collectAsState() + val keepListening by viewModel.keepListening.collectAsState() + val similarRecommendations by viewModel.similarRecommendations.collectAsState() + val accountPlaylists by viewModel.accountPlaylists.collectAsState() + val homePage by viewModel.homePage.collectAsState() + val explorePage by viewModel.explorePage.collectAsState() + val playlists by viewModel.playlists.collectAsState() + val recentActivity by viewModel.recentActivity.collectAsState() + + val (showRecentActivity) = rememberPreference(ShowRecentActivityKey, defaultValue = true) + val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val isLoading by viewModel.isLoading.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val selectedChip by viewModel.selectedChip.collectAsState() + val pullRefreshState = rememberPullToRefreshState() + + val forgottenFavoritesLazyGridState = rememberLazyGridState() + + val scope = rememberCoroutineScope() + val lazylistState = rememberLazyListState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + val (ytmSync) = rememberPreference(YtmSyncKey, true) + val context = LocalContext.current + var showNoInternetDialog by rememberPreference(showNoInternetDialogKey, defaultValue = false) + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + lazylistState.animateScrollToItem(0) + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + LaunchedEffect(Unit) { + snapshotFlow { lazylistState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { lastVisibleIndex -> + val len = lazylistState.layoutInfo.totalItemsCount + if (lastVisibleIndex != null && lastVisibleIndex >= len - 3) { + viewModel.loadMoreYouTubeItems(homePage?.continuation) + } + } + } + + if (!isInternetAvailable(context)) { + LaunchedEffect(Unit) { + showNoInternetDialog = true + } + } + + if (selectedChip != null) { + BackHandler { + viewModel.toggleChip(null) + } + } + + val localGridItem: @Composable (LocalItem) -> Unit = { + when (it) { + is Song -> SongGridItem( + song = it, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (it.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.Companion.radio(it.toMediaMetadata()), + ) + } + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + menuState.show { + SongMenu( + originalSong = it, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ), + isActive = it.id == mediaMetadata?.id, + isPlaying = isPlaying, + ) + + is Album -> AlbumGridItem( + album = it, + isActive = it.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = scope, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${it.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = it, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + + is Artist -> ArtistGridItem( + artist = it, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${it.id}") + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + menuState.show { + ArtistMenu( + originalArtist = it, + coroutineScope = scope, + onDismiss = menuState::dismiss, + ) + } + }, + ), + ) + + is Playlist -> {} + } + } + + val ytGridItem: @Composable (YTItem) -> Unit = { item -> + val itemShape = when (item) { + is ArtistItem -> CircleShape + else -> RoundedCornerShape(ThumbnailCornerRadius) + } + YouTubeGridItem( + item = item, + isActive = item.id in listOf(mediaMetadata?.album?.id, mediaMetadata?.id), + isPlaying = isPlaying, + coroutineScope = scope, + thumbnailRatio = 1f, + shape = itemShape, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue( + YouTubeQueue( + item.endpoint ?: WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ) + ) + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = scope, + onDismiss = menuState::dismiss + ) + } + } + } + ) + ) + } + + LaunchedEffect(forgottenFavorites) { + forgottenFavoritesLazyGridState.scrollToItem(0) + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .pullToRefresh( + state = pullRefreshState, + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh + ), + contentAlignment = Alignment.TopStart + ) { + val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f + val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor + val forgottenFavoritesSnapLayoutInfoProvider = remember(forgottenFavoritesLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = forgottenFavoritesLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) + } + + val recentActivityGridState = rememberLazyGridState() + val (showContentFilter) = rememberPreference(ShowContentFilterKey, defaultValue = true) + + if (showNoInternetDialog) { + AlertDialog( + onDismissRequest = { showNoInternetDialog = false }, + shape = RoundedCornerShape(28.dp), + title = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .size(64.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.signal_cellular_nodata), + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.not_internet), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + } + }, + text = { + Text( + text = stringResource(R.string.internet_required), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Button( + onClick = { + showNoInternetDialog = false + navController.navigate("library") + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text(stringResource(R.string.filter_library)) + } + }, + dismissButton = { + TextButton( + onClick = { showNoInternetDialog = false }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Text(stringResource(R.string.close)) + } + } + ) + } + + + LazyColumn( + state = lazylistState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical) + .asPaddingValues(LocalDensity.current) + ) { + item { + val currentChips = homePage?.chips + if (!currentChips.isNullOrEmpty() && showContentFilter) { + ChipsRow( + chips = currentChips.mapNotNull { chip -> + chip?.let { it to it.title } + }, + currentValue = selectedChip, + onValueUpdate = { + viewModel.toggleChip(it) + } + ) + } + } + + if (selectedChip == null) { + item { + HomeGreetingCard( + updateAvailable = updateAvailable, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp) + .animateItem() + ) + } + } + + if (selectedChip == null && showRecentActivity && isLoggedIn && !recentActivity.isNullOrEmpty()) { + item { + NavigationTitle(title = stringResource(R.string.recent_activity)) + } + item { + LazyHorizontalGrid( + state = recentActivityGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior( + forgottenFavoritesLazyGridState + ), + modifier = Modifier + .fillMaxWidth() + .height(60.dp * 4) + .padding(6.dp) + ) { + items( + items = recentActivity!!, + key = { it.id } + ) { item -> + YouTubeCardItem( + item, + onClick = { + when (item.type) { + RecentActivityType.PLAYLIST -> { + val playlistDb = playlists + ?.firstOrNull { it.playlist.browseId == item.id } + + if (playlistDb != null && playlistDb.songCount != 0) + navController.navigate("local_playlist/${playlistDb.id}") + else + navController.navigate("online_playlist/${item.id}") + } + + RecentActivityType.ALBUM -> navController.navigate("album/${item.id}") + + RecentActivityType.ARTIST -> navController.navigate("artist/${item.id}") + } + }, + ) + } + } + } + } + + if (selectedChip == null) { + quickPicks?.takeIf { it.isNotEmpty() }?.let { quickPicks -> + item { + NavigationTitle( + title = stringResource(R.string.quick_picks), + modifier = Modifier.animateItem() + ) + } + + item { + val chunkedQuickPicks = quickPicks.chunked(4) + val pagerState = rememberPagerState { chunkedQuickPicks.size } + val itemWidth = horizontalLazyGridItemWidth + val contentPadding = (maxWidth - itemWidth) / 2 + + LaunchedEffect(quickPicks) { + pagerState.scrollToPage(0) + } + + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(itemWidth), + contentPadding = PaddingValues(horizontal = contentPadding), + pageSpacing = 8.dp, + modifier = Modifier + .fillMaxWidth() + .animateItem() + ) { page -> + val pageOffset = ( + (pagerState.currentPage - page) + pagerState + .currentPageOffsetFraction + ).absoluteValue + + val scaleFactor = lerp(0.7f, 1f, 1f - pageOffset.coerceIn(0f, 1f)) + val alphaFactor = lerp(0.3f, 1f, 1f - pageOffset.coerceIn(0f, 1f)) + val blurFactor = lerp(8f, 0f, 1f - pageOffset.coerceIn(0f, 1f)) + + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.0f) + ), + modifier = Modifier + .width(itemWidth) + .graphicsLayer { + scaleX = scaleFactor + scaleY = scaleFactor + transformOrigin = TransformOrigin.Center + alpha = alphaFactor + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + renderEffect = if (blurFactor > 0) { + RenderEffect + .createBlurEffect( + blurFactor, blurFactor, + android.graphics.Shader.TileMode.DECAL + ) + .asComposeRenderEffect() + } else { + null + } + } + } + ) { + Column { + val songChunk = chunkedQuickPicks[page] + songChunk.forEach { originalSong -> + val song by database.song(originalSong.id) + .collectAsState(initial = originalSong) + + SongListItem( + song = song!!, + showInLibraryIcon = true, + isActive = song!!.id == mediaMetadata?.id, + isPlaying = isPlaying, + isSwipeable = false, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (song!!.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.Companion.radio( + song!!.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + } + } + } + } + } + + + forgottenFavorites?.takeIf { it.isNotEmpty() }?.let { forgottenFavorites -> + item { + NavigationTitle( + title = stringResource(R.string.forgotten_favorites), + modifier = Modifier.animateItem() + ) + } + + item { + val rows = min(4, forgottenFavorites.size) + LazyHorizontalGrid( + state = forgottenFavoritesLazyGridState, + rows = GridCells.Fixed(rows), + flingBehavior = rememberSnapFlingBehavior( + forgottenFavoritesSnapLayoutInfoProvider + ), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * rows) + .animateItem() + ) { + items( + items = forgottenFavorites, + key = { it.id } + ) { originalSong -> + val song by database.song(originalSong.id) + .collectAsState(initial = originalSong) + + SongListItem( + song = song!!, + showInLibraryIcon = true, + isActive = song!!.id == mediaMetadata?.id, + isPlaying = isPlaying, + isSwipeable = false, + modifier = Modifier + .width(horizontalLazyGridItemWidth) + .combinedClickable( + onClick = { + if (song!!.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.Companion.radio( + song!!.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + } + } + } + + keepListening?.takeIf { it.isNotEmpty() }?.let { fullList -> + val featuredItem = fullList.firstOrNull { it is Album || it is Artist } + val remainingItems = if (featuredItem != null) fullList.minus(featuredItem) else fullList + if (featuredItem != null) { + item { + FeaturedContentCard( + item = featuredItem, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateItem(), + onClick = { + when (featuredItem) { + is Album -> navController.navigate("album/${featuredItem.id}") + is Artist -> navController.navigate("artist/${featuredItem.id}") + else -> { + } + } + } + ) + } + } + + if (remainingItems.isNotEmpty()) { + item { + NavigationTitle( + title = stringResource(R.string.keep_listening), + modifier = Modifier.animateItem() + ) + } + item { + val rows = if (remainingItems.size > 6) 2 else 1 + LazyHorizontalGrid( + state = rememberLazyGridState(), + rows = GridCells.Fixed(rows), + modifier = Modifier + .fillMaxWidth() + .height((GridThumbnailHeight + 24.dp + with(LocalDensity.current) { + MaterialTheme.typography.bodyLarge.lineHeight.toDp() * 2 + + MaterialTheme.typography.bodyMedium.lineHeight.toDp() * 2 + }) * rows) + .animateItem() + ) { + items(remainingItems) { + localGridItem(it) + } + } + } + } + } + } + + if (ytmSync && selectedChip == null) { + accountPlaylists?.takeIf { it.isNotEmpty() }?.let { accountPlaylists -> + item { + NavigationTitle( + title = stringResource(R.string.your_youtube_playlists), + onClick = { + navController.navigate("YouTubePlaylists") + }, + modifier = Modifier.animateItem() + ) + } + + item { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items( + items = accountPlaylists, + key = { it.id }, + ) { item -> + ytGridItem(item) + } + } + } + } + } + + if (selectedChip == null) { + similarRecommendations?.forEach { + item { + NavigationTitle( + label = stringResource(R.string.similar_to), + title = it.title.title, + thumbnail = it.title.thumbnailUrl?.let { thumbnailUrl -> + { + val shape = RoundedCornerShape(ThumbnailCornerRadius) + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(shape) + ) + } + }, + onClick = { + when (it.title) { + is Song -> navController.navigate("album/${it.title.album!!.id}") + is Album -> navController.navigate("album/${it.title.id}") + is Artist -> navController.navigate("artist/${it.title.id}") + is Playlist -> {} + } + }, + modifier = Modifier.animateItem() + ) + } + + item { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items(it.items) { item -> + ytGridItem(item) + } + } + } + } + } + + homePage?.sections?.forEach { + item { + NavigationTitle( + title = it.title, + label = it.label, + thumbnail = it.thumbnail?.let { thumbnailUrl -> + { + val shape = RoundedCornerShape(ThumbnailCornerRadius) + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier.Companion + .size(ListThumbnailSize) + .clip(shape) + ) + } + }, + modifier = Modifier.animateItem() + ) + } + + item { + when (it.sectionType) { + HomePage.SectionType.LIST -> { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items(it.items) { item -> + ytGridItem(item) + } + } + } + + HomePage.SectionType.GRID -> { + LazyRow ( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items(it.items) { item -> + ytGridItem(item) + } + } + } + } + } + } + + if (isLoading) { + item { + ShimmerHost( + modifier = Modifier.animateItem() + ) { + TextPlaceholder( + height = 36.dp, + modifier = Modifier + .padding(12.dp) + .width(250.dp), + ) + LazyRow { + items(4) { + GridItemPlaceHolder() + } + } + } + } + } + } + + HideOnScrollFAB( + visible = !quickPicks.isNullOrEmpty() || explorePage?.newReleaseAlbums?.isNotEmpty() == true, + lazyListState = lazylistState, + icon = (R.drawable.casino), + onClick = { + if (Random.nextBoolean() && !quickPicks.isNullOrEmpty()) { + val song = quickPicks!!.random() + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = song.id), + song.toMediaMetadata() + ) + ) + } else if (explorePage?.newReleaseAlbums?.isNotEmpty() == true) { + val album = explorePage?.newReleaseAlbums!!.random() + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) + } + } + ) + + Indicator( + isRefreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()), + ) + } +} + +@Composable +private fun HomeGreetingCard( + modifier: Modifier = Modifier, + updateAvailable: Boolean +) { + val accountName by rememberPreference(AccountNameKey, "") + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } + val (headerImageKey, _) = rememberPreference(HeaderImageKey, "Loudly-1") + + val painter = when { + headerImageKey.startsWith("content://") -> { + rememberAsyncImagePainter(model = Uri.parse(headerImageKey)) + } + headerImageKey == "Loudly-1" -> painterResource(id = R.drawable.loudly_picutre_1) + headerImageKey == "Loudly-2" -> painterResource(id = R.drawable.loudly_picutre_2) + headerImageKey == "Loudly-3" -> painterResource(id = R.drawable.loudly_picutre_3) + headerImageKey == "Loudly-4" -> painterResource(id = R.drawable.loudly_picutre_4) + headerImageKey == "Loudly-5" -> painterResource(id = R.drawable.loudly_picutre_5) + headerImageKey == "Loudly-6" -> painterResource(id = R.drawable.loudly_picutre_6) + else -> painterResource(id = R.drawable.loudly_picutre_1) + } + + val currentHour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + val greeting = when (currentHour) { + in 6..11 -> stringResource(R.string.good_morning) + in 12..17 -> stringResource(R.string.good_afternoon) + in 18..23 -> stringResource(R.string.good_evening) + else -> stringResource(R.string.good_night) + } + + var showUpdateMessage by remember { mutableStateOf(false) } + LaunchedEffect(updateAvailable) { + showUpdateMessage = false + if (updateAvailable) { + delay(3000) + showUpdateMessage = true + } + } + + Card( + modifier = modifier + .fillMaxWidth() + .height(180.dp), + shape = RoundedCornerShape(32.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomStart + ) { + Image( + painter = painter, + contentDescription = "Header Background", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.7f)), + startY = 150f + ) + ) + ) + AnimatedContent( + targetState = showUpdateMessage, + modifier = Modifier.padding(24.dp), + transitionSpec = { + (slideInVertically { height -> height } + fadeIn()) + .togetherWith(slideOutVertically { height -> -height } + fadeOut()) + }, + label = "GreetingCardAnimation" + ) { shouldShowUpdate -> + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = if (shouldShowUpdate) Alignment.CenterHorizontally else Alignment.Start, + modifier = if (shouldShowUpdate) Modifier.fillMaxWidth() else Modifier.fillMaxSize() + ) { + if (!shouldShowUpdate) { + if (isLoggedIn && accountName.isNotBlank()) { + Text( + text = greeting, + color = Color.White.copy(alpha = 0.9f), + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = accountName.replace("@", ""), + color = Color.White, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } else { + Text( + stringResource(R.string.welcome_to_loudly), + color = Color.White, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold + ) + } + } else { + Text( + text = stringResource(R.string.new_version_available), + color = Color.White, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.home_card_update_desc), + color = Color.White.copy(alpha = 0.9f), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + } + } + } + } + } +} + +private suspend fun checkForUpdate(): Boolean { + return withContext(Dispatchers.IO) { + try { + val latestInfo = getLatestReleaseInfo() + if (latestInfo != null) { + isNewerVersion(latestInfo.tagName, BuildConfig.VERSION_NAME) + } else { + false + } + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/LoginScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..3709718 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/LoginScreen.kt @@ -0,0 +1,117 @@ +package com.babelsoftware.loudly.ui.screens + +import android.annotation.SuppressLint +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.constants.AccountChannelHandleKey +import com.babelsoftware.loudly.constants.AccountEmailKey +import com.babelsoftware.loudly.constants.AccountNameKey +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.VisitorDataKey +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.reportException +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@SuppressLint("SetJavaScriptEnabled") +@OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class) +@Composable +fun LoginScreen( + navController: NavController, +) { + var visitorData by rememberPreference(VisitorDataKey, "") + var innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + var accountName by rememberPreference(AccountNameKey, "") + var accountEmail by rememberPreference(AccountEmailKey, "") + var accountChannelHandle by rememberPreference(AccountChannelHandleKey, "") + + var webView: WebView? = null + + AndroidView( + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .fillMaxSize(), + factory = { context -> + WebView(context).apply { + webViewClient = object : WebViewClient() { + override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { + if (url.startsWith("https://music.youtube.com")) { + innerTubeCookie = CookieManager.getInstance().getCookie(url) + GlobalScope.launch { + YouTube.accountInfo().onSuccess { + accountName = it.name + accountEmail = it.email.orEmpty() + accountChannelHandle = it.channelHandle.orEmpty() + }.onFailure { + reportException(it) + } + } + } + } + + override fun onPageFinished(view: WebView, url: String?) { + loadUrl("javascript:Android.onRetrieveVisitorData(window.yt.config_.VISITOR_DATA)") + } + } + settings.apply { + javaScriptEnabled = true + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + } + addJavascriptInterface(object { + @JavascriptInterface + fun onRetrieveVisitorData(newVisitorData: String?) { + if (newVisitorData != null) { + visitorData = newVisitorData + } + } + }, "Android") + webView = this + loadUrl("https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26next%3Dhttps%253A%252F%252Fmusic.youtube.com%252F") + } + } + ) + + TopAppBar( + title = { Text(stringResource(R.string.login)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + ) + + BackHandler(enabled = webView?.canGoBack() == true) { + webView?.goBack() + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/MoodAndGenresScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/MoodAndGenresScreen.kt new file mode 100644 index 0000000..fd53f59 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/MoodAndGenresScreen.kt @@ -0,0 +1,142 @@ +package com.babelsoftware.loudly.ui.screens + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.shimmer.ListItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.viewmodels.MoodAndGenresViewModel +import com.babelsoftware.loudly.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoodAndGenresScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: MoodAndGenresViewModel = hiltViewModel(), +) { + val localConfiguration = LocalConfiguration.current + val itemsPerRow = if (localConfiguration.orientation == ORIENTATION_LANDSCAPE) 3 else 2 + + val moodAndGenresList by viewModel.moodAndGenres.collectAsState() + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + if (moodAndGenresList == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + + moodAndGenresList?.forEach { moodAndGenres -> + item { + NavigationTitle( + title = moodAndGenres.title + ) + + Column( + modifier = Modifier.padding(horizontal = 6.dp) + ) { + moodAndGenres.items.chunked(itemsPerRow).forEach { row -> + Row { + row.forEach { + MoodAndGenresButton( + title = it.title, + onClick = { + navController.navigate("youtube_browse/${it.endpoint.browseId}?params=${it.endpoint.params}") + }, + modifier = Modifier + .weight(1f) + .padding(6.dp) + ) + } + + repeat(itemsPerRow - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.mood_and_genres)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} + +@Composable +fun MoodAndGenresButton( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = modifier + .height(MoodAndGenresButtonHeight) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +val MoodAndGenresButtonHeight = 48.dp \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/NavigationBuilder.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/NavigationBuilder.kt new file mode 100644 index 0000000..c39d69a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/NavigationBuilder.kt @@ -0,0 +1,297 @@ +package com.babelsoftware.loudly.ui.screens + +import android.annotation.SuppressLint +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.babelsoftware.loudly.constants.PlayerStyle +import com.babelsoftware.loudly.ui.screens.artist.ArtistItemsScreen +import com.babelsoftware.loudly.ui.screens.artist.ArtistScreen +import com.babelsoftware.loudly.ui.screens.artist.ArtistSongsScreen +import com.babelsoftware.loudly.ui.screens.library.LibraryAlbumsScreen +import com.babelsoftware.loudly.ui.screens.library.LibraryArtistsScreen +import com.babelsoftware.loudly.ui.screens.library.LibraryPlaylistsScreen +import com.babelsoftware.loudly.ui.screens.library.LibraryScreen +import com.babelsoftware.loudly.ui.screens.library.LibrarySongsScreen +import com.babelsoftware.loudly.ui.screens.playlist.AutoPlaylistLocalScreen +import com.babelsoftware.loudly.ui.screens.playlist.AutoPlaylistScreen +import com.babelsoftware.loudly.ui.screens.playlist.CachePlaylistScreen +import com.babelsoftware.loudly.ui.screens.playlist.LocalPlaylistScreen +import com.babelsoftware.loudly.ui.screens.playlist.OnlinePlaylistScreen +import com.babelsoftware.loudly.ui.screens.playlist.TopPlaylistScreen +import com.babelsoftware.loudly.ui.screens.search.OnlineSearchResult +import com.babelsoftware.loudly.ui.screens.settings.AboutScreen +import com.babelsoftware.loudly.ui.screens.settings.AccountSettings +import com.babelsoftware.loudly.ui.screens.settings.AppearanceSettings +import com.babelsoftware.loudly.ui.screens.settings.BackupAndRestore +import com.babelsoftware.loudly.ui.screens.settings.ComradeNotificationSettingsScreen +import com.babelsoftware.loudly.ui.screens.settings.ContentSettings +import com.babelsoftware.loudly.ui.screens.settings.DiscordLoginScreen +import com.babelsoftware.loudly.ui.screens.settings.DiscordSettings +import com.babelsoftware.loudly.ui.screens.settings.LocalPlayerSettings +import com.babelsoftware.loudly.ui.screens.settings.LyricsSettings +import com.babelsoftware.loudly.ui.screens.settings.NotificationSettings +import com.babelsoftware.loudly.ui.screens.settings.PlayerSettings +import com.babelsoftware.loudly.ui.screens.settings.PrivacySettings +import com.babelsoftware.loudly.ui.screens.settings.SettingsScreen +import com.babelsoftware.loudly.ui.screens.settings.StorageSettings +import com.babelsoftware.loudly.ui.screens.settings.TipsScreen +import com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.ImportFromSpotifyScreen + +@SuppressLint("UnrememberedMutableState") +@OptIn(ExperimentalMaterial3Api::class) +fun NavGraphBuilder.navigationBuilder( + navController: NavHostController, + scrollBehavior: TopAppBarScrollBehavior, + updateAvailable: Boolean, + playerStyle: PlayerStyle +) { + composable(Screens.Home.route) { + HomeScreen( + navController = navController + ) + } + composable(Screens.Explore.route) { + ExploreScreen(navController) + } + composable(Screens.Library.route) { + LibraryScreen(navController) + } + composable(Screens.Songs.route) { + LibrarySongsScreen(navController) + } + composable(Screens.Artists.route) { + LibraryArtistsScreen(navController) + } + composable(Screens.Albums.route) { + LibraryAlbumsScreen(navController) + } + composable(Screens.Playlists.route) { + LibraryPlaylistsScreen(navController) + } + composable("history") { + HistoryScreen(navController) + } + composable("stats") { + StatsScreen(navController) + } + composable("mood_and_genres") { + MoodAndGenresScreen(navController, scrollBehavior) + } + composable("new_release") { + NewReleaseScreen(navController, scrollBehavior) + } + composable("settings/tips") { + TipsScreen(navController = navController) + } + composable("settings/comradenotification") { + ComradeNotificationSettingsScreen(navController = navController) + } + composable( + route = "search/{query}", + arguments = listOf( + navArgument("query") { + type = NavType.StringType + } + ), + enterTransition = { + fadeIn(tween(250)) + }, + exitTransition = { + if (targetState.destination.route?.startsWith("search/") == true) { + fadeOut(tween(200)) + } else { + fadeOut(tween(200)) + slideOutHorizontally { -it / 2 } + } + }, + popEnterTransition = { + if (initialState.destination.route?.startsWith("search/") == true) { + fadeIn(tween(250)) + } else { + fadeIn(tween(250)) + slideInHorizontally { -it / 2 } + } + }, + popExitTransition = { + fadeOut(tween(200)) + } + ) { + OnlineSearchResult(navController) + } + composable( + route = "album/{albumId}", + arguments = listOf( + navArgument("albumId") { + type = NavType.StringType + }, + ) + ) { + AlbumScreen(navController, scrollBehavior, playerStyle = playerStyle ) + } + composable( + route = "artist/{artistId}", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + val artistId = backStackEntry.arguments?.getString("artistId")!! + if (artistId.startsWith("LA")) { + ArtistSongsScreen(navController, scrollBehavior) + } else { + ArtistScreen(navController, scrollBehavior) + } + } + composable( + route = "artist/{artistId}/songs", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + } + ) + ) { + ArtistSongsScreen(navController, scrollBehavior) + } + composable( + route = "artist/{artistId}/items?browseId={browseId}?params={params}", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + }, + navArgument("browseId") { + type = NavType.StringType + nullable = true + }, + navArgument("params") { + type = NavType.StringType + nullable = true + } + ) + ) { + ArtistItemsScreen(navController, scrollBehavior) + } + composable( + route = "online_playlist/{playlistId}", + arguments = listOf( + navArgument("playlistId") { + type = NavType.StringType + } + ) + ) { + OnlinePlaylistScreen(navController, scrollBehavior) + } + composable( + route = "local_playlist/{playlistId}", + arguments = listOf( + navArgument("playlistId") { + type = NavType.StringType + } + ) + ) { + LocalPlaylistScreen(navController, scrollBehavior) + } + composable( + route = "youtube_browse/{browseId}?params={params}", + arguments = listOf( + navArgument("browseId") { + type = NavType.StringType + nullable = true + }, + navArgument("params") { + type = NavType.StringType + nullable = true + } + ) + ) { + YouTubeBrowseScreen(navController, scrollBehavior) + } + composable( + route = "auto_playlist/{playlist}", + arguments = listOf( + navArgument("playlist") { + type = NavType.StringType + } + ) + ) { + AutoPlaylistScreen(navController, scrollBehavior) + } + composable( + route = "top_playlist/{top}", + arguments = listOf( + navArgument("top") { + type = NavType.StringType + } + ) + ) { + TopPlaylistScreen(navController, scrollBehavior) + } + composable("CachedPlaylist") { + CachePlaylistScreen(navController, scrollBehavior) + } + composable("AutoPlaylistLocal") { + AutoPlaylistLocalScreen(navController, scrollBehavior) + } + composable("settings") { + SettingsScreen(navController, scrollBehavior) + } + composable("settings/appearance") { + AppearanceSettings(navController, scrollBehavior) + } + composable("settings/account") { + AccountSettings(navController, scrollBehavior) + } + composable("settings/content") { + ContentSettings(navController, scrollBehavior) + } + composable("settings/import_from_spotify/ImportFromSpotify") { + ImportFromSpotifyScreen(navController, scrollBehavior) + } + composable("settings/player") { + PlayerSettings(navController, scrollBehavior) + } + composable("settings/player/lyrics") { + LyricsSettings(navController, scrollBehavior) + } + composable("settings/content/notification") { + NotificationSettings(navController, scrollBehavior) + } + composable("settings/storage") { + StorageSettings(navController, scrollBehavior) + } + composable("settings/privacy") { + PrivacySettings(navController, scrollBehavior) + } + composable("settings/backup_restore") { + BackupAndRestore(navController, scrollBehavior) + } + composable("settings/discord") { + DiscordSettings(navController, scrollBehavior) + } + composable("settings/discord/login") { + DiscordLoginScreen(navController) + } + composable("settings/about") { + AboutScreen(navController, scrollBehavior) + } + composable("player/local") { + LocalPlayerSettings(navController, scrollBehavior) + } + composable("login") { + LoginScreen(navController) + } + composable("YouTubePlaylists") { + YouTubePlaylistsScreen(navController, scrollBehavior) + } + composable("setup_wizard") { + SetupWizard(navController) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/NewReleaseScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/NewReleaseScreen.kt new file mode 100644 index 0000000..6fae5de --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/NewReleaseScreen.kt @@ -0,0 +1,114 @@ +package com.babelsoftware.loudly.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.shimmer.GridItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.viewmodels.NewReleaseViewModel +import com.babelsoftware.loudly.R + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun NewReleaseScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: NewReleaseViewModel = hiltViewModel(), +) { + val playerConnection = LocalPlayerConnection.current ?: return + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + isActive = mediaMetadata?.album?.id == album.id, + isPlaying = isPlaying, + fillMaxWidth = true, + coroutineScope = coroutineScope, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeAlbumMenu( + albumItem = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + + if (newReleaseAlbums.isEmpty()) { + items(8) { + ShimmerHost { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.new_release_albums)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/Screens.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/Screens.kt new file mode 100644 index 0000000..ceaee1a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/Screens.kt @@ -0,0 +1,68 @@ +package com.babelsoftware.loudly.ui.screens + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.babelsoftware.loudly.R + +@Immutable +sealed class Screens( + @StringRes val titleId: Int, + @DrawableRes val iconIdInactive: Int, + @DrawableRes val iconIdActive: Int, + val route: String, +) { + data object Home : Screens( + titleId = R.string.home, + iconIdInactive = R.drawable.home_outlined, + iconIdActive = R.drawable.home_filled, + route = "home" + ) + + data object Explore : Screens( + titleId = R.string.explore, + iconIdInactive = R.drawable.explore_outlined, + iconIdActive = R.drawable.explore_filled, + route = "Explore" + ) + + data object Library : Screens( + titleId = R.string.filter_library, + iconIdInactive = R.drawable.library_music_outlined, + iconIdActive = R.drawable.library_music_filled, + route = "library" + ) + + data object Songs : Screens( + titleId = R.string.songs, + iconIdInactive = R.drawable.music_note, + iconIdActive = R.drawable.music_note, + route = "songs" + ) + + data object Artists : Screens( + titleId = R.string.artists, + iconIdInactive = R.drawable.artist, + iconIdActive = R.drawable.artist, + route = "artists" + ) + + data object Albums : Screens( + titleId = R.string.albums, + iconIdInactive = R.drawable.album, + iconIdActive = R.drawable.album, + route = "albums" + ) + + data object Playlists : Screens( + titleId = R.string.playlists, + iconIdInactive = R.drawable.queue_music, + iconIdActive = R.drawable.queue_music, + route = "playlists" + ) + + companion object { + val MainScreens = listOf(Home, Explore, Library) + val MainScreensOld = listOf(Home, Explore, Songs, Artists, Albums, Playlists) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/SetupWizard.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/SetupWizard.kt new file mode 100644 index 0000000..8229270 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/SetupWizard.kt @@ -0,0 +1,638 @@ +package com.babelsoftware.loudly.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.NavigateBefore +import androidx.compose.material.icons.automirrored.rounded.NavigateNext +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.DirectionsCar +import androidx.compose.material.icons.rounded.FastForward +import androidx.compose.material.icons.rounded.FastRewind +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.MusicVideo +import androidx.compose.material.icons.rounded.NotInterested +import androidx.compose.material.icons.rounded.OfflinePin +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material.icons.rounded.SystemUpdateAlt +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AudioQuality +import com.babelsoftware.loudly.constants.AudioQualityKey +import com.babelsoftware.loudly.constants.DynamicThemeKey +import com.babelsoftware.loudly.constants.FirstSetupPassed +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.MiniPlayerAction +import com.babelsoftware.loudly.constants.MiniPlayerActionButtonKey +import com.babelsoftware.loudly.constants.SkipSilenceKey +import com.babelsoftware.loudly.constants.SliderStyle +import com.babelsoftware.loudly.constants.SliderStyleKey +import com.babelsoftware.loudly.constants.StopMusicOnTaskClearKey +import com.babelsoftware.loudly.constants.UseLoginForBrowse +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference + +@Composable +fun SetupWizard( + navController: NavController, +) { + val (firstSetupPassed, onFirstSetupPassedChange) = rememberPreference( + FirstSetupPassed, + defaultValue = false + ) + + var position by remember { mutableIntStateOf(0) } + val maxPosition = 4 // 0: Welcome, 1: Appearance, 2: Player, 3: Account, 4: Finish + + if (position > 0) { + BackHandler { + position-- + } + } + + if (firstSetupPassed) { + navController.navigateUp() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + Image( + painter = painterResource(id = R.drawable.loudly_monochrome), + contentDescription = "Background", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(40.dp) + .alpha(0.5f) + ) + + Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + bottomBar = { + WizardBottomBar( + position = position, + maxPosition = maxPosition, + onBack = { if (position > 0) position-- }, + onNext = { if (position < maxPosition) position++ }, + onFinish = { + onFirstSetupPassedChange(true) + navController.navigateUp() + } + ) + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + AnimatedContent( + targetState = position, + label = "WizardStep", + transitionSpec = { + if (targetState > initialState) { + (slideInHorizontally(animationSpec = tween(300)) { it } + fadeIn()) togetherWith (slideOutHorizontally(animationSpec = tween(300)) { -it } + fadeOut()) + } else { + (slideInHorizontally(animationSpec = tween(300)) { -it } + fadeIn()) togetherWith (slideOutHorizontally(animationSpec = tween(300)) { it } + fadeOut()) + } + } + ) { targetPosition -> + when (targetPosition) { + 0 -> WelcomeStep() + 1 -> AppearanceSettingsStep() + 2 -> PlayerSettingsStep() + 3 -> AccountSettingsStep(navController) + 4 -> FinishStep() + } + } + } + } + } +} + +@Composable +fun WelcomeStep() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(90.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation)) + ) { + Image( + painter = painterResource(R.drawable.loudly_monochrome), + contentDescription = "Logo", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground, BlendMode.SrcIn), + modifier = Modifier.fillMaxSize() + ) + } + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = stringResource(R.string.welcome_to_loudly), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.welcome_to_loudly_description), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(32.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + FeatureChip(icon = Icons.Rounded.MusicVideo, text = stringResource(R.string.yt_music_fingertips)) + FeatureChip(icon = Icons.Rounded.NotInterested, text = stringResource(R.string.ad_free_playback)) + FeatureChip(icon = Icons.Rounded.Sync, text = stringResource(R.string.ytm_account_sync)) + FeatureChip(icon = Icons.Rounded.OfflinePin, text = stringResource(R.string.offline_music_experience)) + FeatureChip(icon = Icons.Rounded.DirectionsCar, text = stringResource(R.string.android_auto)) + FeatureChip(painterIcon = R.drawable.discord, text = stringResource(R.string.discord_support)) + FeatureChip(painterIcon = R.drawable.github, text = stringResource(R.string.fos_info)) + } + } +} + +@Composable +fun AppearanceSettingsStep() { + val (dynamicTheme, onDynamicThemeChange) = rememberPreference(DynamicThemeKey, defaultValue = true) + val (sliderStyle, onSliderStyleChange) = rememberEnumPreference(SliderStyleKey, defaultValue = SliderStyle.COMPOSE) + val (miniPlayerAction, onMiniPlayerActionChange) = rememberEnumPreference(MiniPlayerActionButtonKey, defaultValue = MiniPlayerAction.Like) + + StepLayout( + title = stringResource(R.string.appearance), + description = stringResource(R.string.appearance_setup_desc) + ) { + // Dynamic Theme + WizardSettingsRow( + title = stringResource(R.string.enable_dynamic_theme), + description = stringResource(R.string.dynamic_theme_setup_dec), + icon = painterResource(R.drawable.palette) + ) { + Switch(checked = dynamicTheme, onCheckedChange = onDynamicThemeChange) + } + + // Slider Style + Spacer(modifier = Modifier.height(16.dp)) + Text(stringResource(R.string.slider_style), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SliderStyle.values().forEach { style -> + val isSelected = sliderStyle == style + Card( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(2.dp, if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent), + onClick = { onSliderStyleChange(style) } + ) { + Text( + text = style.name.lowercase().replaceFirstChar { it.uppercase() }, + modifier = Modifier + .padding(16.dp) + .align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + + // Mini Player Actions + Spacer(modifier = Modifier.height(16.dp)) + Text(stringResource(R.string.mini_player_action_button), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium) + Column(modifier = Modifier.padding(top = 8.dp)) { + MiniPlayerAction.values().forEach { action -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onMiniPlayerActionChange(action) } + .padding(vertical = 4.dp) + ) { + RadioButton( + selected = miniPlayerAction == action, + onClick = { onMiniPlayerActionChange(action) } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = when (action) { + MiniPlayerAction.Like -> stringResource(R.string.like) + MiniPlayerAction.Next -> stringResource(R.string.next) + MiniPlayerAction.Previous -> stringResource(R.string.previous) + MiniPlayerAction.Download -> stringResource(R.string.download) + MiniPlayerAction.None -> stringResource(R.string.none) + }) + Spacer(modifier = Modifier.weight(1f)) + // Preview + Icon( + imageVector = when(action) { + MiniPlayerAction.Like -> Icons.Rounded.Favorite + MiniPlayerAction.Next -> Icons.Rounded.FastForward + MiniPlayerAction.Previous -> Icons.Rounded.FastRewind + MiniPlayerAction.Download -> Icons.Rounded.SystemUpdateAlt + MiniPlayerAction.None -> Icons.Rounded.NotInterested + }, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp) + ) + } + } + } + } +} + +@Composable +fun PlayerSettingsStep() { + val (audioQuality, onAudioQualityChange) = rememberEnumPreference(AudioQualityKey, defaultValue = AudioQuality.AUTO) + val (skipSilence, onSkipSilenceChange) = rememberPreference(SkipSilenceKey, defaultValue = false) + val (stopMusicOnTaskClear, onStopMusicOnTaskClearChange) = rememberPreference(StopMusicOnTaskClearKey, defaultValue = false) + + StepLayout( + title = stringResource(R.string.player_and_audio), + description = stringResource(R.string.player_and_audio_setup_desc) + ) { + // Audio Quality + Text(stringResource(R.string.audio_quality), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium) + Column(modifier = Modifier.padding(top = 8.dp)) { + AudioQuality.values().forEach { quality -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onAudioQualityChange(quality) } + .padding(vertical = 4.dp) + ) { + RadioButton( + selected = audioQuality == quality, + onClick = { onAudioQualityChange(quality) } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = when (quality) { + AudioQuality.AUTO -> stringResource(R.string.audio_quality_auto) + AudioQuality.MAX -> stringResource(R.string.audio_quality_max) + AudioQuality.HIGH -> stringResource(R.string.audio_quality_high) + AudioQuality.LOW -> stringResource(R.string.audio_quality_low) + }) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + + // Skip Silence + WizardSettingsRow( + title = stringResource(R.string.skip_silence), + description = stringResource(R.string.skip_silence_setup_desc), + icon = painterResource(R.drawable.fast_forward) + ) { + Switch(checked = skipSilence, onCheckedChange = onSkipSilenceChange) + } + + // Stop music on task clear + WizardSettingsRow( + title = stringResource(R.string.stop_music_on_task_clear), + description = stringResource(R.string.stop_music_setup_desc), + icon = painterResource(R.drawable.clear_all) + ) { + Switch(checked = stopMusicOnTaskClear, onCheckedChange = onStopMusicOnTaskClearChange) + } + } +} + +@Composable +fun AccountSettingsStep(navController: NavController) { + val (innerTubeCookie, _) = rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } + val (useLoginForBrowse, onUseLoginForBrowseChange) = rememberPreference(key = UseLoginForBrowse, defaultValue = false) + + StepLayout( + title = stringResource(R.string.account), + description = stringResource(R.string.login_description) + ) { + if (isLoggedIn) { + Text( + stringResource(R.string.login_successful), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + } else { + Button( + onClick = { + navController.navigate("login") + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.login)) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + WizardSettingsRow( + title = stringResource(R.string.use_login_for_browse), + description = stringResource(R.string.use_login_for_browse_desc), + // Assuming R.drawable.person exists + icon = painterResource(R.drawable.person), + enabled = isLoggedIn + ) { + Switch(checked = useLoginForBrowse, onCheckedChange = onUseLoginForBrowseChange, enabled = isLoggedIn) + } + } +} + + +@Composable +fun FinishStep() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(80.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.setup_complete), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.setup_complete_description), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } +} + +@Composable +fun WizardBottomBar( + position: Int, + maxPosition: Int, + onBack: () -> Unit, + onNext: () -> Unit, + onFinish: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + contentColor = MaterialTheme.colorScheme.onSurface + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .height(80.dp) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onBack, + enabled = position > 0 + ) { + Icon(Icons.AutoMirrored.Rounded.NavigateBefore, contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.back)) + } + + LinearProgressIndicator( + progress = { (position.toFloat() / maxPosition) }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) + + Button( + onClick = { + if (position < maxPosition) { + onNext() + } else { + onFinish() + } + } + ) { + Text(if (position < maxPosition) stringResource(R.string.next) else stringResource(R.string.finish)) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + if (position < maxPosition) Icons.AutoMirrored.Rounded.NavigateNext else Icons.Rounded.CheckCircle, + contentDescription = null + ) + } + } + } +} + +@Composable +fun FeatureChip( + icon: ImageVector? = null, + painterIcon: Int? = null, + text: String) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp) + ) + } + if (painterIcon != null) { + Icon( + painter = painterResource(painterIcon), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +fun StepLayout( + title: String, + description: String, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(32.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + content = content + ) + } +} + +@Composable +fun WizardSettingsRow( + title: String, + description: String, + icon: Painter, + enabled: Boolean = true, + content: @Composable () -> Unit +) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + modifier = Modifier.alpha(if (enabled) 1f else 0.5f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = icon, + contentDescription = title, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.padding(end = 8.dp)) { + Text(text = title, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.7f) + ) + } + } + content() + } + } + Spacer(modifier = Modifier.height(12.dp)) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/StatsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/StatsScreen.kt new file mode 100644 index 0000000..ab769bb --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/StatsScreen.kt @@ -0,0 +1,430 @@ +package com.babelsoftware.loudly.ui.screens + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.StatPeriod +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.* +import com.babelsoftware.loudly.ui.menu.AlbumMenu +import com.babelsoftware.loudly.ui.menu.ArtistMenu +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.viewmodels.StatsViewModel + +@Composable +fun AnimatedGradientBackground(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "background_transition") + val colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + MaterialTheme.colorScheme.background.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background + ) + + val animatedOffset by infiniteTransition.animateFloat( + initialValue = -0.5f, + targetValue = 0.5f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "offset_animation" + ) + + Canvas(modifier = modifier.fillMaxSize()) { + val brush = Brush.radialGradient( + colors = colors, + center = center + Offset(x = center.x * animatedOffset, y = center.y * animatedOffset), + radius = size.maxDimension * 1.2f + ) + drawRect(brush) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SongStatsListItem( + song: Song, + playCountRatio: Float, + isActive: Boolean, + onPlayClick: () -> Unit, + onMenuClick: () -> Unit +) { + var targetRatio by remember { mutableStateOf(0f) } + val animatedRatio by animateFloatAsState( + targetValue = targetRatio, + animationSpec = tween(durationMillis = 800, easing = FastOutSlowInEasing), + label = "ratio_animation" + ) + + LaunchedEffect(playCountRatio) { + targetRatio = playCountRatio + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .combinedClickable( + onClick = onPlayClick, + onLongClick = onMenuClick + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth(animatedRatio) + .height(64.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + shape = RoundedCornerShape(12.dp) + ) + ) + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = song.thumbnailUrl, + contentDescription = song.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = song.title, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = song.artists.joinToString { it.name }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + IconButton(onClick = onMenuClick) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = stringResource(R.string.menu), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun StatsScreen( + navController: NavController, + viewModel: StatsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val statPeriod by viewModel.statPeriod.collectAsState() + val summaryMostPlayedSongs by viewModel.summaryMostPlayedSongs.collectAsState() + val detailedMostPlayedSongs by viewModel.detailedMostPlayedSongs.collectAsState() + val mostPlayedArtists by viewModel.mostPlayedArtists.collectAsState() + val mostPlayedAlbums by viewModel.mostPlayedAlbums.collectAsState() + var songsCardExpanded by rememberSaveable { mutableStateOf(false) } + var artistsCardExpanded by rememberSaveable { mutableStateOf(false) } + var albumsCardExpanded by rememberSaveable { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() + + val parallaxOffset by remember { + derivedStateOf { + listState.firstVisibleItemScrollOffset * 0.38f + } + } + + val frostedCardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + ) + val frostedCardBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f)) + + Box(modifier = Modifier.fillMaxSize()) { + AnimatedGradientBackground( + modifier = Modifier.graphicsLayer { + translationY = -parallaxOffset + } + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.stats)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = stringResource(R.string.back) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.8f) + ) + ) + }, + containerColor = Color.Transparent + ) { innerPadding -> + LazyColumn( + state = listState, + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = innerPadding.calculateTopPadding() + 8.dp, + bottom = LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Bottom).asPaddingValues().calculateBottomPadding() + 16.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize() + ) { + if (summaryMostPlayedSongs.isEmpty() && mostPlayedArtists.isEmpty() && mostPlayedAlbums.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.trending_up, + text = stringResource(R.string.stats_empty), + modifier = Modifier + .fillParentMaxSize() + .animateItem() + ) + } + } else { + item { + ChipsRow( + chips = listOf( + StatPeriod.`1_WEEK` to pluralStringResource(R.plurals.n_week, 1, 1), + StatPeriod.`1_MONTH` to pluralStringResource(R.plurals.n_month, 1, 1), + StatPeriod.`3_MONTH` to pluralStringResource(R.plurals.n_month, 3, 3), + StatPeriod.`6_MONTH` to pluralStringResource(R.plurals.n_month, 6, 6), + StatPeriod.`1_YEAR` to pluralStringResource(R.plurals.n_year, 1, 1), + StatPeriod.ALL to stringResource(R.string.filter_all) + ), + currentValue = statPeriod, + onValueUpdate = { viewModel.statPeriod.value = it }, + modifier = Modifier.animateItem() + ) + } + + if (summaryMostPlayedSongs.isNotEmpty()) { + item(key = "mostPlayedSongs") { + val itemsToShow = if (songsCardExpanded) detailedMostPlayedSongs else summaryMostPlayedSongs + val maxPlayCount = (detailedMostPlayedSongs.firstOrNull()?.playCount?.toFloat() ?: 1f) * 1.1f + Card( + shape = RoundedCornerShape(20.dp), + colors = frostedCardColors, + border = frostedCardBorder, + onClick = { songsCardExpanded = !songsCardExpanded }, + modifier = Modifier.fillMaxWidth().animateItem() + ) { + Column( + modifier = Modifier.padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + NavigationTitle( + title = stringResource(R.string.most_played_songs), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(Modifier.height(4.dp)) + itemsToShow.forEach { songWithCount -> + val ratio = songWithCount.playCount.toFloat() / maxPlayCount + SongStatsListItem( + song = songWithCount.song, + playCountRatio = ratio, + isActive = songWithCount.song.id == mediaMetadata?.id, + onPlayClick = { + if (songWithCount.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + endpoint = WatchEndpoint(songWithCount.song.id), + preloadItem = songWithCount.song.toMediaMetadata() + ) + ) + } + }, + onMenuClick = { + menuState.show { + SongMenu( + originalSong = songWithCount.song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + } + if (detailedMostPlayedSongs.size > summaryMostPlayedSongs.size) { + TextButton( + onClick = { songsCardExpanded = !songsCardExpanded }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) { + Text( + text = if (songsCardExpanded) stringResource(R.string.show_less) + else stringResource(R.string.show_more) + ) + } + } + } + } + } + } + + if (mostPlayedArtists.isNotEmpty()) { + item(key = "mostPlayedArtists") { + Card( + shape = RoundedCornerShape(20.dp), + colors = frostedCardColors, + border = frostedCardBorder, + onClick = { artistsCardExpanded = !artistsCardExpanded }, + modifier = Modifier.fillMaxWidth().animateItem() + ) { + Column { + NavigationTitle( + title = stringResource(R.string.most_played_artists), + modifier = Modifier.padding(16.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items( + items = mostPlayedArtists, + key = { it.id } + ) { artist -> + ArtistGridItem( + artist = artist, + modifier = Modifier.combinedClickable( + onClick = { navController.navigate("artist/${artist.id}") }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + } + } + } + } + } + + if (mostPlayedAlbums.isNotEmpty()) { + item(key = "mostPlayedAlbums") { + Card( + shape = RoundedCornerShape(20.dp), + colors = frostedCardColors, + border = frostedCardBorder, + onClick = { albumsCardExpanded = !albumsCardExpanded }, + modifier = Modifier.fillMaxWidth().animateItem() + ) { + Column { + NavigationTitle( + title = stringResource(R.string.most_played_albums), + modifier = Modifier.padding(16.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items( + items = mostPlayedAlbums, + key = { it.id } + ) { album -> + AlbumGridItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + modifier = Modifier.combinedClickable( + onClick = { navController.navigate("album/${album.id}") }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubeBrowseScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubeBrowseScreen.kt new file mode 100644 index 0000000..682d6e8 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubeBrowseScreen.kt @@ -0,0 +1,302 @@ +@file:Suppress("UNUSED_EXPRESSION") + +package com.babelsoftware.loudly.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.ui.component.shimmer.GridItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.component.shimmer.TextPlaceholder +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.menu.YouTubeArtistMenu +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.ui.utils.SnapLayoutInfoProvider +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.viewmodels.YouTubeBrowseViewModel +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.IconButton + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun YouTubeBrowseScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: YouTubeBrowseViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val browseResult by viewModel.result.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f + val lazyGridState = rememberLazyGridState() + val snapLayoutInfoProvider = remember(lazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = lazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) + } + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (browseResult == null) { + item { + ShimmerHost( + modifier = Modifier.animateItem() + ) { + TextPlaceholder( + height = 36.dp, + modifier = Modifier + .padding(12.dp) + .width(250.dp), + ) + LazyRow { + items(4) { + GridItemPlaceHolder() + } + } + } + } + } + + browseResult?.items?.fastForEach { + if (it.items.isNotEmpty()) { + it.title?.let { title -> + item { + NavigationTitle(title) + } + } + if (it.items.all { item -> item is SongItem }) { + item { + LazyHorizontalGrid( + state = lazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * 4) + .animateItem() + ) { + items( + items = it.items, + ) { song -> + Box(Modifier.width(350.dp)) { + YouTubeListItem( + item = song as SongItem, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + isSwipeable = false, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.Companion.radio( + song.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + } + ) + .animateItem() + ) + } + } + } + } + } else { + item { + LazyRow { + items( + items = it.items, + ) { item -> + YouTubeGridItem( + item = item, + isActive = + when (item) { + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + modifier = + Modifier + .combinedClickable( + onClick = { + when (item) { + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate( + "artist/${item.id}" + ) + + is PlaylistItem -> navController.navigate( + "online_playlist/${item.id}" + ) + + else -> item + } + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress + ) + menuState.show { + when (item) { + is SongItem -> + YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is AlbumItem -> + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is ArtistItem -> + YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + + is PlaylistItem -> + YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + } + } + ) + .animateItem() + ) + } + } + } + } + } + } + } + } + + TopAppBar( + title = { Text(browseResult?.title.orEmpty()) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubePlaylistsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubePlaylistsScreen.kt new file mode 100644 index 0000000..ab3521a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/YouTubePlaylistsScreen.kt @@ -0,0 +1,107 @@ +package com.babelsoftware.loudly.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.shimmer.GridItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.viewmodels.YouTubePlaylistsViewModel +import com.babelsoftware.loudly.R + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun YouTubePlaylistsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: YouTubePlaylistsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + + val coroutineScope = rememberCoroutineScope() + + val playlists by viewModel.playlists.collectAsState() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = playlists.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("online_playlist/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + + if (playlists == null) { + items(8) { + ShimmerHost { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.your_youtube_playlists)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistItemsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistItemsScreen.kt new file mode 100644 index 0000000..356f543 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistItemsScreen.kt @@ -0,0 +1,389 @@ +package com.babelsoftware.loudly.ui.screens.artist + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.ui.component.shimmer.GridItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ListItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.menu.YouTubeArtistMenu +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.viewmodels.ArtistItemsViewModel +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.IconButton +import kotlin.collections.orEmpty + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistItemsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistItemsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val coroutineScope = rememberCoroutineScope() + + val title by viewModel.title.collectAsState() + val itemsPage by viewModel.itemsPage.collectAsState() + val songIndex: Map by remember(itemsPage) { + derivedStateOf { + itemsPage?.items + ?.filterIsInstance() + ?.associateBy { it.id } + .orEmpty() + } + } + + val snackbarHostState = remember { SnackbarHostState() } + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + LaunchedEffect(lazyGridState) { + snapshotFlow { + lazyGridState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + if (itemsPage == null) { + ShimmerHost( + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + ) { + repeat(8) { + ListItemPlaceHolder() + } + } + } + + if (itemsPage?.items?.firstOrNull() is SongItem) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = itemsPage?.items?.filterIsInstance().orEmpty(), + key = { it.id } + ) { song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(song.id) + } else { + selection.remove(song.id) + } + } + + YouTubeListItem( + item = song, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = song.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(song.id !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + song.endpoint ?: WatchEndpoint(videoId = song.id), + song.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + .animateItem() + ) + } + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost(Modifier.animateItem()) { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + } + } else { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + fillMaxWidth = true, + coroutineScope = coroutineScope, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue( + YouTubeQueue( + item.endpoint ?: WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ) + ) + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) + .animateItem() + ) + } + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost(Modifier.animateItem()) { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + } + + TopAppBar( + title = { + if (inSelectMode) { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + } else { + Text(title) + } + }, + navigationIcon = { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + } else { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == itemsPage?.items?.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == itemsPage?.items?.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(itemsPage?.items?.map { it.id }.orEmpty()) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + YouTubeSongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songId -> + songIndex[songId] + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + scrollBehavior = scrollBehavior + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistScreen.kt new file mode 100644 index 0000000..dafefea --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistScreen.kt @@ -0,0 +1,553 @@ +package com.babelsoftware.loudly.ui.screens.artist + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEach +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.valentinilk.shimmer.shimmer +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AppBarHeight +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.YouTubeGridItem +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.ui.component.shimmer.ButtonPlaceholder +import com.babelsoftware.loudly.ui.component.shimmer.ListItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.component.shimmer.TextPlaceholder +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.menu.YouTubeArtistMenu +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.ui.utils.fadingEdge +import com.babelsoftware.loudly.ui.utils.resize +import com.babelsoftware.loudly.viewmodels.ArtistViewModel +import androidx.core.net.toUri + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArtistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val artistPage = viewModel.artistPage + val libraryArtist by viewModel.libraryArtist.collectAsState() + val librarySongs by viewModel.librarySongs.collectAsState() + + val lazyListState = rememberLazyListState() + + val snackbarHostState = remember { SnackbarHostState() } + + val transparentAppBar by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex <= 1 + } + } + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .add(WindowInsets(top = -WindowInsets.systemBars.asPaddingValues().calculateTopPadding() - AppBarHeight)) + .asPaddingValues() + ) { + artistPage.let { + if (artistPage != null) { + item(key = "header") { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + AsyncImage( + model = artistPage.artist.thumbnail.resize(1200, 900), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 64.dp + ) + ) + AutoResizeText( + text = artistPage.artist.title, + style = MaterialTheme.typography.displayLarge, + fontSizeRange = FontSizeRange(32.sp, 58.sp), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.shuffle) + ) + } + } + + artistPage.artist.radioEndpoint?.let { radioEndpoint -> + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.radio)) + } + } + } + } + } + + if (librarySongs.isNotEmpty()) { + item { + NavigationTitle( + title = stringResource(R.string.from_your_library), + onClick = { + navController.navigate("artist/${viewModel.artistId}/songs") + } + ) + } + + items( + items = librarySongs, + key = { "local_${it.id}" } + ) { song -> + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint( + videoId = song.id + ), song.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem() + ) + } + } + + artistPage.sections.fastForEach { section -> + item { + NavigationTitle( + title = section.title, + onClick = section.moreEndpoint?.let { + { + navController.navigate("artist/${viewModel.artistId}/items?browseId=${it.browseId}?params=${it.params}") + } + } + ) + } + + if ((section.items.firstOrNull() as? SongItem)?.album != null) { + items( + items = section.items, + key = { it.id } + ) { song -> + YouTubeListItem( + item = song as SongItem, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.Companion.radio( + song.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem() + ) + } + } else { + item { + LazyRow { + items( + items = section.items, + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue( + YouTubeQueue.Companion.radio(item.toMediaMetadata()) + ) + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) + .animateItem() + ) + } + } + } + } + } + } else { + item(key = "shimmer") { + ShimmerHost { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + Spacer( + modifier = Modifier + .shimmer() + .background(MaterialTheme.colorScheme.onSurface) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 108.dp + ) + ) + TextPlaceholder( + height = 56.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } + + Row( + modifier = Modifier.padding(12.dp) + ) { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } + + TopAppBar( + title = { if (!transparentAppBar) Text(artistPage?.artist?.title.orEmpty()) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + actions = { + IconButton( + onClick = { + database.transaction { + val artist = libraryArtist?.artist + if (artist != null) { + update(artist.toggleLike()) + } else { + artistPage?.artist?.let { + insert( + ArtistEntity( + id = it.id, + name = it.title, + channelId = it.channelId, + thumbnailUrl = it.thumbnail + ).toggleLike() + ) + } + } + } + } + ) { + Icon( + painter = painterResource(if (libraryArtist?.artist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (libraryArtist?.artist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + + IconButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, + viewModel.artistPage?.artist?.shareLink?.toUri()) + context.startActivity(intent) + } + ) { + Icon( + painterResource(R.drawable.music_note), + contentDescription = null + ) + } + + IconButton( + onClick = { + viewModel.artistPage?.artist?.shareLink?.let { link -> + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, link) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + ) { + Icon( + painterResource(R.drawable.share), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior, + colors = if (transparentAppBar) { + TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + } else { + TopAppBarDefaults.topAppBarColors() + } + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistSongsScreen.kt new file mode 100644 index 0000000..dd85467 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/artist/ArtistSongsScreen.kt @@ -0,0 +1,316 @@ +package com.babelsoftware.loudly.ui.screens.artist + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachReversed +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.ArtistSongSortDescendingKey +import com.babelsoftware.loudly.constants.ArtistSongSortType +import com.babelsoftware.loudly.constants.ArtistSongSortTypeKey +import com.babelsoftware.loudly.constants.CONTENT_TYPE_HEADER +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.HideOnScrollFAB +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.ArtistSongsViewModel +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.IconButton + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistSongsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistSongsViewModel = hiltViewModel(), +) { + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference( + ArtistSongSortTypeKey, + ArtistSongSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference( + ArtistSongSortDescendingKey, + true + ) + + val artist by viewModel.artist.collectAsState() + val songs by viewModel.songs.collectAsState() + val songIndex: Map by remember(songs) { + derivedStateOf { + songs.associateBy { it.id } + } + } + + val lazyListState = rememberLazyListState() + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + LaunchedEffect(songIndex) { + selection.fastForEachReversed { songId -> + if (songIndex[songId] == null) { + selection.remove(songId) + } + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSongSortType.NAME -> R.string.sort_by_name + ArtistSongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_song, songs.size, songs.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + + itemsIndexed( + items = songs, + key = { _, item -> item.id } + ) { index, song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(song.id) + } else { + selection.remove(song.id) + } + } + + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = song.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(song.id !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + }) + .animateItem() + ) + } + } + + TopAppBar( + title = { + if (inSelectMode) { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + } else { + Text(artist?.artist?.name.orEmpty()) + } + }, + navigationIcon = { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + } else { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == songs.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == songs.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(songs.map { it.id }) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songId -> + songIndex[songId] + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + scrollBehavior = scrollBehavior + ) + + HideOnScrollFAB( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = artist?.artist?.name, + items = songs.shuffled().map { it.toMediaItem() }, + ) + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/guides/FeatureGuides.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/guides/FeatureGuides.kt new file mode 100644 index 0000000..e6c9954 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/guides/FeatureGuides.kt @@ -0,0 +1,309 @@ +package com.babelsoftware.loudly.ui.screens.guides + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.babelsoftware.loudly.R + +/** + * Basic structure for property introduction steps. + */ +@Composable +fun FeatureGuideStep( + title: String, + description: String, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(32.dp)) + Box( + modifier = Modifier + .fillMaxWidth(0.7f) + .aspectRatio(9f / 16f) + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border(2.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), RoundedCornerShape(24.dp)), + contentAlignment = Alignment.Center + ) { + content() + } + } +} + +@Composable +fun StoryShareFeatureStep(modifier: Modifier = Modifier) { + FeatureGuideStep( + title = stringResource(R.string.create_your_story), + description = stringResource(R.string.share_your_story_description), + modifier = modifier + ) { + StorySharePreview() + } +} + +@Composable +fun SettingsHeaderFeatureStep(modifier: Modifier = Modifier) { + FeatureGuideStep( + title = stringResource(R.string.customizable_settings), + description = stringResource(R.string.customizable_settings_description), + modifier = modifier + ) { + SettingsHeaderPreview() + } +} + +@Composable +fun PlayerPlaylistFeatureStep(modifier: Modifier = Modifier) { + FeatureGuideStep( + title = stringResource(R.string.new_song_list), + description = stringResource(R.string.new_song_list_description), + modifier = modifier + ) { + PlayerPlaylistPreview() + } +} + +@Composable +fun NewSettingsUiFeatureStep(modifier: Modifier = Modifier) { + FeatureGuideStep( + title = stringResource(R.string.remastered_settings), + description = stringResource(R.string.remastered_settings_description), + modifier = modifier + ) { + NewSettingsUiPreview() + } +} + +@Composable +fun ClassicWidgetFeatureStep(modifier: Modifier = Modifier) { + FeatureGuideStep( + title = stringResource(R.string.classic_music_widget), + description = stringResource(R.string.classic_music_widget_description), + modifier = modifier + ) { + ClassicWidgetPreview() + } +} + +// Special preview designs +@Composable +private fun StorySharePreview() { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) + ) + ) + ) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(0.5f)) + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)) + ) + Spacer(modifier = Modifier.height(12.dp)) + Box(modifier = Modifier.fillMaxWidth(0.7f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(Color.White.copy(alpha = 0.8f))) + Spacer(modifier = Modifier.height(4.dp)) + Box(modifier = Modifier.fillMaxWidth(0.5f).height(6.dp).clip(RoundedCornerShape(4.dp)).background(Color.White.copy(alpha = 0.5f))) + Spacer(modifier = Modifier.weight(0.2f)) + Column( + modifier = Modifier.fillMaxWidth(0.8f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color.White.copy(alpha = 0.3f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.3f) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(Color.White) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.size(20.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.7f))) + Box(modifier = Modifier.size(30.dp).clip(CircleShape).background(Color.White)) + Box(modifier = Modifier.size(20.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.7f))) + } + } + Spacer(modifier = Modifier.weight(0.3f)) + } + } +} + +@Composable +private fun SettingsHeaderPreview() { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.35f) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(15.dp).clip(CircleShape).background(MaterialTheme.colorScheme.tertiary)) + Spacer(modifier = Modifier.height(2.dp)) + Box(modifier = Modifier.fillMaxWidth(0.5f).height(20.dp).clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp)).background(MaterialTheme.colorScheme.primary)) + } + } + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + repeat(6) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(modifier = Modifier.size(18.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Box(modifier = Modifier.width(80.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Box(modifier = Modifier.width(50.dp).height(6.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.width(80.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Box(modifier = Modifier.width(50.dp).height(6.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.width(80.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Box(modifier = Modifier.width(50.dp).height(6.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + } + } + } + } + } +} + +@Composable +private fun PlayerPlaylistPreview() { + Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + MaterialTheme.colorScheme.surfaceVariant + ) + ) + )) + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .fillMaxHeight(0.65f) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box(modifier = Modifier.fillMaxWidth().height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.primary)) + Box(modifier = Modifier.fillMaxWidth(0.8f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.fillMaxWidth(0.9f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.fillMaxWidth(0.8f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.fillMaxWidth(0.9f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.fillMaxWidth(0.8f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.fillMaxWidth(0.9f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.fillMaxWidth(0.8f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + Box(modifier = Modifier.fillMaxWidth(0.9f).height(10.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f))) + } + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 20.dp) + .fillMaxWidth(0.5f) + .aspectRatio(1f) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + ) + } +} + +@Composable +private fun NewSettingsUiPreview() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box(modifier = Modifier.width(50.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Box(modifier = Modifier.fillMaxWidth().height(30.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))) + Spacer(modifier = Modifier.height(4.dp)) + Box(modifier = Modifier.width(60.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Spacer(modifier = Modifier.height(4.dp)) + Box(modifier = Modifier.width(60.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Box(modifier = Modifier.width(50.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Spacer(modifier = Modifier.height(4.dp)) + Box(modifier = Modifier.width(60.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))) + Box(modifier = Modifier.fillMaxWidth().height(30.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box(modifier = Modifier.fillMaxWidth().height(12.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) + Box(modifier = Modifier.fillMaxWidth().height(12.dp).clip(RoundedCornerShape(6.dp)).background(MaterialTheme.colorScheme.surfaceVariant)) + } + } +} + +@Composable +private fun ClassicWidgetPreview() { + Image( + painter = painterResource(id = R.drawable.widget_preview_classic), + contentDescription = "Classic Widget Preview", + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryAlbumsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryAlbumsScreen.kt new file mode 100644 index 0000000..7d58483 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryAlbumsScreen.kt @@ -0,0 +1,382 @@ +package com.babelsoftware.loudly.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.AlbumFilter +import com.babelsoftware.loudly.constants.AlbumFilterKey +import com.babelsoftware.loudly.constants.AlbumSortDescendingKey +import com.babelsoftware.loudly.constants.AlbumSortType +import com.babelsoftware.loudly.constants.AlbumSortTypeKey +import com.babelsoftware.loudly.constants.AlbumViewTypeKey +import com.babelsoftware.loudly.constants.AppDesignVariantKey +import com.babelsoftware.loudly.constants.AppDesignVariantType +import com.babelsoftware.loudly.constants.CONTENT_TYPE_ALBUM +import com.babelsoftware.loudly.constants.CONTENT_TYPE_HEADER +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.GridCellSize +import com.babelsoftware.loudly.constants.GridCellSizeKey +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.constants.LibraryViewType +import com.babelsoftware.loudly.constants.SmallGridThumbnailHeight +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.ui.component.AlbumGridItem +import com.babelsoftware.loudly.ui.component.AlbumListItem +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.menu.AlbumMenu +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.LibraryAlbumsViewModel +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryAlbumsScreen( + navController: NavController, + viewModel: LibraryAlbumsViewModel = hiltViewModel(), +) { + var filterType by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + var filter by rememberEnumPreference(AlbumFilterKey, AlbumFilter.LIKED) + val gridCellSize by rememberEnumPreference(GridCellSizeKey, GridCellSize.SMALL) + var viewType by rememberEnumPreference(AlbumViewTypeKey, LibraryViewType.GRID) + val (sortType, onSortTypeChange) = rememberEnumPreference( + AlbumSortTypeKey, + AlbumSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(AlbumSortDescendingKey, true) + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + val (appDesignVariant) = rememberEnumPreference( + AppDesignVariantKey, + defaultValue = AppDesignVariantType.NEW + ) + + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = + remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val filterContent = @Composable { + Row { + Spacer(Modifier.width(12.dp)) + if (appDesignVariant == AppDesignVariantType.NEW) { + FilterChip( + label = { Text(stringResource(R.string.albums)) }, + selected = true, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = { filterType = LibraryFilter.LIBRARY }, + shape = RoundedCornerShape(16.dp), + leadingIcon = { + Icon(painter = painterResource(R.drawable.close), contentDescription = "") + }, + ) + } + ChipsRow( + chips = + listOf( + AlbumFilter.LIKED to stringResource(R.string.filter_liked), + AlbumFilter.LIBRARY to stringResource(R.string.filter_library), + ), + currentValue = filter, + onValueUpdate = { + filter = it + }, + modifier = Modifier.weight(1f), + ) + } + } + + LaunchedEffect(Unit) { + if (ytmSync && isLoggedIn && isInternetAvailable(context)) + withContext(Dispatchers.IO) { + viewModel.sync() + } + } + + val albums by viewModel.allAlbums.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + LibraryViewType.LIST -> lazyListState.animateScrollToItem(0) + LibraryViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date + AlbumSortType.NAME -> R.string.sort_by_name + AlbumSortType.ARTIST -> R.string.sort_by_artist + AlbumSortType.YEAR -> R.string.sort_by_year + AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count + AlbumSortType.LENGTH -> R.string.sort_by_length + AlbumSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + + Spacer(Modifier.weight(1f)) + + albums?.let { albums -> + Text( + text = pluralStringResource(R.plurals.n_album, albums.size, albums.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp) + ) { + Icon( + painter = painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view + } + ), + contentDescription = null + ) + } + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + when (viewType) { + LibraryViewType.LIST -> + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + albums?.let { albums -> + if (albums.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.album, + text = stringResource(R.string.library_album_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = albums, + key = { it.id }, + contentType = { CONTENT_TYPE_ALBUM } + ) { album -> + AlbumListItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + }, + ).animateItem(), + ) + } + } + } + + LibraryViewType.GRID -> + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive( + minSize = when (gridCellSize) { + GridCellSize.SMALL -> SmallGridThumbnailHeight + GridCellSize.BIG -> GridThumbnailHeight + } + 24.dp + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + albums?.let { albums -> + if (albums.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + EmptyPlaceholder( + icon = R.drawable.album, + text = stringResource(R.string.library_album_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = albums, + key = { it.id }, + contentType = { CONTENT_TYPE_ALBUM } + ) { album -> + AlbumGridItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem() + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryArtistsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryArtistsScreen.kt new file mode 100644 index 0000000..7195fd9 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryArtistsScreen.kt @@ -0,0 +1,368 @@ +package com.babelsoftware.loudly.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.constants.AppDesignVariantKey +import com.babelsoftware.loudly.constants.AppDesignVariantType +import com.babelsoftware.loudly.constants.ArtistFilter +import com.babelsoftware.loudly.constants.ArtistFilterKey +import com.babelsoftware.loudly.constants.ArtistSortDescendingKey +import com.babelsoftware.loudly.constants.ArtistSortType +import com.babelsoftware.loudly.constants.ArtistSortTypeKey +import com.babelsoftware.loudly.constants.ArtistViewTypeKey +import com.babelsoftware.loudly.constants.CONTENT_TYPE_ARTIST +import com.babelsoftware.loudly.constants.CONTENT_TYPE_HEADER +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.GridCellSize +import com.babelsoftware.loudly.constants.GridCellSizeKey +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.constants.LibraryViewType +import com.babelsoftware.loudly.constants.SmallGridThumbnailHeight +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.ui.component.ArtistGridItem +import com.babelsoftware.loudly.ui.component.ArtistListItem +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.menu.ArtistMenu +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.LibraryArtistsViewModel +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryArtistsScreen( + navController: NavController, + viewModel: LibraryArtistsViewModel = hiltViewModel(), +) { + var filterType by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + + var filter by rememberEnumPreference(ArtistFilterKey, ArtistFilter.LIKED) + val gridCellSize by rememberEnumPreference(GridCellSizeKey, GridCellSize.SMALL) + var viewType by rememberEnumPreference(ArtistViewTypeKey, LibraryViewType.GRID) + val (sortType, onSortTypeChange) = rememberEnumPreference( + ArtistSortTypeKey, + ArtistSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSortDescendingKey, true) + val (ytmSync) = rememberPreference(YtmSyncKey, true) + val (appDesignVariant) = rememberEnumPreference( + AppDesignVariantKey, + defaultValue = AppDesignVariantType.NEW + ) + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = + remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val filterContent = @Composable { + Row { + Spacer(Modifier.width(12.dp)) + if (appDesignVariant == AppDesignVariantType.NEW) { + FilterChip( + label = { Text(stringResource(R.string.artists)) }, + selected = true, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = { filterType = LibraryFilter.LIBRARY }, + shape = RoundedCornerShape(16.dp), + leadingIcon = { + Icon(painter = painterResource(R.drawable.close), contentDescription = "") + }, + ) + } + ChipsRow( + chips = + listOf( + ArtistFilter.LIKED to stringResource(R.string.filter_liked), + ArtistFilter.LIBRARY to stringResource(R.string.filter_library), + ), + currentValue = filter, + onValueUpdate = { + filter = it + }, + modifier = Modifier.weight(1f), + ) + } + } + + LaunchedEffect(Unit) { + if (ytmSync && isLoggedIn && isInternetAvailable(context)) + withContext(Dispatchers.IO) { + viewModel.sync() + } + } + + val artists by viewModel.allArtists.collectAsState() + val coroutineScope = rememberCoroutineScope() + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + LibraryViewType.LIST -> lazyListState.animateScrollToItem(0) + LibraryViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSortType.NAME -> R.string.sort_by_name + ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count + ArtistSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + + Spacer(Modifier.weight(1f)) + + artists?.let { artists -> + Text( + text = pluralStringResource(R.plurals.n_artist, artists.size, artists.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp) + ) { + Icon( + painter = painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view + } + ), + contentDescription = null + ) + } + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + when (viewType) { + LibraryViewType.LIST -> + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + artists?.let { artists -> + if (artists.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.artist, + text = stringResource(R.string.library_artist_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = artists, + key = { it.id }, + contentType = { CONTENT_TYPE_ARTIST } + ) { artist -> + ArtistListItem( + artist = artist, + trailingContent = { + IconButton( + onClick = { + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${artist.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem(), + ) + } + } + } + + LibraryViewType.GRID -> + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive( + minSize = when (gridCellSize) { + GridCellSize.SMALL -> SmallGridThumbnailHeight + GridCellSize.BIG -> GridThumbnailHeight + } + 24.dp + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + artists?.let { artists -> + if (artists.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + EmptyPlaceholder( + icon = R.drawable.artist, + text = stringResource(R.string.library_artist_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = artists, + key = { it.id }, + contentType = { CONTENT_TYPE_ARTIST } + ) { artist -> + ArtistGridItem( + artist = artist, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${artist.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem() + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryMixScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryMixScreen.kt new file mode 100644 index 0000000..5865bbd --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryMixScreen.kt @@ -0,0 +1,830 @@ +package com.babelsoftware.loudly.ui.screens.library + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material.icons.automirrored.rounded.TrendingUp +import androidx.compose.material.icons.rounded.Cached +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat.requestPermissions +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.AutoPlaylistCachedPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistDownloadShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistLikedShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistLocalPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistTopPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoSyncLocalSongsKey +import com.babelsoftware.loudly.constants.CONTENT_TYPE_HEADER +import com.babelsoftware.loudly.constants.CONTENT_TYPE_PLAYLIST +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.GridCellSize +import com.babelsoftware.loudly.constants.GridCellSizeKey +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.constants.MixSortDescendingKey +import com.babelsoftware.loudly.constants.MixSortType +import com.babelsoftware.loudly.constants.MixSortTypeKey +import com.babelsoftware.loudly.constants.MixViewType +import com.babelsoftware.loudly.constants.MixViewTypeKey +import com.babelsoftware.loudly.constants.ScannerSensitivity +import com.babelsoftware.loudly.constants.ScannerSensitivityKey +import com.babelsoftware.loudly.constants.ScannerStrictExtKey +import com.babelsoftware.loudly.constants.SmallGridThumbnailHeight +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.extensions.reversed +import com.babelsoftware.loudly.ui.component.AlbumGridItem +import com.babelsoftware.loudly.ui.component.AlbumListItem +import com.babelsoftware.loudly.ui.component.ArtistGridItem +import com.babelsoftware.loudly.ui.component.ArtistListItem +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.PlaylistGridItem +import com.babelsoftware.loudly.ui.component.PlaylistListItem +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.menu.AlbumMenu +import com.babelsoftware.loudly.ui.menu.ArtistMenu +import com.babelsoftware.loudly.ui.menu.PlaylistMenu +import com.babelsoftware.loudly.ui.utils.scanLocal +import com.babelsoftware.loudly.ui.utils.syncDB +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.LibraryMixViewModel +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.Collator +import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryMixScreen( + navController: NavController, + viewModel: LibraryMixViewModel = hiltViewModel(), +) { + val gridCellSize by rememberEnumPreference(GridCellSizeKey, GridCellSize.SMALL) + var filterType by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) + val filterContent = @Composable { + Row(modifier = Modifier.padding(bottom = 16.dp)) { + ChipsRow( + chips = + listOf( + LibraryFilter.PLAYLISTS to stringResource(R.string.filter_playlists), + LibraryFilter.SONGS to stringResource(R.string.filter_songs), + LibraryFilter.ALBUMS to stringResource(R.string.filter_albums), + LibraryFilter.ARTISTS to stringResource(R.string.filter_artists), + ), + currentValue = filterType, + onValueUpdate = { + filterType = + if (filterType == it) { + LibraryFilter.LIBRARY + } else { + it + } + }, + modifier = Modifier.weight(1f), + ) + } + } + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val (sortType, onSortTypeChange) = rememberEnumPreference( + MixSortTypeKey, + MixSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(MixSortDescendingKey, true) + var viewType by rememberEnumPreference(MixViewTypeKey, MixViewType.GRID) + + val (autoPlaylistLiked) = rememberPreference(AutoPlaylistLikedShowKey, defaultValue = true) + val (autoPlaylistDownload) = rememberPreference( + AutoPlaylistDownloadShowKey, defaultValue = true + ) + val (autoPlaylistTopPlaylist) = rememberPreference( + AutoPlaylistTopPlaylistShowKey, defaultValue = true + ) + val (autoPlaylistCached) = rememberPreference( + AutoPlaylistCachedPlaylistShowKey, defaultValue = true + ) + val (autoPlaylistLocal) = rememberPreference( + AutoPlaylistLocalPlaylistShowKey, defaultValue = true + ) + + val (ytmSync) = rememberPreference(YtmSyncKey, true) + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = + remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val likedSongs by viewModel.likedSongs.collectAsState() + val downloadSongs by viewModel.downloadSongs.collectAsState(initial = null) + val topSongs by viewModel.topSongs.collectAsState(initial = null) + val localSongsCount by viewModel.localSongsCount.collectAsState() + val cachedSongs by viewModel.cachedSongs.collectAsState() + val topSize = 50 + val likedPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.liked) + ), + songCount = if (likedSongs != null) likedSongs!!.size else 0, + songThumbnails = emptyList() + ) + val downloadPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.offline) + ), + songCount = if (downloadSongs != null) downloadSongs!!.size else 0, + songThumbnails = emptyList() + ) + val topSizeInt = topSize.toString().toInt() + if (topSongs != null) + println(topSongs?.size) + + val topPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.my_top) + ), + songCount = topSongs?.let { minOf(it.size, topSizeInt) } ?: 0, + songThumbnails = emptyList() + ) + val localPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.local) + ), + songCount = localSongsCount, + songThumbnails = emptyList() + ) + + val cachedPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.cached) + ), + songCount = cachedSongs.size, + songThumbnails = emptyList() + ) + + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + var isScannerActive by remember { mutableStateOf(false) } + var isScanFinished by remember { mutableStateOf(false) } + var mediaPermission by remember { mutableStateOf(true) } + val mediaPermissionLevel = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE + val (autoSyncLocalSongs) = rememberPreference( + key = AutoSyncLocalSongsKey, + defaultValue = true + ) + val (scannerSensitivity) = rememberEnumPreference( + key = ScannerSensitivityKey, + defaultValue = ScannerSensitivity.LEVEL_2 + ) + val (strictExtensions) = rememberPreference(ScannerStrictExtKey, defaultValue = false) + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + MixViewType.LIST -> lazyListState.animateScrollToItem(0) + MixViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + LaunchedEffect(Unit) { + if (ytmSync && isLoggedIn && isInternetAvailable(context)) { + withContext(Dispatchers.IO) { + viewModel.syncAllLibrary() + } + } + } + + if (autoSyncLocalSongs) { + LaunchedEffect(Unit) { + if (isScannerActive) { + return@LaunchedEffect + } + if (context.checkSelfPermission(mediaPermissionLevel) + != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + context as Activity, + arrayOf(mediaPermissionLevel), PackageManager.PERMISSION_GRANTED + ) + mediaPermission = false + return@LaunchedEffect + } else if (context.checkSelfPermission(mediaPermissionLevel) + == PackageManager.PERMISSION_GRANTED + ) { + mediaPermission = true + } + isScanFinished = false + isScannerActive = true + coroutineScope.launch(Dispatchers.IO) { + val directoryStructure = scanLocal(context).value + syncDB(database, directoryStructure.toList(), scannerSensitivity, strictExtensions) + isScannerActive = false + isScanFinished = true + database.localSongsCount() + } + } + } + + val albums = viewModel.albums.collectAsState() + val artist = viewModel.artists.collectAsState() + val playlist = viewModel.playlists.collectAsState() + + var allItems = albums.value + artist.value + playlist.value + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + allItems = when (sortType) { + MixSortType.CREATE_DATE -> allItems.sortedBy { item -> + when (item) { + is Album -> item.album.bookmarkedAt + is Artist -> item.artist.bookmarkedAt + is Playlist -> item.playlist.createdAt + else -> LocalDateTime.now() + } + } + + MixSortType.LAST_UPDATED -> allItems.sortedBy { item -> + when (item) { + is Album -> item.album.lastUpdateTime + is Artist -> item.artist.lastUpdateTime + is Playlist -> item.playlist.lastUpdateTime + else -> LocalDateTime.now() + } + } + + MixSortType.NAME -> allItems.sortedWith( + compareBy(collator) { item -> + when (item) { + is Album -> item.album.title + is Artist -> item.artist.name + is Playlist -> item.playlist.name + else -> "" + } + }, + ) + }.reversed(sortDescending) + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + MixSortType.CREATE_DATE -> R.string.sort_by_create_date + MixSortType.LAST_UPDATED -> R.string.sort_by_last_updated + MixSortType.NAME -> R.string.sort_by_name + } + }, + ) + + Spacer(Modifier.weight(1f)) + + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(end = 6.dp) + ) { + Icon( + painter = painterResource( + when (viewType) { + MixViewType.LIST -> R.drawable.list + MixViewType.GRID -> R.drawable.grid_view + } + ), + contentDescription = null + ) + } + } + } + if (viewType == MixViewType.LIST) { + Box( + modifier = Modifier.fillMaxSize() + ) { + if (!autoPlaylistLiked && !autoPlaylistDownload && !autoPlaylistTopPlaylist && !autoPlaylistCached && !autoPlaylistLocal && allItems.isEmpty()) { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Fixed(1), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + item { + EmptyPlaceholder( + icon = R.drawable.library_music_outlined, + text = stringResource(R.string.library_empty), + ) + } + } + } else { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Fixed(1), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + if (autoPlaylistLiked) { + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = likedPlaylist, + thumbnail = Icons.Rounded.Favorite, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/liked") + }) + .animateItem() + ) + } + } + + if (autoPlaylistDownload) { + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = downloadPlaylist, + thumbnail = Icons.Rounded.CloudDownload, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/downloaded") + }) + .animateItem() + ) + } + } + if (autoPlaylistTopPlaylist) { + item( + key = "topPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = topPlaylist, + thumbnail = Icons.AutoMirrored.Rounded.TrendingUp, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("top_playlist/$topSize") + }) + .animateItem() + ) + } + } + if (autoPlaylistCached) { + item( + key = "cachedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = cachedPlaylist, + thumbnail = Icons.Rounded.Cached, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("CachedPlaylist") + }) + .animateItem() + ) + } + } + if (autoPlaylistLocal) { + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = localPlaylist, + thumbnail = Icons.Rounded.MusicNote, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("AutoPlaylistLocal") + }) + .animateItem() + ) + } + } + items( + allItems, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST } + ) { item -> + when (item) { + is Playlist -> { + PlaylistListItem( + playlist = item, + thumbnail = Icons.AutoMirrored.Rounded.QueueMusic, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = { + navController.navigate("local_playlist/${item.id}") + }, onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + PlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }) + .animateItem() + ) + } + + is Artist -> { + ArtistListItem( + artist = item, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = { + navController.navigate("artist/${item.id}") + }, onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }) + .animateItem() + ) + } + + is Album -> { + AlbumListItem( + album = item, + isActive = item.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = { + navController.navigate("album/${item.id}") + }, onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }) + .animateItem() + ) + } + + else -> {} + } + } + } + } + } + } else { + Box( + modifier = Modifier.fillMaxSize() + ) { + if (!autoPlaylistLiked && !autoPlaylistDownload && !autoPlaylistTopPlaylist && !autoPlaylistCached && !autoPlaylistLocal && allItems.isEmpty()) { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Fixed(1), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + item { + EmptyPlaceholder( + icon = R.drawable.library_music_outlined, + text = stringResource(R.string.library_empty) + ) + } + } + } else { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive( + minSize = when (gridCellSize) { + GridCellSize.SMALL -> SmallGridThumbnailHeight + GridCellSize.BIG -> GridThumbnailHeight + } + 24.dp + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + if (autoPlaylistLiked) { + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = likedPlaylist, + thumbnail = Icons.Rounded.Favorite, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/liked") + }) + .animateItem() + ) + } + } + if (autoPlaylistDownload) { + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = downloadPlaylist, + thumbnail = Icons.Rounded.CloudDownload, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/downloaded") + }) + .animateItem() + ) + } + } + if (autoPlaylistTopPlaylist) { + item( + key = "topPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = topPlaylist, + thumbnail = Icons.AutoMirrored.Rounded.TrendingUp, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("top_playlist/$topSize") + }) + .animateItem() + ) + } + } + if (autoPlaylistCached) { + item( + key = "cachedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = cachedPlaylist, + thumbnail = Icons.Rounded.Cached, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("CachedPlaylist") + }) + .animateItem() + ) + } + } + if (autoPlaylistLocal) { + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = localPlaylist, + thumbnail = Icons.Rounded.MusicNote, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("AutoPlaylistLocal") + }) + .animateItem() + ) + } + } + items( + allItems, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { item -> + when (item) { + is Playlist -> { + PlaylistGridItem( + playlist = item, + thumbnail = Icons.AutoMirrored.Rounded.QueueMusic, + fillMaxWidth = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = { + navController.navigate("local_playlist/${item.id}") + }, onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + PlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }) + .animateItem() + ) + } + + is Artist -> { + ArtistGridItem( + artist = item, + fillMaxWidth = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable(onClick = { + navController.navigate("artist/${item.id}") + }, onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }) + .animateItem() + ) + } + + is Album -> { + AlbumGridItem( + album = item, + isActive = item.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + fillMaxWidth = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + } + ) + .animateItem() + ) + } + + else -> {} + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryPlaylistsScreen.kt new file mode 100644 index 0000000..3ff91f1 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryPlaylistsScreen.kt @@ -0,0 +1,809 @@ +package com.babelsoftware.loudly.ui.screens.library + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material.icons.automirrored.rounded.TrendingUp +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Cached +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat.requestPermissions +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.constants.AppDesignVariantKey +import com.babelsoftware.loudly.constants.AppDesignVariantType +import com.babelsoftware.loudly.constants.AutoPlaylistCachedPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistDownloadShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistLikedShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistLocalPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistTopPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoSyncLocalSongsKey +import com.babelsoftware.loudly.constants.CONTENT_TYPE_HEADER +import com.babelsoftware.loudly.constants.CONTENT_TYPE_PLAYLIST +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.GridCellSize +import com.babelsoftware.loudly.constants.GridCellSizeKey +import com.babelsoftware.loudly.constants.GridThumbnailHeight +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.constants.LibraryViewType +import com.babelsoftware.loudly.constants.PlaylistSortDescendingKey +import com.babelsoftware.loudly.constants.PlaylistSortType +import com.babelsoftware.loudly.constants.PlaylistSortTypeKey +import com.babelsoftware.loudly.constants.PlaylistViewTypeKey +import com.babelsoftware.loudly.constants.ScannerSensitivity +import com.babelsoftware.loudly.constants.ScannerSensitivityKey +import com.babelsoftware.loudly.constants.ScannerStrictExtKey +import com.babelsoftware.loudly.constants.SmallGridThumbnailHeight +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.HideOnScrollFAB +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.PlaylistGridItem +import com.babelsoftware.loudly.ui.component.PlaylistListItem +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.loudly.ui.menu.PlaylistMenu +import com.babelsoftware.loudly.ui.utils.scanLocal +import com.babelsoftware.loudly.ui.utils.syncDB +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.LibraryPlaylistsViewModel +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.util.UUID + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryPlaylistsScreen( + navController: NavController, + viewModel: LibraryPlaylistsViewModel = hiltViewModel(), +) { + val (appDesignVariant) = rememberEnumPreference( + AppDesignVariantKey, + defaultValue = AppDesignVariantType.NEW + ) + var filterType by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) + val filterContent = @Composable { + if (appDesignVariant == AppDesignVariantType.NEW) { + Row { + ChipsRow( + chips = + listOf( + LibraryFilter.PLAYLISTS to stringResource(R.string.filter_playlists), + LibraryFilter.SONGS to stringResource(R.string.filter_songs), + LibraryFilter.ALBUMS to stringResource(R.string.filter_albums), + LibraryFilter.ARTISTS to stringResource(R.string.filter_artists), + ), + currentValue = filterType, + onValueUpdate = { + filterType = + if (filterType == it) { + LibraryFilter.LIBRARY + } else { + it + } + }, + modifier = Modifier.weight(1f), + ) + } + } + } + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val haptic = LocalHapticFeedback.current + + var isScannerActive by remember { mutableStateOf(false) } + var isScanFinished by remember { mutableStateOf(false) } + var mediaPermission by remember { mutableStateOf(true) } + val mediaPermissionLevel = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE + val (autoSyncLocalSongs) = rememberPreference( + key = AutoSyncLocalSongsKey, + defaultValue = true + ) + val (scannerSensitivity) = rememberEnumPreference( + key = ScannerSensitivityKey, + defaultValue = ScannerSensitivity.LEVEL_2 + ) + val (strictExtensions) = rememberPreference(ScannerStrictExtKey, defaultValue = false) + + val coroutineScope = rememberCoroutineScope() + + val likedSongs by viewModel.likedSongs.collectAsState() + val downloadSongs by viewModel.downloadSongs.collectAsState(initial = null) + val localSongsCount by viewModel.localSongsCount.collectAsState() + val cachedSongs by viewModel.cachedSongs.collectAsState() + val topSongs by viewModel.topSongs.collectAsState(initial = null) + val topSize = 50 + val likedPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.liked) + ), + songCount = if (likedSongs != null) likedSongs!!.size else 0, + songThumbnails = emptyList() + ) + val downloadPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.offline) + ), + songCount = if (downloadSongs != null) downloadSongs!!.size else 0, + songThumbnails = emptyList() + ) + val topSizeInt = topSize.toString().toInt() + if (topSongs != null) + println(topSongs?.size) + + val topPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.my_top) + ), + songCount = topSongs?.let { minOf(it.size, topSizeInt) } ?: 0, + songThumbnails = emptyList() + ) + val localPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.local) + ), + songCount = localSongsCount, + songThumbnails = emptyList() + ) + + val cachedPlaylist = Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.cached) + ), + songCount = cachedSongs.size, + songThumbnails = emptyList() + ) + + val gridCellSize by rememberEnumPreference(GridCellSizeKey, GridCellSize.SMALL) + var viewType by rememberEnumPreference(PlaylistViewTypeKey, LibraryViewType.GRID) + val (sortType, onSortTypeChange) = rememberEnumPreference( + PlaylistSortTypeKey, + PlaylistSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference( + PlaylistSortDescendingKey, + true + ) + + val playlists by viewModel.allPlaylists.collectAsState() + + val (autoPlaylistLiked) = rememberPreference(AutoPlaylistLikedShowKey, defaultValue = true) + val (autoPlaylistDownload) = rememberPreference( + AutoPlaylistDownloadShowKey, defaultValue = true + ) + val (autoPlaylistTopPlaylist) = rememberPreference( + AutoPlaylistTopPlaylistShowKey, defaultValue = true + ) + val (autoPlaylistCached) = rememberPreference( + AutoPlaylistCachedPlaylistShowKey, defaultValue = true + ) + val (autoPlaylistLocal) = rememberPreference( + AutoPlaylistLocalPlaylistShowKey, defaultValue = true + ) + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + LaunchedEffect(Unit) { + if (ytmSync) { + viewModel.sync() + } + } + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + LibraryViewType.LIST -> lazyListState.animateScrollToItem(0) + LibraryViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + if (autoSyncLocalSongs) { + LaunchedEffect(Unit) { + if (isScannerActive) { + return@LaunchedEffect + } + if (context.checkSelfPermission(mediaPermissionLevel) + != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + context as Activity, + arrayOf(mediaPermissionLevel), PackageManager.PERMISSION_GRANTED + ) + mediaPermission = false + return@LaunchedEffect + } else if (context.checkSelfPermission(mediaPermissionLevel) + == PackageManager.PERMISSION_GRANTED + ) { + mediaPermission = true + } + isScanFinished = false + isScannerActive = true + coroutineScope.launch(Dispatchers.IO) { + val directoryStructure = scanLocal(context).value + syncDB(database, directoryStructure.toList(), scannerSensitivity, strictExtensions) + isScannerActive = false + isScanFinished = true + database.localSongsCount() + } + } + } + + var showAddPlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var syncedPlaylist: Boolean by remember { + mutableStateOf(false) + } + + if (showAddPlaylistDialog) { + TextFieldDialog( + icon = { Icon(imageVector = Icons.Rounded.Add, contentDescription = null) }, + title = { Text(text = stringResource(R.string.create_playlist)) }, + onDismiss = { showAddPlaylistDialog = false }, + onDone = { playlistName -> + viewModel.viewModelScope.launch(Dispatchers.IO) { + val browseId = if (syncedPlaylist) + YouTube.createPlaylist(playlistName) + else null + + database.query { + insert( + PlaylistEntity( + name = playlistName, + browseId = browseId, + bookmarkedAt = LocalDateTime.now() + ) + ) + } + } + }, + extraContent = { + if (isLoggedIn && isInternetAvailable(context)) { + Row( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 40.dp) + ) { + Column { + Text( + text = stringResource(R.string.sync_playlist), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.allows_for_sync_witch_youtube), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(0.7f) + ) + } + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End + ) { + Switch( + checked = syncedPlaylist, + onCheckedChange = { + syncedPlaylist = !syncedPlaylist + }, + ) + } + } + } + } + ) + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSortType.NAME -> R.string.sort_by_name + PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count + } + } + ) + + Spacer(Modifier.weight(1f)) + + playlists?.let { playlists -> + Text( + text = pluralStringResource( + R.plurals.n_playlist, + playlists.size, + playlists.size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp, end = 6.dp) + ) { + Icon( + painter = painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view + } + ), + contentDescription = null + ) + } + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + playlists?.let { playlists -> + when (viewType) { + LibraryViewType.LIST -> { + if (!autoPlaylistLiked && !autoPlaylistDownload && !autoPlaylistTopPlaylist && !autoPlaylistCached && !autoPlaylistLocal && playlists.isEmpty()) { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Fixed(1), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + item { + EmptyPlaceholder( + icon = R.drawable.queue_music, + text = stringResource(R.string.library_playlist_empty) + ) + } + } + } else { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + if (autoPlaylistLiked) { + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = likedPlaylist, + thumbnail = Icons.Rounded.Favorite, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("auto_playlist/liked") + } + .animateItem() + ) + } + } + if (autoPlaylistDownload) { + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = downloadPlaylist, + thumbnail = Icons.Rounded.CloudDownload, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("auto_playlist/downloaded") + } + .animateItem() + ) + } + } + if (autoPlaylistTopPlaylist) { + item( + key = "topPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = topPlaylist, + thumbnail = Icons.AutoMirrored.Rounded.TrendingUp, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("top_playlist/$topSize") + } + ) + .animateItem() + ) + } + } + if (autoPlaylistCached) { + item( + key = "cachedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = cachedPlaylist, + thumbnail = Icons.Rounded.Cached, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("CachedPlaylist") + } + ) + .animateItem() + ) + } + } + if (autoPlaylistLocal) { + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistListItem( + playlist = localPlaylist, + thumbnail = Icons.Rounded.MusicNote, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("AutoPlaylistLocal") + } + ) + .animateItem() + ) + } + } + items( + items = playlists, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST } + ) { playlist -> + PlaylistListItem( + playlist = playlist, + thumbnail = Icons.AutoMirrored.Rounded.QueueMusic, + trailingContent = { + IconButton( + onClick = { + menuState.show { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("local_playlist/${playlist.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } + HideOnScrollFAB( + lazyListState = lazyListState, + icon = R.drawable.add, + onClick = { + showAddPlaylistDialog = true + } + ) + } + + LibraryViewType.GRID -> { + if (!autoPlaylistLiked && !autoPlaylistDownload && !autoPlaylistTopPlaylist && !autoPlaylistCached && !autoPlaylistLocal && playlists.isEmpty()) { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Fixed(1), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + item { + EmptyPlaceholder( + icon = R.drawable.queue_music, + text = stringResource(R.string.library_playlist_empty) + ) + } + } + } else { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive( + minSize = when (gridCellSize) { + GridCellSize.SMALL -> SmallGridThumbnailHeight + GridCellSize.BIG -> GridThumbnailHeight + } + 24.dp + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + filterContent() + } + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER + ) { + headerContent() + } + + if (autoPlaylistLiked) { + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = likedPlaylist, + thumbnail = Icons.Rounded.Favorite, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/liked") + }, + ) + .animateItem() + ) + } + } + if (autoPlaylistDownload) { + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = downloadPlaylist, + thumbnail = Icons.Rounded.CloudDownload, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/downloaded") + }, + ) + .animateItem() + ) + } + } + if (autoPlaylistTopPlaylist) { + item( + key = "topPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = topPlaylist, + thumbnail = Icons.AutoMirrored.Rounded.TrendingUp, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("top_playlist/$topSize") + } + ) + .animateItem() + ) + } + } + if (autoPlaylistCached) { + item( + key = "cachedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = cachedPlaylist, + thumbnail = Icons.Rounded.Cached, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("CachedPlaylist") + } + ) + .animateItem() + ) + } + } + if (autoPlaylistLocal) { + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST } + ) { + PlaylistGridItem( + playlist = localPlaylist, + thumbnail = Icons.Rounded.MusicNote, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("AutoPlaylistLocal") + } + ) + .animateItem() + ) + } + } + items( + items = playlists, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST } + ) { playlist -> + PlaylistGridItem( + playlist = playlist, + thumbnail = Icons.AutoMirrored.Rounded.QueueMusic, + fillMaxWidth = true, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("local_playlist/${playlist.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem() + ) + } + } + } + HideOnScrollFAB( + lazyListState = lazyGridState, + icon = R.drawable.add, + onClick = { + showAddPlaylistDialog = true + } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryScreen.kt new file mode 100644 index 0000000..7618784 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibraryScreen.kt @@ -0,0 +1,28 @@ +package com.babelsoftware.loudly.ui.screens.library + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.utils.rememberEnumPreference + +@Composable +fun LibraryScreen(navController: NavController) { + val filterType by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) + + Box( + modifier = Modifier.fillMaxSize(), + ) { + when (filterType) { + LibraryFilter.LIBRARY -> LibraryMixScreen(navController) + LibraryFilter.PLAYLISTS -> LibraryPlaylistsScreen(navController) + LibraryFilter.SONGS -> LibrarySongsScreen(navController) + LibraryFilter.ALBUMS -> LibraryAlbumsScreen(navController) + LibraryFilter.ARTISTS -> LibraryArtistsScreen(navController) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibrarySongsScreen.kt new file mode 100644 index 0000000..8555420 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/library/LibrarySongsScreen.kt @@ -0,0 +1,592 @@ +package com.babelsoftware.loudly.ui.screens.library + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachReversed +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.AppDesignVariantKey +import com.babelsoftware.loudly.constants.AppDesignVariantType +import com.babelsoftware.loudly.constants.CONTENT_TYPE_HEADER +import com.babelsoftware.loudly.constants.CONTENT_TYPE_SONG +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.constants.SongFilter +import com.babelsoftware.loudly.constants.SongFilterKey +import com.babelsoftware.loudly.constants.SongSortDescendingKey +import com.babelsoftware.loudly.constants.SongSortType +import com.babelsoftware.loudly.constants.SongSortTypeKey +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.HideOnScrollFAB +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.LibrarySongsViewModel +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun LibrarySongsScreen( + navController: NavController, + viewModel: LibrarySongsViewModel = hiltViewModel(), +) { + var filterType by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + var filter by rememberEnumPreference(SongFilterKey, SongFilter.LIKED) + + val (sortType, onSortTypeChange) = rememberEnumPreference( + SongSortTypeKey, + SongSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + val (ytmSync) = rememberPreference(YtmSyncKey, true) + val (appDesignVariant) = rememberEnumPreference( + AppDesignVariantKey, + defaultValue = AppDesignVariantType.NEW + ) + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = + remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val songs by viewModel.allSongs.collectAsState() + + LaunchedEffect(Unit) { + if (ytmSync && isLoggedIn && isInternetAvailable(context)) { + withContext(Dispatchers.IO) { + viewModel.syncLikedSongs() + } + } + } + + val lazyListState = rememberLazyListState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + val focusRequester = remember { FocusRequester() } + var isSearching by rememberSaveable { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + val searchQueryStr = searchQuery.text.trim() + val filteredSongs = if (searchQueryStr.isEmpty()) { + songs + } else { + songs.filter { song -> + song.song.title.contains(searchQueryStr, ignoreCase = true) || + song.artists.joinToString("") + .contains(searchQueryStr, ignoreCase = true) + } + } + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + lazyListState.animateScrollToItem(0) + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + LaunchedEffect(inSelectMode) { + backStackEntry?.savedStateHandle?.set("inSelectMode", inSelectMode) + } + + LaunchedEffect(filteredSongs) { + selection.fastForEachReversed { songId -> + if (filteredSongs.find { it.id == songId } == null) { + selection.remove(songId) + } + } + } + + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER + ) { + Row { + Spacer(Modifier.width(12.dp)) + if (appDesignVariant == AppDesignVariantType.NEW) { + FilterChip( + label = { Text(stringResource(R.string.songs)) }, + selected = true, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = { filterType = LibraryFilter.LIBRARY }, + shape = RoundedCornerShape(16.dp), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = "" + ) + }, + ) + } + ChipsRow( + chips = + listOf( + SongFilter.LIKED to stringResource(R.string.filter_liked), + SongFilter.LIBRARY to stringResource(R.string.filter_library), + SongFilter.DOWNLOADED to stringResource(R.string.filter_downloaded), + SongFilter.CACHED to stringResource(R.string.cached) + ), + currentValue = filter, + onValueUpdate = { + filter = it + }, + modifier = Modifier.weight(1f), + ) + } + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp) + ) { + if (filter != SongFilter.CACHED) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + Spacer(Modifier.weight(1f)) + if (!isSearching) { + IconButton( + onClick = { isSearching = true }, + Modifier.size(20.dp) + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } else { + IconButton( + onClick = { isSearching = false }, + Modifier.size(20.dp) + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + Spacer(Modifier.weight(1f)) + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } else { + if (!isSearching) { + IconButton( + onClick = { isSearching = true }, + Modifier.size(20.dp) + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } else { + IconButton( + onClick = { isSearching = false }, + Modifier.size(20.dp) + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + if (filteredSongs.isEmpty() && !isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.library_song_empty), + modifier = Modifier.animateItem() + ) + } + } + if (filteredSongs.isEmpty() && isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + + itemsIndexed( + items = filteredSongs, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { _, song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(song.id) + } else { + selection.remove(song.id) + } + } + + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + showInLibraryIcon = true, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = song.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + if (filter == SongFilter.CACHED) { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss, + isFromCache = true + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(song.id !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = songs.indexOfFirst { it.song.id == song.id } + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + .animateItem() + ) + } + } + + HideOnScrollFAB( + visible = songs.isNotEmpty(), + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.shuffled() + .map { it.toMediaItem() } + ) + } + ) + } + + if (inSelectMode) { + TopAppBar( + title = { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + }, + navigationIcon = { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + }, + actions = { + Checkbox( + checked = selection.size == filteredSongs.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == filteredSongs.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(filteredSongs.map { it.id }) + } + } + ) + if (filter == SongFilter.CACHED) { + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songId -> + filteredSongs.find { it.id == songId } + }, + isFromCache = true, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else { + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songId -> + filteredSongs.find { it.id == songId } + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + } + ) + } else { + if (isSearching) { + TopAppBar( + title = { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (searchQuery.text.isNotEmpty()) { + IconButton( + onClick = { searchQuery = TextFieldValue("") } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + }, + navigationIcon = { + if (isSearching) { + com.babelsoftware.loudly.ui.component.IconButton( + onClick = { + isSearching = false + searchQuery = TextFieldValue() + }, + onLongClick = { + isSearching = false + searchQuery = TextFieldValue() + } + ) { + Icon( + painter = painterResource( + R.drawable.arrow_back + ), + contentDescription = null + ) + } + } + } + ) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistLocalScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistLocalScreen.kt new file mode 100644 index 0000000..a18e864 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistLocalScreen.kt @@ -0,0 +1,721 @@ +package com.babelsoftware.loudly.ui.screens.playlist + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachReversed +import androidx.compose.ui.util.fastSumBy +import androidx.core.app.ActivityCompat.requestPermissions +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.AlbumThumbnailSize +import com.babelsoftware.loudly.constants.AutoSyncLocalSongsKey +import com.babelsoftware.loudly.constants.CONTENT_TYPE_SONG +import com.babelsoftware.loudly.constants.FlatSubfoldersKey +import com.babelsoftware.loudly.constants.ScannerSensitivity +import com.babelsoftware.loudly.constants.ScannerSensitivityKey +import com.babelsoftware.loudly.constants.ScannerStrictExtKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SongFolderItem +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.ui.utils.getDirectorytree +import com.babelsoftware.loudly.ui.utils.scanLocal +import com.babelsoftware.loudly.ui.utils.syncDB +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.LibrarySongsViewModel +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Stack + +@SuppressLint("StateFlowValueCalledInComposition") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AutoPlaylistLocalScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: LibrarySongsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val folderStack = remember { viewModel.folderPositionStack } + val (flatSubfolders) = rememberPreference(FlatSubfoldersKey, defaultValue = true) + val coroutineScope = rememberCoroutineScope() + var inLocal by viewModel.inLocal + var isScannerActive by remember { mutableStateOf(false) } + var isScanFinished by remember { mutableStateOf(false) } + var mediaPermission by remember { mutableStateOf(true) } + val mediaPermissionLevel = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) Manifest.permission.READ_MEDIA_AUDIO + else Manifest.permission.READ_EXTERNAL_STORAGE + + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + val (autoSyncLocalSongs) = rememberPreference( + key = AutoSyncLocalSongsKey, + defaultValue = true + ) + val (scannerSensitivity) = rememberEnumPreference( + key = ScannerSensitivityKey, + defaultValue = ScannerSensitivity.LEVEL_2 + ) + val (strictExtensions) = rememberPreference(ScannerStrictExtKey, defaultValue = false) + + val snackbarHostState = remember { SnackbarHostState() } + val lazyListState = rememberLazyListState() + + flatSubfolders.let { + viewModel.folderPositionStack = Stack() + } + + if (folderStack.isEmpty()) { + val cachedTree = getDirectorytree() + if (cachedTree == null) { + viewModel.getLocalSongs(context, viewModel.databaseLink) + } + folderStack.push( + if (flatSubfolders) viewModel.localSongDirectoryTree.value.toFlattenedTree() + else viewModel.localSongDirectoryTree.value + ) + } + var currDir by remember { + mutableStateOf(folderStack.peek()) + } + + val songs = currDir.files + val likeLength = remember(songs) { + songs.fastSumBy { it.song.duration } + } + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + + LaunchedEffect(inSelectMode) { + backStackEntry?.savedStateHandle?.set("inSelectMode", inSelectMode) + } + + var isSearching by rememberSaveable { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + + val searchQueryStr = searchQuery.text.trim() + val filteredItems = remember(currDir, searchQueryStr) { + if (searchQueryStr.isEmpty()) currDir else currDir.copy( + subdirs = currDir.subdirs.filter { dir -> + dir.currentDir.contains(searchQueryStr, ignoreCase = true) + }, + files = currDir.files.filter { song -> + song.title.contains(searchQueryStr, ignoreCase = true) || + song.artists.joinToString("").contains(searchQueryStr, ignoreCase = true) + } + ) + } + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + lazyListState.animateScrollToItem(0) + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + if (inLocal) { + BackHandler { + if (folderStack.size > 1) { + folderStack.pop() + currDir = folderStack.peek() + } else inLocal = false + } + } + + if (autoSyncLocalSongs) { + LaunchedEffect(Unit) { + if (isScannerActive) { + return@LaunchedEffect + } + if (context.checkSelfPermission(mediaPermissionLevel) + != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + context as Activity, + arrayOf(mediaPermissionLevel), PackageManager.PERMISSION_GRANTED + ) + mediaPermission = false + return@LaunchedEffect + } else if (context.checkSelfPermission(mediaPermissionLevel) + == PackageManager.PERMISSION_GRANTED + ) { + mediaPermission = true + } + isScanFinished = false + isScannerActive = true + coroutineScope.launch(Dispatchers.IO) { + val directoryStructure = scanLocal(context).value + syncDB(database, directoryStructure.toList(), scannerSensitivity, strictExtensions) + isScannerActive = false + isScanFinished = true + } + } + } + + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + + LaunchedEffect(songs) { + selection.fastForEachReversed { songId -> + if (songs.find { it.id == songId } == null) { + selection.remove(songId) + } + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + if (filteredItems.toList().isEmpty() && !isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty) + ) + } + } + if (filteredItems.toList().isEmpty() && isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + if (filteredItems.toList().isNotEmpty() && !isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + val libcarditem = 25.dp + Box( + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(libcarditem)) + .align(alignment = Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(110.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(Alignment.Center) + ) + } + Spacer(Modifier.height(12.dp)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizeText( + text = stringResource(R.string.local), + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + Text( + text = makeTimeString(likeLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = { + if (isScannerActive) { + return@Button + } + if (context.checkSelfPermission(mediaPermissionLevel) + != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions( + context as Activity, + arrayOf(mediaPermissionLevel), + PackageManager.PERMISSION_GRANTED + ) + mediaPermission = false + return@Button + } else if (context.checkSelfPermission(mediaPermissionLevel) + == PackageManager.PERMISSION_GRANTED + ) { + mediaPermission = true + } + isScanFinished = false + isScannerActive = true + val text = context.getString(R.string.sync_local_songs_toast) + Toast.makeText( + context, + text, + Toast.LENGTH_SHORT + ).show() + coroutineScope.launch(Dispatchers.IO) { + val directoryStructure = + scanLocal(context).value + syncDB( + database, + directoryStructure.toList(), + scannerSensitivity, + strictExtensions + ) + isScannerActive = false + isScanFinished = true + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null + ) + } + Button( + onClick = { + playerConnection.addToQueue( + items = currDir.toList().map { it.toMediaItem() }, + ) + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.local), + items = currDir + .toList() + .map { it.toMediaItem() }) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.local), + items = currDir.toList().shuffled() + .map { it.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + } + itemsIndexed( + items = filteredItems.subdirs, + key = { _, item -> item.uid }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { _, folder -> + SongFolderItem( + folder = folder, + subtitle = pluralStringResource( + R.plurals.n_song, + folder.toList().size, + folder.toList().size + ), + modifier = Modifier + .combinedClickable { + currDir = folderStack.push(folder) + } + .animateItem(), + menuState = menuState, + navController = navController + ) + } + if (currDir.subdirs.size > 0 && currDir.files.size > 0) { + item( + key = "folder_songs_divider", + ) { + HorizontalDivider( + thickness = DividerDefaults.Thickness, + modifier = Modifier.padding(20.dp) + ) + } + } + itemsIndexed( + items = filteredItems.files, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, song -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(song.id) + } else { + selection.remove(song.id) + } + } + if (index == 0) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, filteredItems.toList().size, filteredItems.toList().size + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal + ) + } + } + SongListItem( + song = song, + isActive = song.id == mediaMetadata?.id, + showLikedIcon = true, + showInLibraryIcon = true, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = selection.contains(song.id), + onCheckedChange = { checked -> + if (checked) selection.add(song.id) + else selection.remove(song.id) + } + ) + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(song.id !in selection) + } else { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.local), + items = songs.map { it.toMediaItem() }, + startIndex = songs.indexOfFirst { it.song.id == song.id } + ) + ) + } + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + .animateItem() + ) + } + } + if (inSelectMode) { + TopAppBar( + title = { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + }, + navigationIcon = { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + }, + actions = { + Checkbox( + checked = selection.size == filteredItems.files.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == filteredItems.files.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(filteredItems.files.map { it.id }) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + selection = selection.mapNotNull { songId -> + songs.find { it.id == songId } + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode, + navController = navController + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + ) + } else { + TopAppBar( + title = { + if (isSearching) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (searchQuery.text.isNotEmpty()) { + IconButton( + onClick = { searchQuery = TextFieldValue("") } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + } else { + Text(if (viewModel.folderPositionStack.size > 1) currDir.currentDir else "Internal Storage") + } + }, + navigationIcon = { + com.babelsoftware.loudly.ui.component.IconButton( + onClick = { + if (isSearching) { + isSearching = false + searchQuery = TextFieldValue() + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (!isSearching && !inSelectMode) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + }, + scrollBehavior = if (!isSearching) scrollBehavior else null + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistScreen.kt new file mode 100644 index 0000000..118712c --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/AutoPlaylistScreen.kt @@ -0,0 +1,777 @@ +package com.babelsoftware.loudly.ui.screens.playlist + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachReversed +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.AlbumThumbnailSize +import com.babelsoftware.loudly.constants.CONTENT_TYPE_SONG +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.SongSortDescendingKey +import com.babelsoftware.loudly.constants.SongSortType +import com.babelsoftware.loudly.constants.SongSortTypeKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.AutoPlaylistViewModel +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.collections.orEmpty + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AutoPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AutoPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val scope = rememberCoroutineScope() + + var isSearching by rememberSaveable { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = + remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + + val playlist = if (viewModel.playlist == "liked") stringResource(R.string.liked) else stringResource(R.string.offline) + val songs by viewModel.likedSongs.collectAsState(null) + val mutableSongs = remember { + mutableStateListOf() + } + val likeLength = remember(songs) { + songs?.fastSumBy { it.song.duration } ?: 0 + } + val playlistId = viewModel.playlist + val playlistType = when (playlistId) { + "liked" -> PlaylistType.LIKE + "downloaded" -> PlaylistType.DOWNLOAD + else -> PlaylistType.OTHER + } + val (sortType, onSortTypeChange) = rememberEnumPreference( + SongSortTypeKey, + SongSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + val snackbarHostState = remember { SnackbarHostState() } + + val backStackEntry by navController.currentBackStackEntryAsState() + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + + LaunchedEffect(inSelectMode) { + backStackEntry?.savedStateHandle?.set("inSelectMode", inSelectMode) + } + LaunchedEffect(songs) { + selection.fastForEachReversed { songId -> + if (songs?.find { it.id == songId } == null) { + selection.remove(songId) + } + } + } + + LaunchedEffect(Unit) { + if (ytmSync && isLoggedIn && isInternetAvailable(context)) { + withContext(Dispatchers.IO) { + if (playlistType == PlaylistType.LIKE) viewModel.syncLikedSongs() + } + } + } + + LaunchedEffect(songs) { + mutableSongs.apply { + clear() + songs?.let { addAll(it) } + } + if (songs?.isEmpty() == true) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs?.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED } == true) + Download.STATE_COMPLETED + else if (songs?.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED + || downloads[it.song.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.song.id]?.state == Download.STATE_COMPLETED + } == true) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource(R.string.remove_download_playlist_confirm, playlist), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + val searchQueryStr = searchQuery.text.trim() + val filteredSongs = if (searchQueryStr.isEmpty()) { + songs + } else { + songs?.filter { song -> + song.song.title.contains(searchQueryStr, ignoreCase = true) || + song.artists.joinToString("") + .contains(searchQueryStr, ignoreCase = true) + } + } + val state = rememberLazyListState() + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = state, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (filteredSongs.isNullOrEmpty() && isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + if (songs != null) { + if (songs!!.isEmpty() && !isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty) + ) + } + } else { + if (!isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + val libcarditem = 25.dp + Box( + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(libcarditem)) + .align(alignment = Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + imageVector = if (viewModel.playlist == "liked") Icons.Rounded.Favorite else Icons.Rounded.CloudDownload, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(110.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(Alignment.Center) + ) + } + Spacer(Modifier.height(12.dp)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizeText( + text = playlist, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + Text( + text = makeTimeString(likeLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + when (downloadState) { + Download.STATE_COMPLETED -> { + Button( + onClick = { + showRemoveDownloadDialog = true + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + Button( + onClick = { + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) + } + } + + else -> { + Button( + onClick = { + songs!!.forEach { song -> + val downloadRequest = + DownloadRequest.Builder( + song.song.id, + song.song.id.toUri() + ) + .setCustomCacheKey(song.song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + if (isLoggedIn && ytmSync && isInternetAvailable(context) && playlistType == PlaylistType.LIKE) { + Button( + onClick = { + scope.launch(Dispatchers.IO) { + viewModel.syncLikedSongs() + snackbarHostState.showSnackbar( + context.getString( + R.string.playlist_synced + ) + ) + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null + ) + } + } + Button( + onClick = { + playerConnection.addToQueue( + items = songs!!.map { it.toMediaItem() }, + ) + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.liked), + items = songs!!.map { it.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist, + items = songs!!.shuffled() + .map { it.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + if (filteredSongs!!.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + } + ) + } + } + } + } + if (filteredSongs != null) { + itemsIndexed( + items = filteredSongs, + key = { _, song -> song.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, songWrapper -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(songWrapper.id) + } else { + selection.remove(songWrapper.id) + } + } + if (index == 0) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal + ) + } + } + SongListItem( + song = songWrapper, + isActive = songWrapper.song.id == mediaMetadata?.id, + showInLibraryIcon = true, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = songWrapper.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(songWrapper.id !in selection) + } else if (songWrapper.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist, + items = songs!!.map { it.toMediaItem() }, + startIndex = songs!!.indexOfFirst { it.song.id == songWrapper.id } + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + .animateItem() + ) + } + } + } + } + } + + if (inSelectMode) { + TopAppBar( + title = { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + }, + navigationIcon = { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + }, + actions = { + Checkbox( + checked = selection.size == filteredSongs?.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == filteredSongs?.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(filteredSongs?.map { it.id }.orEmpty()) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songId -> + songs?.find { it.id == songId } + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + ) + } else { + TopAppBar( + title = { + if (isSearching) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (searchQuery.text.isNotEmpty()) { + IconButton( + onClick = { searchQuery = TextFieldValue("") } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + } + }, + navigationIcon = { + com.babelsoftware.loudly.ui.component.IconButton( + onClick = { + if (isSearching) { + isSearching = false + searchQuery = TextFieldValue() + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (!isSearching && !inSelectMode) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + }, + scrollBehavior = if (!isSearching) scrollBehavior else null + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } + } +} + +enum class PlaylistType { + LIKE, DOWNLOAD, OTHER +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/CachePlaylistScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/CachePlaylistScreen.kt new file mode 100644 index 0000000..3911bfa --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/CachePlaylistScreen.kt @@ -0,0 +1,654 @@ +package com.babelsoftware.loudly.ui.screens.playlist + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cached +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.AlbumThumbnailSize +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.viewmodels.CachePlaylistViewModel +import com.babelsoftware.loudly.R +import kotlin.collections.find +import kotlin.collections.forEach + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CachePlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: CachePlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val haptic = LocalHapticFeedback.current + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val cachedSongs by viewModel.cachedSongs.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + val lazyListState = rememberLazyListState() + + val likeLength = remember(cachedSongs) { + cachedSongs.fastSumBy { it.song.duration } + } + val backStackEntry by navController.currentBackStackEntryAsState() + var isSearching by rememberSaveable { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + val downloadUtil = LocalDownloadUtil.current + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + val mutableSongs = remember { + mutableStateListOf() + } + LaunchedEffect(cachedSongs) { + mutableSongs.apply { + clear() + addAll(cachedSongs) + } + if (cachedSongs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (cachedSongs.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (cachedSongs.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED + || downloads[it.song.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.song.id]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + stringResource(R.string.cached) + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { + showRemoveDownloadDialog = false + cachedSongs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + + LaunchedEffect(inSelectMode) { + backStackEntry?.savedStateHandle?.set("inSelectMode", inSelectMode) + } + + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + + val searchQueryStr = searchQuery.text.trim() + val filteredSongs = if (searchQueryStr.isEmpty()) { + cachedSongs + } else { + cachedSongs.filter { song -> + song.song.title.contains(searchQueryStr, ignoreCase = true) || + song.artists.joinToString("") + .contains(searchQueryStr, ignoreCase = true) + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (filteredSongs.isEmpty() && !isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty) + ) + } + } + if (filteredSongs.isEmpty() && isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } else { + if (filteredSongs.isNotEmpty() && !isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + val libcarditem = 25.dp + Box( + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(libcarditem)) + .align(alignment = Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + imageVector = Icons.Rounded.Cached, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(110.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(Alignment.Center) + ) + } + Spacer(Modifier.height(12.dp)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizeText( + text = context.getString(R.string.cached), + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + Text( + text = makeTimeString(likeLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + when (downloadState) { + Download.STATE_COMPLETED -> { + Button( + onClick = { + showRemoveDownloadDialog = true + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + Button( + onClick = { + cachedSongs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) + } + } + + else -> { + Button( + onClick = { + cachedSongs.forEach { song -> + val downloadRequest = + DownloadRequest.Builder( + song.song.id, + song.song.id.toUri() + ) + .setCustomCacheKey(song.song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + Button( + onClick = { + playerConnection.addToQueue( + items = cachedSongs.map { it.toMediaItem() }, + ) + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.cached), + items = cachedSongs.map { it.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.cached), + items = cachedSongs.shuffled() + .map { it.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + } + if (filteredSongs.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal + ) + } + } + } + itemsIndexed( + filteredSongs, + key = { _, song -> song.id } + ) { _, songWrapper -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(songWrapper.id) + } else { + selection.remove(songWrapper.id) + } + } + SongListItem( + song = songWrapper, + isActive = songWrapper.id == mediaMetadata?.id, + isPlaying = isPlaying, + showLikedIcon = true, + showInLibraryIcon = true, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = selection.contains(songWrapper.id), + onCheckedChange = { checked -> + if (checked) selection.add(songWrapper.id) + else selection.remove(songWrapper.id) + } + ) + } else { + IconButton(onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper, + navController = navController, + onDismiss = menuState::dismiss, + isFromCache = true, + ) + } + }) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(songWrapper.id !in selection) + } else if (songWrapper.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.cached), + items = cachedSongs.map { it.toMediaItem() }, + startIndex = cachedSongs.indexOfFirst { it.song.id == songWrapper.id } + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + .animateItem() + ) + } + } + } + + if (inSelectMode) { + TopAppBar( + title = { + Text( + pluralStringResource( + R.plurals.n_selected, + selection.size, + selection.size + ) + ) + }, + navigationIcon = { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + }, + actions = { + Checkbox( + checked = selection.size == cachedSongs.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == cachedSongs.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(cachedSongs.map { it.id }) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songId -> + cachedSongs.find { it.id == songId } + }, + isFromCache = true, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + ) + } else { + TopAppBar( + title = { + if (isSearching) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (searchQuery.text.isNotEmpty()) { + IconButton( + onClick = { searchQuery = TextFieldValue("") } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + } + }, + navigationIcon = { + IconButton( + onClick = { + if (isSearching) { + isSearching = false + searchQuery = TextFieldValue() + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (!isSearching && !inSelectMode) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + }, + scrollBehavior = if (!isSearching) scrollBehavior else null + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/LocalPlaylistScreen.kt new file mode 100644 index 0000000..803f88d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/LocalPlaylistScreen.kt @@ -0,0 +1,1077 @@ +@file:Suppress("NAME_SHADOWING") + +package com.babelsoftware.loudly.ui.screens.playlist + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.util.fastForEachReversed +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.LocalSyncUtils +import com.babelsoftware.loudly.constants.AlbumThumbnailSize +import com.babelsoftware.loudly.constants.PlaylistEditLockKey +import com.babelsoftware.loudly.constants.PlaylistSongSortDescendingKey +import com.babelsoftware.loudly.constants.PlaylistSongSortType +import com.babelsoftware.loudly.constants.PlaylistSongSortTypeKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.PlaylistSong +import com.babelsoftware.loudly.extensions.move +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.isInternetAvailable +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.LocalPlaylistViewModel +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.IconButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.detectReorder +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable + +@SuppressLint("SuspiciousIndentation") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun LocalPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: LocalPlaylistViewModel = hiltViewModel(), +) { + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() + val mutableSongs = remember { mutableStateListOf() } + + val (sortType, onSortTypeChange) = rememberEnumPreference( + PlaylistSongSortTypeKey, + PlaylistSongSortType.CUSTOM + ) + val (sortDescending, onSortDescendingChange) = rememberPreference( + PlaylistSongSortDescendingKey, + true + ) + var locked by rememberPreference(PlaylistEditLockKey, defaultValue = true) + + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var isSearching by rememberSaveable { mutableStateOf(false) } + var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + val filteredSongs = remember(songs, query) { + if (query.text.isEmpty()) songs + else songs.filter { song -> + song.song.title.contains(query.text, ignoreCase = true) || + song.song.artists.fastAny { it.name.contains(query.text, ignoreCase = true) } + } + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + if (isSearching) { + BackHandler { + isSearching = false + query = TextFieldValue() + } + } + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, Int>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + LaunchedEffect(songs) { + mutableSongs.apply { + clear() + addAll(songs) + } + selection.fastForEachReversed { mapId -> + if (songs.find { it.map.id == mapId } == null) { + selection.remove(mapId) + } + } + } + + val headerItems = 2 + var dragInfo by remember { + mutableStateOf?>(null) + } + val reorderableState = rememberReorderableLazyListState( + onMove = { from, to -> + if (to.index >= headerItems && from.index >= headerItems) { + val currentDragInfo = dragInfo + dragInfo = if (currentDragInfo == null) { + (from.index - headerItems) to (to.index - headerItems) + } else { + currentDragInfo.first to (to.index - headerItems) + } + mutableSongs.move(from.index - headerItems, to.index - headerItems) + } + } + ) + dragInfo?.let { (from, to) -> + database.transaction { + move(viewModel.playlistId, from, to) + } + viewModel.viewModelScope.launch(Dispatchers.IO) { + val from = from + val to = to + val playlistSongMap = database.songMapsToPlaylist(viewModel.playlistId, 0) + + var fromIndex = from //- headerItems + val toIndex = to //- headerItems + + var successorIndex = if (fromIndex > toIndex) toIndex else toIndex + 1 + + /* + * Because of how YouTube Music handles playlist changes, you necessarily need to + * have the SetVideoId of the successor when trying to move a song inside of a + * playlist. + * For this reason, if we are trying to move a song to the last element of a playlist, + * we need to first move it as penultimate and then move the last element before it. + */ + if (successorIndex >= playlistSongMap.size) { + playlistSongMap[fromIndex].setVideoId?.let { setVideoId -> + playlistSongMap[toIndex].setVideoId?.let { successorSetVideoId -> + viewModel.playlist.first()?.playlist?.browseId?.let { browseId -> + YouTube.moveSongPlaylist( + browseId, + setVideoId, + successorSetVideoId + ) + } + } + } + + successorIndex = fromIndex + fromIndex = toIndex + } + + playlistSongMap[fromIndex].setVideoId?.let { setVideoId -> + playlistSongMap[successorIndex].setVideoId?.let { successorSetVideoId -> + viewModel.playlist.first()?.playlist?.browseId?.let { browseId -> + YouTube.moveSongPlaylist( + browseId, + setVideoId, + successorSetVideoId + ) + } + } + } + } + dragInfo = null + } + + var dismissJob: Job? by remember { mutableStateOf(null) } + + var showEditDialog by remember { mutableStateOf(false) } + if (showEditDialog) { + playlist?.playlist?.let { playlistEntity -> + TextFieldDialog( + icon = { + Icon( + painter = painterResource(R.drawable.edit), + contentDescription = null + ) + }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue( + playlistEntity.name, + TextRange(playlistEntity.name.length) + ), + onDone = { name -> + database.query { + update(playlistEntity.copy(name = name)) + } + viewModel.viewModelScope.launch(Dispatchers.IO) { + playlistEntity.browseId?.let { YouTube.renamePlaylist(it, name) } + } + } + ) + } + } + + var showRemoveDownloadDialog by remember { mutableStateOf(false) } + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist?.playlist!!.name + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = reorderableState.listState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + modifier = Modifier.reorderable(reorderableState) + ) { + if (filteredSongs.isEmpty() && isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + playlist?.let { playlist -> + if (playlist.songCount == 0) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty), + modifier = Modifier.animateItem() + ) + } + } else { + if (!isSearching) { + item { + LocalPlaylistHeader( + playlist = playlist, + songs = songs, + onShowEditDialog = { showEditDialog = true }, + onShowRemoveDownloadDialog = { showRemoveDownloadDialog = true }, + snackbarHostState = snackbarHostState, + modifier = Modifier.animateItem() + ) + } + } + + if (filteredSongs.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(start = 16.dp) + .animateItem() + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSongSortType.CUSTOM -> R.string.sort_by_custom + PlaylistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSongSortType.NAME -> R.string.sort_by_name + PlaylistSongSortType.ARTIST -> R.string.sort_by_artist + PlaylistSongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + modifier = Modifier.weight(1f) + ) + + if (!inSelectMode && sortType == PlaylistSongSortType.CUSTOM) { + IconButton( + onClick = { locked = !locked }, + modifier = Modifier.padding(horizontal = 6.dp) + ) { + Icon( + painter = painterResource(if (locked) R.drawable.lock else R.drawable.lock_open), + contentDescription = null + ) + } + } + } + } + } + } + } + + itemsIndexed( + items = if (isSearching) filteredSongs else mutableSongs, + key = { _, song -> song.map.id } + ) { index, song -> + if (index == 0) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal + ) + } + } + ReorderableItem( + state = reorderableState, + key = song.map.id + ) { + val currentItem by rememberUpdatedState(song) + + fun deleteFromPlaylist() { + database.transaction { + coroutineScope.launch { + playlist?.playlist?.browseId?.let { it1 -> + val setVideoId = getSetVideoId(currentItem.map.songId) + if (setVideoId?.setVideoId != null) { + YouTube.removeFromPlaylist( + it1, currentItem.map.songId, setVideoId.setVideoId + ) + } + } + } + move(currentItem.map.playlistId, currentItem.map.position, Int.MAX_VALUE) + delete(currentItem.map.copy(position = Int.MAX_VALUE)) + } + dismissJob?.cancel() + dismissJob = coroutineScope.launch { + val snackbarResult = snackbarHostState.showSnackbar( + message = context.getString(R.string.removed_song_from_playlist, currentItem.song.song.title), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Short + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + database.transaction { + insert(currentItem.map.copy(position = songs.size)) + move(currentItem.map.playlistId, songs.size, currentItem.map.position) + } + } + } + } + + val dismissState = rememberSwipeToDismissBoxState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == SwipeToDismissBoxValue.StartToEnd || dismissValue == SwipeToDismissBoxValue.EndToStart) { + deleteFromPlaylist() + } + true + } + ) + + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(song.map.id) + } else { + selection.remove(song.map.id) + } + } + + val content: @Composable () -> Unit = { + SongListItem( + song = song.song, + isActive = song.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = song.map.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song.song, + playlistSong = song, + playlist = playlist, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + + if (sortType == PlaylistSongSortType.CUSTOM && !locked && !isSearching) { + IconButton( + onClick = { }, + modifier = Modifier.detectReorder(reorderableState) + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null + ) + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(song.map.id !in selection) + } else if (song.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist!!.playlist.name, + items = songs.map { it.song.toMediaItem() }, + startIndex = songs.indexOfFirst { it.map.id == song.map.id } + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + ) + } + + if (locked || inSelectMode) { + content() + } else { + SwipeToDismissBox( + state = dismissState, + backgroundContent = {}, + content = { content() } + ) + } + } + } + } + + TopAppBar( + title = { + if (inSelectMode) { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + } else if (isSearching) { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (query.text.isNotEmpty()) { + IconButton( + onClick = { query = TextFieldValue("") } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + } + }, + navigationIcon = { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + } else { + IconButton( + onClick = { + if (isSearching) { + isSearching = false + query = TextFieldValue() + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == filteredSongs.size, + onCheckedChange = { + if (selection.size == filteredSongs.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(filteredSongs.map { it.map.id }) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { mapId -> + songs.find { it.map.id == mapId }?.song + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode, + onRemoveFromQueue = { + val sel = selection.toList() + val sortedSongs = songs.sortedBy { it.map.position } + database.transaction { + val remainSongs = mutableListOf() + sortedSongs.forEach { song -> + if (song.map.id in sel) { + delete(song.map) + } else { + remainSongs.add(song) + } + } + remainSongs.forEachIndexed { index, song -> + if (song.map.position != index) { + update(song.map.copy(position = index)) + } + } + } + } + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painterResource(R.drawable.search), + contentDescription = null + ) + } + } + }, + scrollBehavior = scrollBehavior + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime)) + .align(Alignment.BottomCenter) + ) + } +} + +@Composable +fun LocalPlaylistHeader( + playlist: Playlist, + songs: List, + onShowEditDialog: () -> Unit, + onShowRemoveDownloadDialog: () -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val context = LocalContext.current + val database = LocalDatabase.current + val syncUtils = LocalSyncUtils.current + val scope = rememberCoroutineScope() + + val playlistLength = remember(songs) { + songs.fastSumBy { it.song.song.duration } + } + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + val editable: Boolean = playlist.playlist.isEditable + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED + || downloads[it.song.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.song.id]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.padding(12.dp) + ) { + if (playlist.thumbnails.isEmpty()) { + val libcarditem = 25.dp + Box( + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(libcarditem)) + .align(alignment = Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(110.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(Alignment.Center) + ) + } + } + if (playlist.thumbnails.size == 1) { + AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(alignment = Alignment.CenterHorizontally) + ) + } else if (playlist.thumbnails.size > 1) { + Box( + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(alignment = Alignment.CenterHorizontally) + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(AlbumThumbnailSize / 2) + ) + } + } + } + Spacer(Modifier.height(12.dp)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizeText( + text = playlist.playlist.name, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + Text( + text = makeTimeString(playlistLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + Spacer(Modifier.height(12.dp)) + Row ( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + when (downloadState) { + Download.STATE_COMPLETED -> { + Button( + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)), + onClick = onShowRemoveDownloadDialog + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + Button( + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)), + onClick = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) + } + } + + else -> { + Button( + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)), + onClick = { + songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.song.id, song.song.id.toUri()) + .setCustomCacheKey(song.song.id) + .setData(song.song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + Button( + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)), + onClick = onShowEditDialog + ) { + Icon( + painter = painterResource(R.drawable.edit), + contentDescription = null + ) + } + if (editable) { + Button( + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)), + onClick = { + playerConnection.addToQueue( + items = songs.map { it.song.toMediaItem() }, + ) + } + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + ) + } + } else { + Button( + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)), + onClick = { + database.transaction { + update(playlist.playlist.toggleLike()) + } + } + ) { + val liked = playlist.playlist.bookmarkedAt != null + Icon( + painter = painterResource(if (liked) R.drawable.favorite else R.drawable.favorite_border), + contentDescription = null + ) + } + } + + if (playlist.playlist.browseId != null && isInternetAvailable(context)) { + Button( + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)), + onClick = { + scope.launch(Dispatchers.IO) { + syncUtils.syncPlaylist(playlist.playlist.browseId, playlist.id) + snackbarHostState.showSnackbar(context.getString(R.string.playlist_synced)) + } + } + ) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null + ) + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.map { it.song.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map { it.song.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/OnlinePlaylistScreen.kt new file mode 100644 index 0000000..4fba0df --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -0,0 +1,846 @@ +package com.babelsoftware.loudly.ui.screens.playlist + +import android.content.Intent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.LocalSyncUtils +import com.babelsoftware.loudly.constants.AccountNameKey +import com.babelsoftware.loudly.constants.AlbumThumbnailSize +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.db.entities.PlaylistSongMap +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.ui.component.shimmer.ButtonPlaceholder +import com.babelsoftware.loudly.ui.component.shimmer.ListItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.component.shimmer.TextPlaceholder +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.viewmodels.OnlinePlaylistViewModel +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.IconButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun OnlinePlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: OnlinePlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() + val dbPlaylist by viewModel.dbPlaylist.collectAsState() + + val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val syncUtils = LocalSyncUtils.current + + val accountName by rememberPreference(AccountNameKey, "") + + var isSearching by rememberSaveable { mutableStateOf(false) } + var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + val filteredSongs = remember(songs, query, hideExplicit) { + songs.mapIndexed { index, song -> index to song } + .filter { (_, song) -> + (!hideExplicit || !song.explicit) && (query.text.isEmpty() || + song.title.contains(query.text, ignoreCase = true) || + song.artists.any { it.name.contains(query.text, ignoreCase = true) }) + } + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + if (isSearching) { + BackHandler { + isSearching = false + query = TextFieldValue() + } + } + + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 + } + } + + val downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist?.title!! + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + database.transaction { + dbPlaylist?.id?.let { clearPlaylist(it) } + } + + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, Int>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime).asPaddingValues() + ) { + if (filteredSongs.isEmpty() && isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + playlist.let { playlist -> + if (playlist != null) { + if (!isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + AsyncImage( + model = playlist.thumbnail, + contentDescription = null, + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(alignment = Alignment.CenterHorizontally) + ) + + Spacer(Modifier.width(12.dp)) + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizeText( + text = playlist.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + if (playlist.id != "LM") { + playlist.author?.let { artist -> + Text(buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ).toSpanStyle() + ) { + if (artist.id != null) { + val link = + LinkAnnotation.Clickable(artist.id!!) { + navController.navigate("artist/${artist.id!!}") + } + withLink(link) { + append(artist.name) + } + } else { + append(artist.name) + } + } + }) + } + } else { + Text( + text = accountName, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ) + ) + } + + Spacer(Modifier.height(12.dp)) + + Row { + if (playlist.id != "LM") { + Button( + onClick = { + if (dbPlaylist?.playlist == null) { + database.transaction { + val playlistEntity = PlaylistEntity( + name = playlist.title, + browseId = playlist.id, + thumbnailUrl = playlist.thumbnail, + isEditable = playlist.isEditable, + remoteSongCount = playlist.songCountText?.let { + Regex( + """\d+""" + ).find(it)?.value?.toIntOrNull() + }, + playEndpointParams = playlist.playEndpoint?.params, + shuffleEndpointParams = playlist.shuffleEndpoint.params, + radioEndpointParams = playlist.radioEndpoint?.params + ).toggleLike() + insert(playlistEntity) + songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistEntity.id, + position = index + ) + } + .forEach(::insert) + } + } else { + database.transaction { + update(dbPlaylist!!.playlist.toggleLike()) + } + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource( + if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border + ), + contentDescription = null + ) + } + } + + if (dbPlaylist != null) { + when (downloadState) { + Download.STATE_COMPLETED -> { + Button( + onClick = { + showRemoveDownloadDialog = true + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + Button( + onClick = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) + } + } + + else -> { + Button( + onClick = { + viewModel.viewModelScope.launch( + Dispatchers.IO + ) { + syncUtils.syncPlaylist( + playlist.id, + dbPlaylist!!.id + ) + } + + songs.forEach { song -> + val downloadRequest = + DownloadRequest.Builder( + song.id, + song.id.toUri() + ) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + } + + Button( + onClick = { + playerConnection.addToQueue( + items = songs.map { it.toMediaItem() } + ) + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null + ) + } + + if (playlist.id != "LM") { + Button( + onClick = { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + playlist.shareLink + ) + } + context.startActivity( + Intent.createChooser( + intent, + null + ) + ) + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null + ) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + playlist.radioEndpoint?.let { radioEndpoint -> + Button( + onClick = { + playerConnection.playQueue( + YouTubeQueue( + radioEndpoint + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.radio)) + } + } + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + + items( + items = filteredSongs, + key = { (index, _) -> index } + ) { (index, song) -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(index) + } else { + selection.remove(index) + } + } + if (index == 0) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal + ) + } + } + + YouTubeListItem( + item = song, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = index in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .combinedClickable( + enabled = !hideExplicit || !song.explicit, + onClick = { + if (inSelectMode) { + onCheckedChange(index !in selection) + } else if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + song.endpoint + ?: WatchEndpoint(videoId = song.id), + song.toMediaMetadata() + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + .alpha(if (hideExplicit && song.explicit) 0.3f else 1f) + .animateItem() + ) + } + } else { + item { + ShimmerHost { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } + + Spacer(Modifier.padding(8.dp)) + + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } + + TopAppBar( + title = { + if (inSelectMode) { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + } else if (isSearching) { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (query.text.isNotEmpty()) { + IconButton( + onClick = { query = TextFieldValue("") } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + } else { + if (showTopBarTitle) Text(playlist?.title.orEmpty()) + } + }, + navigationIcon = { + if (inSelectMode) { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + } else { + IconButton( + onClick = { + if (isSearching) { + isSearching = false + query = TextFieldValue() + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + }, + actions = { + if (inSelectMode) { + Checkbox( + checked = selection.size == filteredSongs.size, + onCheckedChange = { + if (selection.size == filteredSongs.size) { + selection.clear() + if (selection.size == songs.size) { + selection.clear() + } + } else { + selection.clear() + selection.addAll(filteredSongs.map { it.first }) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + YouTubeSongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songs.getOrNull(it) }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painterResource(R.drawable.search), + contentDescription = null + ) + } + } + if (!isSearching && !inSelectMode) { + IconButton( + onClick = { + menuState.show { + YouTubePlaylistMenu( + navController = navController, + playlist = playlist!!, + songs = songs, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + scrollBehavior = scrollBehavior + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime)) + .align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/TopPlaylistScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/TopPlaylistScreen.kt new file mode 100644 index 0000000..384f42d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/playlist/TopPlaylistScreen.kt @@ -0,0 +1,718 @@ +package com.babelsoftware.loudly.ui.screens.playlist + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.babelsoftware.loudly.LocalDownloadUtil +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AlbumThumbnailSize +import com.babelsoftware.loudly.constants.CONTENT_TYPE_SONG +import com.babelsoftware.loudly.constants.MyTopFilter +import com.babelsoftware.loudly.constants.ThumbnailCornerRadius +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.ExoDownloadService +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.AutoResizeText +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.FontSizeRange +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.component.SortHeader +import com.babelsoftware.loudly.ui.menu.SongMenu +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import com.babelsoftware.loudly.ui.menu.SongSelectionMenu +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.makeTimeString +import com.babelsoftware.loudly.viewmodels.TopPlaylistViewModel +import kotlin.collections.orEmpty + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun TopPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: TopPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val focusRequester = remember { FocusRequester() } + + val songs by viewModel.topSongs.collectAsState(null) + val mutableSongs = + remember { + mutableStateListOf() + } + + val likeLength = + remember(songs) { + songs?.fastSumBy { it.song.song.duration } ?: 0 + } + + var isSearching by rememberSaveable { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + val backStackEntry by navController.currentBackStackEntryAsState() + var inSelectMode by rememberSaveable { mutableStateOf(false) } + val selection = rememberSaveable( + saver = listSaver, String>( + save = { it.toList() }, + restore = { it.toMutableStateList() } + ) + ) { mutableStateListOf() } + val onExitSelectionMode = { + inSelectMode = false + selection.clear() + } + if (inSelectMode) { + BackHandler(onBack = onExitSelectionMode) + } + + + LaunchedEffect(inSelectMode) { + backStackEntry?.savedStateHandle?.set("inSelectMode", inSelectMode) + } + + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + + val snackbarHostState = remember { SnackbarHostState() } + + val sortType by viewModel.topPeriod.collectAsState() + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + mutableSongs.apply { + clear() + songs?.let { songList -> addAll(songList.map { it.song }) } + } + if (songs?.isEmpty() == true) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs?.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED } == true) + Download.STATE_COMPLETED + else if (songs?.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED + || downloads[it.song.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.song.id]?.state == Download.STATE_COMPLETED + } == true) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + stringResource(R.string.my_top) + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + val searchQueryStr = searchQuery.text.trim() + val filteredSongs = if (searchQueryStr.isEmpty()) { + songs + } else { + // DÜZELTME: Lambda parametresinin adı 'songWithCount' olarak değiştirildi ve doğru kullanıldı. + songs?.filter { songWithCount -> + // Şarkı başlığı eşleşmesi kontrolü + val titleMatch = songWithCount.song.song.title.contains(searchQueryStr, ignoreCase = true) + + // Sanatçı isimleri eşleşmesi kontrolü + // DÜZELTME: `artists` listesindeki her bir sanatçının 'name' özelliğine erişerek arama yapılıyor. + val artistsMatch = songWithCount.song.artists.joinToString { it.name }.contains(searchQueryStr, ignoreCase = true) + + titleMatch || artistsMatch + } + } + val state = rememberLazyListState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = state, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (filteredSongs.isNullOrEmpty() && isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found) + ) + } + } + if (songs != null) { + if (songs!!.isEmpty() && !isSearching) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty) + ) + } + } else { + if (!isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + val libcarditem = 25.dp + Box( + modifier = Modifier.Companion + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(libcarditem)) + .align(alignment = Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + painterResource(R.drawable.trending_up), + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = Modifier + .size(110.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .align(Alignment.Center) + ) + } + Spacer(Modifier.height(12.dp)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AutoResizeText( + text = stringResource(R.string.my_top), + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + Text( + text = makeTimeString(likeLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + when (downloadState) { + Download.STATE_COMPLETED -> { + Button( + onClick = { + showRemoveDownloadDialog = true + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + Button( + onClick = { + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) + } + } + + else -> { + Button( + onClick = { + songs!!.forEach { song -> + val downloadRequest = + DownloadRequest.Builder( + song.song.id, + song.song.id.toUri() + ) + .setCustomCacheKey(song.song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + Button( + onClick = { + playerConnection.addToQueue( + items = songs!!.map { it.song.toMediaItem() }, + ) + }, + modifier = Modifier + .weight(1f) + .padding(4.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.my_top), + items = songs!!.map { it.song.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.my_top), + items = songs!!.shuffled() + .map { it.song.toMediaItem() } + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + if (filteredSongs!!.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp) + ) { + SortHeader( + sortType = sortType, + sortDescending = false, + onSortTypeChange = { + viewModel.topPeriod.value = it + }, + onSortDescendingChange = {}, + sortTypeText = { sortType -> + when (sortType) { + MyTopFilter.ALL_TIME -> R.string.all_time + MyTopFilter.DAY -> R.string.past_24_hours + MyTopFilter.WEEK -> R.string.past_week + MyTopFilter.MONTH -> R.string.past_month + MyTopFilter.YEAR -> R.string.past_year + } + }, + modifier = Modifier.weight(1f), + showDescending = false, + ) + } + } + } + } + if (filteredSongs != null) { + itemsIndexed( + items = filteredSongs, + key = { _, song -> song.song.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, songWrapper -> + val onCheckedChange: (Boolean) -> Unit = { + if (it) { + selection.add(songWrapper.song.id) + } else { + selection.remove(songWrapper.song.id) + } + } + if (index == 0) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal + ) + } + } + SongListItem( + song = songWrapper.song, + isActive = songWrapper.song.id == mediaMetadata?.id, + showInLibraryIcon = true, + isPlaying = isPlaying, + trailingContent = { + if (inSelectMode) { + Checkbox( + checked = songWrapper.song.id in selection, + onCheckedChange = onCheckedChange + ) + } else { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (inSelectMode) { + onCheckedChange(songWrapper.song.id !in selection) + } else if (songWrapper.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.my_top), + items = songs!!.map { it.song.toMediaItem() }, + startIndex = songs!!.indexOfFirst { it.song.id == songWrapper.song.id } + ) + ) + } + }, + onLongClick = { + if (!inSelectMode) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + inSelectMode = true + onCheckedChange(true) + } + } + ) + .animateItem() + ) + } + } + } + } + } + + if (inSelectMode) { + TopAppBar( + title = { + Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) + }, + navigationIcon = { + IconButton(onClick = onExitSelectionMode) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + }, + actions = { + Checkbox( + checked = selection.size == filteredSongs?.size && selection.isNotEmpty(), + onCheckedChange = { + if (selection.size == filteredSongs?.size) { + selection.clear() + } else { + selection.clear() + selection.addAll(filteredSongs?.map { it.song.song.id }.orEmpty()) + } + } + ) + IconButton( + enabled = selection.isNotEmpty(), + onClick = { + menuState.show { + SongSelectionMenu( + navController = navController, + selection = selection.mapNotNull { songId -> + songs?.find { it.song.id == songId }?.song + }, + onDismiss = menuState::dismiss, + onExitSelectionMode = onExitSelectionMode + ) + } + } + ) { + Icon( + painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + ) + } else { + TopAppBar( + title = { + if (isSearching) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + trailingIcon = { + if (searchQuery.text.isNotEmpty()) { + IconButton( + onClick = { searchQuery = TextFieldValue("") } + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + } + ) + } + }, + navigationIcon = { + com.babelsoftware.loudly.ui.component.IconButton( + onClick = { + if (isSearching) { + isSearching = false + searchQuery = TextFieldValue() + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (!isSearching && !inSelectMode) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + }, + scrollBehavior = if (!isSearching) scrollBehavior else null + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/LocalSearchScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/LocalSearchScreen.kt new file mode 100644 index 0000000..8e868e9 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/LocalSearchScreen.kt @@ -0,0 +1,288 @@ +package com.babelsoftware.loudly.ui.screens.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.CONTENT_TYPE_LIST +import com.babelsoftware.loudly.constants.ListItemHeight +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.extensions.toMediaItem +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.playback.queues.ListQueue +import com.babelsoftware.loudly.ui.component.AlbumListItem +import com.babelsoftware.loudly.ui.component.ArtistListItem +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.PlaylistListItem +import com.babelsoftware.loudly.ui.component.SongListItem +import com.babelsoftware.loudly.ui.menu.SongMenu +import com.babelsoftware.loudly.viewmodels.LocalFilter +import com.babelsoftware.loudly.viewmodels.LocalSearchViewModel +import com.babelsoftware.loudly.R +import kotlinx.coroutines.flow.drop + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LocalSearchScreen( + query: String, + navController: NavController, + onDismiss: () -> Unit, + isFromCache: Boolean = false, + viewModel: LocalSearchViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val searchFilter by viewModel.filter.collectAsState() + val result by viewModel.result.collectAsState() + + val lazyListState = rememberLazyListState() + + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { + keyboardController?.hide() + } + } + + LaunchedEffect(query) { + viewModel.query.value = query + } + + Column { + ChipsRow( + chips = listOf( + LocalFilter.ALL to stringResource(R.string.filter_all), + LocalFilter.SONG to stringResource(R.string.filter_songs), + LocalFilter.ALBUM to stringResource(R.string.filter_albums), + LocalFilter.ARTIST to stringResource(R.string.filter_artists), + LocalFilter.PLAYLIST to stringResource(R.string.filter_playlists) + ), + currentValue = searchFilter, + onValueUpdate = { viewModel.filter.value = it }, + containerColor = MaterialTheme.colorScheme.surface + ) + + LazyColumn( + state = lazyListState, + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Bottom) + .asPaddingValues(), + modifier = Modifier.weight(1f) + ) { + result.map.forEach { (filter, items) -> + if (result.filter == LocalFilter.ALL) { + item( + key = filter + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight) + .clickable { viewModel.filter.value = filter } + .padding(start = 12.dp, end = 18.dp) + ) { + Text( + text = stringResource( + when (filter) { + LocalFilter.SONG -> R.string.filter_songs + LocalFilter.ALBUM -> R.string.filter_albums + LocalFilter.ARTIST -> R.string.filter_artists + LocalFilter.PLAYLIST -> R.string.filter_playlists + LocalFilter.ALL -> error("") + } + ), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f) + ) + + Icon( + painter = painterResource(R.drawable.navigate_next), + contentDescription = null + ) + } + } + } + + items( + items = items, + key = { it.id }, + contentType = { CONTENT_TYPE_LIST } + ) { item -> + when (item) { + is Song -> SongListItem( + song = item, + isActive = item.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = item, + navController = navController, + onDismiss = { + onDismiss() + menuState.dismiss() + }, + isFromCache = isFromCache + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .combinedClickable( + onClick = { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + val songs = result.map + .getOrDefault(LocalFilter.SONG, emptyList()) + .filterIsInstance() + .map { it.toMediaItem() } + playerConnection.playQueue( + ListQueue( + title = "${context.getString(R.string.queue_searched_songs)}: $query", + items = songs, + startIndex = songs.indexOfFirst { it.mediaId == item.id } + )) + } + }, + onLongClick = { + menuState.show { + SongMenu( + originalSong = item, + navController = navController, + onDismiss = { + onDismiss() + menuState.dismiss() + }, + isFromCache = isFromCache + ) + } + } + ) + .animateItem() + ) + + is Album -> AlbumListItem( + album = item, + isActive = item.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("album/${item.id}") + } + .animateItem() + ) + + is Artist -> ArtistListItem( + artist = item, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("artist/${item.id}") + } + .animateItem() + ) + + is Playlist -> PlaylistListItem( + playlist = item, + thumbnail = Icons.AutoMirrored.Rounded.QueueMusic, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("local_playlist/${item.id}") + } + .animateItem() + ) + } + } + } + + if (result.query.isNotEmpty() && result.map.isEmpty()) { + item( + key = "no_result" + ) { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found), + modifier = Modifier.animateItem() + ) + } + } + } + } + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchResult.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchResult.kt new file mode 100644 index 0000000..2c34c20 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchResult.kt @@ -0,0 +1,291 @@ +package com.babelsoftware.loudly.ui.screens.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.AppBarHeight +import com.babelsoftware.loudly.constants.SearchFilterHeight +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.ChipsRow +import com.babelsoftware.loudly.ui.component.EmptyPlaceholder +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.NavigationTitle +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.ui.component.shimmer.ListItemPlaceHolder +import com.babelsoftware.loudly.ui.component.shimmer.ShimmerHost +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.menu.YouTubeArtistMenu +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM +import com.babelsoftware.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST +import com.babelsoftware.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST +import com.babelsoftware.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST +import com.babelsoftware.innertube.YouTube.SearchFilter.Companion.FILTER_SONG +import com.babelsoftware.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.innertube.models.YTItem +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.viewmodels.OnlineSearchViewModel +import kotlinx.coroutines.launch +import kotlin.collections.orEmpty + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OnlineSearchResult( + navController: NavController, + viewModel: OnlineSearchViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val haptic = LocalHapticFeedback.current + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + + val snackbarHostState = remember { SnackbarHostState() } + + val searchFilter by viewModel.filter.collectAsState() + val searchSummary = viewModel.summaryPage + val itemsPage by remember(searchFilter) { + derivedStateOf { + searchFilter?.value?.let { + viewModel.viewStateMap[it] + } + } + } + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + val ytItemContent: @Composable LazyItemScope.(YTItem) -> Unit = { item: YTItem -> + val longClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss + ) + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss + ) + is PlaylistItem -> YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + YouTubeListItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = longClick + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ) + ) + } + } + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = longClick + ) + .animateItem() + ) + } + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .add(WindowInsets(top = SearchFilterHeight)) + .asPaddingValues() + ) { + if (searchFilter == null) { + searchSummary?.summaries?.forEach { summary -> + item { + NavigationTitle(summary.title) + } + + items( + items = summary.items, + key = { "${summary.title}/${it.id}" }, + itemContent = ytItemContent + ) + } + + if (searchSummary?.summaries?.isEmpty() == true) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found), + modifier = Modifier.animateItem() + ) + } + } + } else { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id }, + itemContent = ytItemContent + ) + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + + if (itemsPage?.items?.isEmpty() == true) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found), + modifier = Modifier.animateItem() + ) + } + } + } + + if (searchFilter == null && searchSummary == null || searchFilter != null && itemsPage == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } + + ChipsRow( + chips = listOf( + null to stringResource(R.string.filter_all), + FILTER_SONG to stringResource(R.string.filter_songs), + FILTER_VIDEO to stringResource(R.string.filter_videos), + FILTER_ALBUM to stringResource(R.string.filter_albums), + FILTER_ARTIST to stringResource(R.string.filter_artists), + FILTER_COMMUNITY_PLAYLIST to stringResource(R.string.filter_community_playlists), + FILTER_FEATURED_PLAYLIST to stringResource(R.string.filter_featured_playlists) + ), + currentValue = searchFilter, + onValueUpdate = { + if (viewModel.filter.value != it) { + viewModel.filter.value = it + } + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + }, + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + .padding(top = AppBarHeight) + .padding(bottom = 8.dp) // Added padding for better spacing + .background(MaterialTheme.colorScheme.surface) + .fillMaxWidth() + ) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchScreen.kt new file mode 100644 index 0000000..32d0010 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/search/OnlineSearchScreen.kt @@ -0,0 +1,404 @@ +package com.babelsoftware.loudly.ui.screens.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.constants.SuggestionItemHeight +import com.babelsoftware.loudly.extensions.togglePlayPause +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.queues.YouTubeQueue +import com.babelsoftware.loudly.ui.component.LocalMenuState +import com.babelsoftware.loudly.ui.component.SearchBarIconOffsetX +import com.babelsoftware.loudly.ui.component.YouTubeListItem +import com.babelsoftware.loudly.ui.menu.YouTubeAlbumMenu +import com.babelsoftware.loudly.ui.menu.YouTubeArtistMenu +import com.babelsoftware.loudly.ui.menu.YouTubePlaylistMenu +import com.babelsoftware.loudly.ui.menu.YouTubeSongMenu +import com.babelsoftware.loudly.viewmodels.OnlineSearchSuggestionViewModel +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.loudly.R +import kotlinx.coroutines.flow.drop + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OnlineSearchScreen( + query: String, + onQueryChange: (TextFieldValue) -> Unit, + navController: NavController, + onSearch: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: OnlineSearchSuggestionViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val keyboardController = LocalSoftwareKeyboardController.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + + val scope = rememberCoroutineScope() + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val coroutineScope = rememberCoroutineScope() + val viewState by viewModel.viewState.collectAsState() + + val lazyListState = rememberLazyListState() + + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { + keyboardController?.hide() + } + } + + LaunchedEffect(query) { + viewModel.query.value = query + } + + LazyColumn( + state = lazyListState, + contentPadding = + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom) + .asPaddingValues(), + ) { + if (viewState.history.isNotEmpty()) { + item { + Text( + text = (stringResource(R.string.SearchHistory)), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + items( + items = viewState.history, + key = { it.query }, + ) { history -> + SuggestionItem( + query = history.query, + online = false, + onClick = { + onSearch(history.query) + onDismiss() + }, + onDelete = { + database.query { + delete(history) + } + }, + onFillTextField = { + onQueryChange( + TextFieldValue( + text = history.query, + selection = TextRange(history.query.length), + ), + ) + }, + modifier = Modifier.Companion.animateItem(fadeInSpec = null, fadeOutSpec = null), + ) + } + if (viewState.suggestions.isNotEmpty()) { + item { + Text( + text = (stringResource(R.string.Sujestions)), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + + items( + items = viewState.suggestions, + key = { it }, + ) { query -> + SuggestionItem( + query = query, + online = true, + onClick = { + onSearch(query) + onDismiss() + }, + onFillTextField = { + onQueryChange( + TextFieldValue( + text = query, + selection = TextRange(query.length), + ), + ) + }, + modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), + ) + } + + if (viewState.items.isNotEmpty()) { + item { + Text( + text = (stringResource(R.string.SearchResutls)), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + items( + items = viewState.items, + key = { it.id } + ) { item -> + YouTubeListItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + when (item) { + is SongItem -> + YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is AlbumItem -> + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is ArtistItem -> + YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + + is PlaylistItem -> + YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = scope, + onDismiss = menuState::dismiss, + ) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ), + ) + onDismiss() + } + } + + is AlbumItem -> { + navController.navigate("album/${item.id}") + onDismiss() + } + + is ArtistItem -> { + navController.navigate("artist/${item.id}") + onDismiss() + } + + is PlaylistItem -> { + navController.navigate("online_playlist/${item.id}") + onDismiss() + } + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> + YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is AlbumItem -> + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is ArtistItem -> + YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + + is PlaylistItem -> + YouTubePlaylistMenu( + navController = navController, + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + } + }, + ) + .animateItem() + ) + } + } + Box( + modifier = Modifier.fillMaxSize() + ) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) + } +} + +@Composable +fun SuggestionItem( + modifier: Modifier = Modifier, + query: String, + online: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit = {}, + onFillTextField: () -> Unit, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + shape = MaterialTheme.shapes.medium + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(SuggestionItemHeight) + .clickable(onClick = onClick) + .padding(end = SearchBarIconOffsetX) + ) { + Icon( + painterResource(if (online) R.drawable.search else R.drawable.history), + contentDescription = null, + modifier = Modifier + .padding(horizontal = 16.dp) + .alpha(0.5f) + ) + + Text( + text = query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + if (!online) { + IconButton( + onClick = onDelete, + modifier = Modifier.alpha(0.5f) + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null + ) + } + } + + IconButton( + onClick = onFillTextField, + modifier = Modifier.alpha(0.5f) + ) { + Icon( + painter = painterResource(R.drawable.arrow_top_left), + contentDescription = null + ) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AboutScreen.kt new file mode 100644 index 0000000..ce78d47 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AboutScreen.kt @@ -0,0 +1,367 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.InstallMobile +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.BuildConfig +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.component.ErrorDetailDialog +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.viewmodel.AppUpdateViewModel +import com.babelsoftware.loudly.ui.screens.settings.viewmodel.UpdateState +import com.babelsoftware.loudly.utils.ReleaseInfo +import com.babelsoftware.loudly.utils.getLatestReleaseInfo +import com.babelsoftware.loudly.utils.isNewerVersion + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior +) { + val uriHandler = LocalUriHandler.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.about)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(painterResource(R.drawable.arrow_back), null) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + item { + UpdateCard() + } + + item { + SettingsBox( + title = stringResource(id = R.string.app_version), + description = BuildConfig.VERSION_NAME, + icon = IconResource.Drawable(painterResource(id = R.drawable.tag)), + actionType = ActionType.TEXT, + onClick = {} + ) + } + item { + SettingsBox( + title = stringResource(R.string.app_type), + description = BuildConfig.FLAVOR.uppercase(), + icon = IconResource.Vector(Icons.Default.Build), + actionType = ActionType.TEXT, + onClick = {} + ) + } + item { + SettingsBox( + title = stringResource(R.string.Developer), + description = "Babel Software founder & Android developer", + icon = IconResource.Drawable(painterResource(id = R.drawable.person)), + onClick = { uriHandler.openUri("https://github.com/RRechz") } + ) + } + item { + SettingsBox( + title = stringResource(R.string.code), + description = stringResource(R.string.code_text), + icon = IconResource.Drawable(painterResource(id = R.drawable.codigo)), + onClick = { uriHandler.openUri("https://github.com/RRechz/Loudly") } + ) + } + item { + SettingsBox( + title = stringResource(R.string.bugs), + description = stringResource(R.string.bugs_text), + icon = IconResource.Drawable(painterResource(id = R.drawable.bug_report)), + onClick = { uriHandler.openUri("t.me/by_BabelSoftware") } + ) + } + } + } +} + +@Composable +fun UpdateCard( + updateViewModel: AppUpdateViewModel = hiltViewModel() +) { + val context = LocalContext.current + val updateState by updateViewModel.updateState.collectAsState() + + var latestReleaseInfo by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var updateAvailable by remember { mutableStateOf(false) } + var errorToShow by remember { mutableStateOf(null) } + + errorToShow?.let { error -> + ErrorDetailDialog( + errorTitle = stringResource(R.string.update_error_details), + errorMessage = error, + onDismiss = { errorToShow = null } + ) + } + + LaunchedEffect(key1 = true) { + isLoading = true + val info = getLatestReleaseInfo() + latestReleaseInfo = info + if (info != null) { + updateAvailable = isNewerVersion(info.tagName, BuildConfig.VERSION_NAME) + } + isLoading = false + } + + val installPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { + if (context.canInstallUnknownApps()) { + (updateState as? UpdateState.ReadyToInstall)?.let { + installApk(context, it.apkUri) + } + } else { + Toast.makeText(context, "Please authorize the installation.", Toast.LENGTH_LONG).show() + } + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(24.dp), + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.padding(vertical = 8.dp)) + Text(text = stringResource(R.string.checking_for_update), style = MaterialTheme.typography.bodyMedium) + } else if (updateAvailable && latestReleaseInfo != null) { + UpdateAvailableContent( + info = latestReleaseInfo!!, + state = updateState, + updateViewModel = updateViewModel, + context = context, + installPermissionLauncher = installPermissionLauncher, + onShowError = { error -> + errorToShow = error + } + ) + } else { + UpToDateContent() + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun UpdateAvailableContent( + info: ReleaseInfo, + state: UpdateState, + updateViewModel: AppUpdateViewModel, + context: Context, + installPermissionLauncher: androidx.activity.result.ActivityResultLauncher, + onShowError: (String) -> Unit +) { + val uriHandler = LocalUriHandler.current + + Icon( + imageVector = if (state is UpdateState.Failed) Icons.Filled.Error else Icons.Filled.CloudDownload, + contentDescription = stringResource(R.string.new_version_available), + modifier = Modifier.size(48.dp), + tint = if (state is UpdateState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + + Text( + text = if (state is UpdateState.Failed) stringResource(R.string.download_failed) else stringResource(R.string.new_version_available), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.update_card_version_ready_to_download, info.tagName), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + when (state) { + is UpdateState.Idle -> { + Button(onClick = { info.apkDownloadUrl?.let { updateViewModel.downloadAndInstallApk(it) } }) { + Icon(Icons.Filled.Download, contentDescription = null) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.download_now)) + } + } + is UpdateState.Downloading -> { + Text(stringResource(R.string.update_card_downloading), style = MaterialTheme.typography.bodyMedium) + LinearProgressIndicator(progress = { state.progress / 100f }, modifier = Modifier.fillMaxWidth()) + } + is UpdateState.ReadyToInstall -> { + Button(onClick = { + if (context.canInstallUnknownApps()) { + installApk(context, state.apkUri) + } else { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData(Uri.parse("package:${context.packageName}")) + installPermissionLauncher.launch(intent) + } + }) { + Icon(Icons.Filled.InstallMobile, contentDescription = null) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.install)) + } + } + is UpdateState.Failed -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + AssistChip( + onClick = { onShowError(state.error) }, + label = { + Text( + text = stringResource(R.string.show_error), + maxLines = 1, + softWrap = false, + modifier = Modifier.basicMarquee() + ) + }, + leadingIcon = { Icon(Icons.Filled.Error, null, modifier = Modifier.size(18.dp)) } + ) + AssistChip( + onClick = { info.apkDownloadUrl?.let { updateViewModel.downloadAndInstallApk(it) } }, + label = { + Text( + text = stringResource(R.string.try_again), + maxLines = 1, + softWrap = false, + modifier = Modifier.basicMarquee() + ) + }, + leadingIcon = { Icon(Icons.Filled.Refresh, null, modifier = Modifier.size(18.dp)) } + ) + AssistChip( + onClick = { uriHandler.openUri("https://github.com/RRechz/Loudly/releases/latest") }, + label = { + Text( + text = stringResource(R.string.go_to_update_link), + maxLines = 1, + softWrap = false, + modifier = Modifier.basicMarquee() + ) + }, + leadingIcon = { Icon(Icons.Filled.Link, null, modifier = Modifier.size(18.dp)) } + ) + } + } + } +} + +@Composable +private fun UpToDateContent() { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = "App up to date", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Text( + text = stringResource(R.string.app_up_to_date), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) +} + +// Helper fun +private fun installApk(context: Context, uri: Uri) { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(intent) +} + +private fun Context.canInstallUnknownApps(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + packageManager.canRequestPackageInstalls() + } else { + true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AccountScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AccountScreen.kt new file mode 100644 index 0000000..a049cf2 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AccountScreen.kt @@ -0,0 +1,192 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AccountChannelHandleKey +import com.babelsoftware.loudly.constants.AccountEmailKey +import com.babelsoftware.loudly.constants.AccountNameKey +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.UseLoginForBrowse +import com.babelsoftware.loudly.constants.YtmSyncKey +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.InfoLabel +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val accountName by rememberPreference(AccountNameKey, "") + val accountEmail by rememberPreference(AccountEmailKey, "") + val accountChannelHandle by rememberPreference(AccountChannelHandleKey, "") + val (innerTubeCookie, onInnerTubeCookieChange) = rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } + val (ytmSync, onYtmSyncChange) = rememberPreference(YtmSyncKey, defaultValue = true) + val (useLoginForBrowse, onUseLoginForBrowseChange) = rememberPreference(key = UseLoginForBrowse, defaultValue = false) + + var showTokenEditor by remember { mutableStateOf(false) } + + if (showTokenEditor) { + TextFieldDialog( + modifier = Modifier, + initialTextFieldValue = TextFieldValue(innerTubeCookie), + onDone = { onInnerTubeCookieChange(it) }, + onDismiss = { showTokenEditor = false }, + singleLine = false, + maxLines = 20, + isInputValid = { + it.isNotEmpty() && + try { + "SAPISID" in parseCookieString(it) + true + } catch (e: Exception) { + false + } + }, + extraContent = { + InfoLabel(text = stringResource(R.string.token_adv_login_description)) + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.account)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(16.dp)) + + // --- AACCOUNT GROUP --- + SettingCategory(title = stringResource(R.string.account)) + val accountDescription = if (isLoggedIn) { + accountEmail.takeIf { it.isNotEmpty() } ?: accountChannelHandle.takeIf { it.isNotEmpty() } + } else { + stringResource(R.string.login_description) + } + + SettingsBox( + title = if (isLoggedIn) accountName else stringResource(R.string.login), + description = accountDescription, + icon = IconResource.Drawable(painterResource(R.drawable.person)), + shape = if (isLoggedIn) shapeManager(isFirst = true) else shapeManager(isBoth = true), + onClick = { if (!isLoggedIn) navController.navigate("login") } + ) + + if (isLoggedIn) { + SettingsBox( + title = stringResource(R.string.token_shown), + description = stringResource(R.string.token_adv_login_description), + icon = IconResource.Drawable(painterResource(R.drawable.token)), + shape = shapeManager(), + onClick = { showTokenEditor = true } + ) + SettingsBox( + title = stringResource(R.string.ytm_sync), + description = stringResource(R.string.ytm_sync_description), + icon = IconResource.Drawable(painterResource(R.drawable.cached)), + actionType = ActionType.SWITCH, + isChecked = ytmSync, + onCheckedChange = onYtmSyncChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.logout), + icon = IconResource.Drawable(painterResource(R.drawable.logout)), + shape = shapeManager(isLast = true), + onClick = { onInnerTubeCookieChange("") } + ) + } + + // --- INTEGRATİONS GROUP --- + SettingCategory(title = stringResource(R.string.integrations)) + SettingsBox( + title = stringResource(R.string.import_from_spotify), + icon = IconResource.Drawable(painterResource(R.drawable.spotify)), + shape = shapeManager(isFirst = true), + onClick = { navController.navigate("settings/import_from_spotify/ImportFromSpotify") } + ) + SettingsBox( + title = stringResource(R.string.discord_integration), + icon = IconResource.Drawable(painterResource(R.drawable.discord)), + shape = shapeManager(isLast = true), + onClick = { navController.navigate("settings/discord") } + ) + + // --- MISC GROUP --- + SettingCategory(title = stringResource(R.string.misc)) + SettingsBox( + title = stringResource(R.string.use_login_for_browse), + description = stringResource(R.string.use_login_for_browse_desc), + icon = IconResource.Drawable(painterResource(R.drawable.person)), + actionType = ActionType.SWITCH, + isChecked = useLoginForBrowse, + onCheckedChange = { + YouTube.useLoginForBrowse = it + onUseLoginForBrowseChange(it) + }, + shape = shapeManager(isBoth = true) + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AppearanceSettings.kt new file mode 100644 index 0000000..9bfcb74 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/AppearanceSettings.kt @@ -0,0 +1,1831 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import android.annotation.SuppressLint +import android.content.Intent +import android.graphics.RenderEffect +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.TrendingUp +import androidx.compose.material.icons.rounded.Cached +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.DesignServices +import androidx.compose.material.icons.rounded.East +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.FavoriteBorder +import androidx.compose.material.icons.rounded.FilterCenterFocus +import androidx.compose.material.icons.rounded.FilterList +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.North +import androidx.compose.material.icons.rounded.NorthEast +import androidx.compose.material.icons.rounded.NorthWest +import androidx.compose.material.icons.rounded.QueryStats +import androidx.compose.material.icons.rounded.South +import androidx.compose.material.icons.rounded.SouthEast +import androidx.compose.material.icons.rounded.SouthWest +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.West +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import androidx.navigation.NavController +import coil.compose.rememberAsyncImagePainter +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AccountNameKey +import com.babelsoftware.loudly.constants.AppDesignVariantKey +import com.babelsoftware.loudly.constants.AppDesignVariantType +import com.babelsoftware.loudly.constants.AutoPlaylistCachedPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistDownloadShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistLikedShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistLocalPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistTopPlaylistShowKey +import com.babelsoftware.loudly.constants.AutoPlaylistsCustomizationKey +import com.babelsoftware.loudly.constants.ChipSortTypeKey +import com.babelsoftware.loudly.constants.DarkModeKey +import com.babelsoftware.loudly.constants.DefaultOpenTabKey +import com.babelsoftware.loudly.constants.DefaultOpenTabOldKey +import com.babelsoftware.loudly.constants.DynamicThemeKey +import com.babelsoftware.loudly.constants.GridCellSize +import com.babelsoftware.loudly.constants.GridCellSizeKey +import com.babelsoftware.loudly.constants.HeaderCardAlignmentKey +import com.babelsoftware.loudly.constants.HeaderCardCornerRadiusKey +import com.babelsoftware.loudly.constants.HeaderCardGradientIntensityKey +import com.babelsoftware.loudly.constants.HeaderCardProfilePictureUriKey +import com.babelsoftware.loudly.constants.HeaderImageKey +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.LibraryFilter +import com.babelsoftware.loudly.constants.MiniPlayerAction +import com.babelsoftware.loudly.constants.MiniPlayerActionButtonKey +import com.babelsoftware.loudly.constants.PlayerBackgroundStyle +import com.babelsoftware.loudly.constants.PlayerBackgroundStyleKey +import com.babelsoftware.loudly.constants.PlayerStyle +import com.babelsoftware.loudly.constants.PlayerStyleKey +import com.babelsoftware.loudly.constants.PureBlackKey +import com.babelsoftware.loudly.constants.ShowContentFilterKey +import com.babelsoftware.loudly.constants.ShowHeaderCardTextKey +import com.babelsoftware.loudly.constants.ShowRecentActivityKey +import com.babelsoftware.loudly.constants.SliderStyle +import com.babelsoftware.loudly.constants.SliderStyleKey +import com.babelsoftware.loudly.constants.SlimNavBarKey +import com.babelsoftware.loudly.constants.SwipeSongToDismissKey +import com.babelsoftware.loudly.constants.SwipeThumbnailKey +import com.babelsoftware.loudly.constants.ThumbnailCornerRadiusV2Key +import com.babelsoftware.loudly.constants.UiPresetKey +import com.babelsoftware.loudly.ui.component.CounterDialog +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.EnumListPreference +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.ListPreference +import com.babelsoftware.loudly.ui.component.PlayerSliderTrack +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import com.canhub.cropper.CropImageContract +import com.canhub.cropper.CropImageContractOptions +import com.canhub.cropper.CropImageOptions +import com.canhub.cropper.CropImageView +import kotlinx.coroutines.launch +import me.saket.squiggles.SquigglySlider +import kotlin.math.absoluteValue + +@SuppressLint("DefaultLocale") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppearanceSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (appDesignVariant, onAppDesignVariantChange) = rememberEnumPreference( + AppDesignVariantKey, + defaultValue = AppDesignVariantType.NEW + ) + val (dynamicTheme, onDynamicThemeChange) = rememberPreference( + DynamicThemeKey, + defaultValue = true + ) + val (darkMode, onDarkModeChange) = rememberEnumPreference( + DarkModeKey, + defaultValue = DarkMode.AUTO + ) + val (pureBlack, onPureBlackChange) = rememberPreference(PureBlackKey, defaultValue = false) + val (autoPlaylistsCustomization, onAutoPlaylistsCustomizationChange) = rememberPreference( + AutoPlaylistsCustomizationKey, defaultValue = false + ) + val (autoPlaylistLiked, onAutoPlaylistLikedChange) = rememberPreference( + AutoPlaylistLikedShowKey, + defaultValue = true + ) + val (autoPlaylistDownload, onAutoPlaylistDownloadChange) = rememberPreference( + AutoPlaylistDownloadShowKey, defaultValue = true + ) + val (autoPlaylistTopPlaylist, onAutoPlaylistTopPlaylistChange) = rememberPreference( + AutoPlaylistTopPlaylistShowKey, defaultValue = true + ) + val (autoPlaylistCached, onAutoPlaylistCachedChange) = rememberPreference( + AutoPlaylistCachedPlaylistShowKey, defaultValue = true + ) + val (autoPlaylistLocal, onAutoPlaylistLocalChange) = rememberPreference( + AutoPlaylistLocalPlaylistShowKey, defaultValue = true + ) + val (swipeSongToDismiss, onSwipeSongToDismissChange) = rememberPreference( + SwipeSongToDismissKey, + defaultValue = true + ) + val (sliderStyle, onSliderStyleChange) = rememberEnumPreference( + SliderStyleKey, + defaultValue = SliderStyle.COMPOSE + ) + val (defaultOpenTabOld, onDefaultOpenTabOldChange) = rememberEnumPreference( + DefaultOpenTabOldKey, + defaultValue = NavigationTabOld.HOME + ) + val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference( + DefaultOpenTabKey, + defaultValue = NavigationTab.HOME + ) + val (gridCellSize, onGridCellSizeChange) = rememberEnumPreference( + GridCellSizeKey, + defaultValue = GridCellSize.SMALL + ) + val (defaultChip, onDefaultChipChange) = rememberEnumPreference( + key = ChipSortTypeKey, + defaultValue = LibraryFilter.LIBRARY + ) + val (swipeThumbnail, onSwipeThumbnailChange) = rememberPreference( + SwipeThumbnailKey, + defaultValue = true + ) + val (slimNav, onSlimNavChange) = rememberPreference(SlimNavBarKey, defaultValue = true) + val (thumbnailCornerRadius, onThumbnailCornerRadius) = rememberPreference( + ThumbnailCornerRadiusV2Key, + defaultValue = 6 + ) + val (showContentFilter, onShowContentFilterChange) = rememberPreference( + ShowContentFilterKey, + defaultValue = true + ) + val (showRecentActivity,onShowRecentActivityChange) = rememberPreference( + ShowRecentActivityKey, + defaultValue = true + ) + val (playerStyle, onPlayerStyle) = rememberEnumPreference( + PlayerStyleKey, + defaultValue = PlayerStyle.NEW + ) + val (showHeaderCardText, onShowHeaderCardTextChange) = rememberPreference( + ShowHeaderCardTextKey, defaultValue = true + ) + val (headerCardAlignment, onHeaderCardAlignmentChange) = rememberEnumPreference( + HeaderCardAlignmentKey, + defaultValue = HeaderCardContentAlignment.BottomStart + ) + var showAlignmentDialog by remember { mutableStateOf(false) } + val (gradientIntensity, onGradientIntensityChange) = rememberPreference( + HeaderCardGradientIntensityKey, + defaultValue = 0.6f + ) + val (headerCardCornerRadius, onHeaderCardCornerRadiusChange) = rememberPreference( + HeaderCardCornerRadiusKey, + defaultValue = 24 + ) + var showHeaderCardCornerDialog by remember { mutableStateOf(false) } + val (profilePictureUri, onProfilePictureUriChange) = rememberPreference( + HeaderCardProfilePictureUriKey, + defaultValue = "" + ) + var showProfilePictureDialog by remember { mutableStateOf(false) } + var showGradientDialog by remember { mutableStateOf(false) } + + val (miniPlayerAction, onMiniPlayerActionChange) = rememberEnumPreference( + MiniPlayerActionButtonKey, + defaultValue = MiniPlayerAction.Like + ) + + val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = remember(darkMode, isSystemInDarkTheme) { if (darkMode == DarkMode.AUTO) isSystemInDarkTheme else darkMode == DarkMode.ON } + val (playerBackground, onPlayerBackgroundChange) = rememberEnumPreference( PlayerBackgroundStyleKey, defaultValue = PlayerBackgroundStyle.DEFAULT,) + var showCornerRadiusDialog by remember { mutableStateOf(false) } + + val (headerImage, onHeaderImageChange) = rememberPreference(HeaderImageKey, "Loudly-1") + var showHeaderImageDialog by remember { mutableStateOf(false) } + + var tempImageUri by remember { mutableStateOf(null) } + + var showMiscSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + + var showDarkModeDialog by remember { mutableStateOf(false) } + var showPlayerBackgroundDialog by remember { mutableStateOf(false) } + var showAppDesignDialog by remember { mutableStateOf(false) } + var showPlayerStyleDialog by remember { mutableStateOf(false) } + + var showMiniPlayerActionDialog by remember { mutableStateOf(false) } + + val cropActivityTitle = stringResource(id = R.string.edit_photo) + val cropButtonTitle = stringResource(id = R.string.save) + val toolbarColor = MaterialTheme.colorScheme.surface + val toolbarWidgetColor = MaterialTheme.colorScheme.onSurface + + val imageCropper = rememberLauncherForActivityResult(CropImageContract()) { result -> + if (result.isSuccessful) { + tempImageUri = result.uriContent + } + } + + val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + uri?.let { + val cropOptions = CropImageContractOptions( + uri = it, + cropImageOptions = CropImageOptions( + aspectRatioX = 16, + aspectRatioY = 9, + fixAspectRatio = true, + guidelines = CropImageView.Guidelines.ON, + outputCompressQuality = 70, + activityTitle = cropActivityTitle, + toolbarColor = toolbarColor.toArgb(), + toolbarBackButtonColor = toolbarWidgetColor.toArgb(), + activityMenuIconColor = toolbarWidgetColor.toArgb(), + borderLineColor = toolbarWidgetColor.toArgb(), + cropMenuCropButtonTitle = cropButtonTitle + ) + ) + imageCropper.launch(cropOptions) + } + } + + val contentResolver = LocalContext.current.contentResolver + val profilePicturePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + uri?.let { + contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + onProfilePictureUriChange(it.toString()) + } + } + ) + + if (showHeaderImageDialog) { + HeaderImagePickerDialog( + initialValue = headerImage, + tempSelectedUri = tempImageUri, + onDismiss = { + showHeaderImageDialog = false + tempImageUri = null + }, + onSave = { selectedValue -> + onHeaderImageChange(selectedValue) + showHeaderImageDialog = false + tempImageUri = null + }, + onGalleryClick = { + imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + ) + } + + if (showAppDesignDialog) { + AlertDialog( + onDismissRequest = { showAppDesignDialog = false }, + title = { Text(stringResource(R.string.app_design_variant)) }, + text = { + Column { + val options = mapOf( + AppDesignVariantType.NEW to stringResource(R.string.app_design_ui_1_5), + AppDesignVariantType.OLD to stringResource(R.string.app_design_ui_1_0) + ) + options.forEach { (option, description) -> + Row( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable { + onAppDesignVariantChange(option) + showAppDesignDialog = false + }.padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = (appDesignVariant == option), onClick = { + onAppDesignVariantChange(option) + showAppDesignDialog = false + }) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = description) + } + } + } + }, + confirmButton = { TextButton(onClick = { showAppDesignDialog = false }) { Text(stringResource(R.string.cancel)) } } + ) + } + + if (showPlayerStyleDialog) { + AlertDialog( + onDismissRequest = { showPlayerStyleDialog = false }, + title = { Text(stringResource(R.string.player_style)) }, + text = { + Column { + val options = mapOf( + PlayerStyle.OLD to stringResource(R.string.player_style_old_ui), + PlayerStyle.NEW to stringResource(R.string.player_style_ui_1_0), + PlayerStyle.UI_2_0 to stringResource(R.string.player_style_ui_2_0) + ) + options.forEach { (option, description) -> + Row( + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)).clickable { + onPlayerStyle(option) + showPlayerStyleDialog = false + }.padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = (playerStyle == option), onClick = { + onPlayerStyle(option) + showPlayerStyleDialog = false + }) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = description) + } + } + } + }, + confirmButton = { TextButton(onClick = { showPlayerStyleDialog = false }) { Text(stringResource(R.string.cancel)) } } + ) + } + + if (showPlayerBackgroundDialog) { + AlertDialog( + onDismissRequest = { showPlayerBackgroundDialog = false }, + title = { Text(stringResource(R.string.player_background_style)) }, + text = { + Column { + val options = listOf( + PlayerBackgroundStyle.DEFAULT, + PlayerBackgroundStyle.GRADIENT, + PlayerBackgroundStyle.BLURMOV, + PlayerBackgroundStyle.BLUR + ) + options.forEach { option -> + val description = when (option) { + PlayerBackgroundStyle.DEFAULT -> stringResource(R.string.follow_theme) + PlayerBackgroundStyle.GRADIENT -> stringResource(R.string.gradient) + PlayerBackgroundStyle.BLURMOV -> stringResource(R.string.blurmv) + PlayerBackgroundStyle.BLUR -> stringResource(R.string.blur) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onPlayerBackgroundChange(option) + showPlayerBackgroundDialog = false + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (playerBackground == option), + onClick = { + onPlayerBackgroundChange(option) + showPlayerBackgroundDialog = false + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = description) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showPlayerBackgroundDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + if (showDarkModeDialog) { + AlertDialog( + onDismissRequest = { showDarkModeDialog = false }, + title = { Text(stringResource(R.string.dark_theme)) }, + text = { + Column { + val options = listOf(DarkMode.ON, DarkMode.OFF, DarkMode.AUTO) + options.forEach { option -> + val description = when (option) { + DarkMode.ON -> stringResource(R.string.dark_theme_on) + DarkMode.OFF -> stringResource(R.string.dark_theme_off) + DarkMode.AUTO -> stringResource(R.string.dark_theme_follow_system) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDarkModeChange(option) + showDarkModeDialog = false + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (darkMode == option), + onClick = { + onDarkModeChange(option) + showDarkModeDialog = false + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = description) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showDarkModeDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } + + if (showMiniPlayerActionDialog) { + MiniPlayerActionDialog( + initialAction = miniPlayerAction, + onDismiss = { showMiniPlayerActionDialog = false }, + onSave = { selectedAction -> + onMiniPlayerActionChange(selectedAction) + showMiniPlayerActionDialog = false + } + ) + } + + if (showMiscSheet) { + ModalBottomSheet(onDismissRequest = { showMiscSheet = false }, sheetState = sheetState) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + Text(text = stringResource(R.string.misc), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 16.dp)) + + SettingsBox( + title = stringResource(R.string.auto_playlists_customization), + icon = IconResource.Drawable(painterResource(R.drawable.playlist_play)), + shape = shapeManager(isFirst = true, isLast = !autoPlaylistsCustomization), + actionType = ActionType.SWITCH, + isChecked = autoPlaylistsCustomization, + onCheckedChange = onAutoPlaylistsCustomizationChange, + onClick = { onAutoPlaylistsCustomizationChange(!autoPlaylistsCustomization) } + ) + + AnimatedVisibility(autoPlaylistsCustomization) { + Column { + SettingsBox( + title = stringResource(R.string.show_liked_auto_playlist), + icon = IconResource.Vector(Icons.Rounded.Favorite), + shape = shapeManager(), + actionType = ActionType.SWITCH, + isChecked = autoPlaylistLiked, + onCheckedChange = onAutoPlaylistLikedChange, + onClick = { onAutoPlaylistLikedChange(!autoPlaylistLiked) } + ) + SettingsBox( + title = stringResource(R.string.show_download_auto_playlist), + icon = IconResource.Vector(Icons.Rounded.CloudDownload), + shape = shapeManager(), + actionType = ActionType.SWITCH, + isChecked = autoPlaylistDownload, + onCheckedChange = onAutoPlaylistDownloadChange, + onClick = { onAutoPlaylistDownloadChange(!autoPlaylistDownload) } + ) + SettingsBox( + title = stringResource(R.string.show_top_auto_playlist), + icon = IconResource.Vector(Icons.AutoMirrored.Rounded.TrendingUp), + shape = shapeManager(), + actionType = ActionType.SWITCH, + isChecked = autoPlaylistTopPlaylist, + onCheckedChange = onAutoPlaylistTopPlaylistChange, + onClick = { onAutoPlaylistTopPlaylistChange(!autoPlaylistTopPlaylist) } + ) + SettingsBox( + title = stringResource(R.string.show_cached_auto_playlist), + icon = IconResource.Vector(Icons.Rounded.Cached), + shape = shapeManager(), + actionType = ActionType.SWITCH, + isChecked = autoPlaylistCached, + onCheckedChange = onAutoPlaylistCachedChange, + onClick = { onAutoPlaylistCachedChange(!autoPlaylistCached) } + ) + SettingsBox( + title = stringResource(R.string.show_local_auto_playlist), + icon = IconResource.Vector(Icons.Rounded.MusicNote), + shape = shapeManager(isLast = true), + actionType = ActionType.SWITCH, + isChecked = autoPlaylistLocal, + onCheckedChange = onAutoPlaylistLocalChange, + onClick = { onAutoPlaylistLocalChange(!autoPlaylistLocal) } + ) + } + } + + SettingsBox( + title = stringResource(R.string.swipe_song_to_dismiss), + icon = IconResource.Drawable(painterResource(R.drawable.queue_music)), + shape = shapeManager(isFirst = !autoPlaylistsCustomization), + actionType = ActionType.SWITCH, + isChecked = swipeSongToDismiss, + onCheckedChange = onSwipeSongToDismissChange, + onClick = { onSwipeSongToDismissChange(!swipeSongToDismiss) } + ) + + if (appDesignVariant == AppDesignVariantType.NEW) { + SettingsBox(shape = shapeManager()) { + EnumListPreference(title = { Text(stringResource(R.string.default_open_tab)) }, icon = { Icon(painterResource(R.drawable.tab), null) }, selectedValue = defaultOpenTab, onValueSelected = onDefaultOpenTabChange, valueText = { stringResource(it.stringId) }) + } + } else { + SettingsBox(shape = shapeManager()) { + EnumListPreference(title = { Text(stringResource(R.string.default_open_tab)) }, icon = { Icon(painterResource(R.drawable.tab), null) }, selectedValue = defaultOpenTabOld, onValueSelected = onDefaultOpenTabOldChange, valueText = { stringResource(it.stringId) }) + } + } + + SettingsBox( + title = stringResource(R.string.slim_navbar), + icon = IconResource.Drawable(painterResource(R.drawable.nav_bar)), + shape = shapeManager(), + actionType = ActionType.SWITCH, + isChecked = slimNav, + onCheckedChange = onSlimNavChange, + onClick = { onSlimNavChange(!slimNav) } + ) + + SettingsBox(shape = shapeManager()) { + EnumListPreference( + title = { Text(stringResource(R.string.grid_cell_size)) }, + icon = { Icon(painterResource(R.drawable.grid_view), null) }, + selectedValue = gridCellSize, + onValueSelected = onGridCellSizeChange, + valueText = { + when (it) { + GridCellSize.SMALL -> stringResource(R.string.small) + GridCellSize.BIG -> stringResource(R.string.big) + } + }, + ) + } + + SettingsBox(shape = shapeManager(isLast = true)) { + ListPreference( + title = { Text(stringResource(R.string.default_lib_chips)) }, + icon = { Icon(painterResource(R.drawable.list), null) }, + selectedValue = defaultChip, + values = listOf( + LibraryFilter.LIBRARY, + LibraryFilter.PLAYLISTS, + LibraryFilter.SONGS, + LibraryFilter.ALBUMS, + LibraryFilter.ARTISTS, + ), + valueText = { + when (it) { + LibraryFilter.SONGS -> stringResource(R.string.songs) + LibraryFilter.ARTISTS -> stringResource(R.string.artists) + LibraryFilter.ALBUMS -> stringResource(R.string.albums) + LibraryFilter.PLAYLISTS -> stringResource(R.string.playlists) + LibraryFilter.LIBRARY -> stringResource(R.string.filter_library) + } + }, + onValueSelected = onDefaultChipChange, + ) + } + Spacer(Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Bottom))) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + + if (showAlignmentDialog) { + var tempAlignment by remember { mutableStateOf(headerCardAlignment) } + val (gradientIntensity, _) = rememberPreference(HeaderCardGradientIntensityKey, 0.6f) + val (headerCardCornerRadius, _) = rememberPreference(HeaderCardCornerRadiusKey, 24) + val (profilePictureUri, _) = rememberPreference(HeaderCardProfilePictureUriKey, "") + val (headerImageKey, _) = rememberPreference(HeaderImageKey, "Loudly-1") + val accountName by rememberPreference(AccountNameKey, "") + + val painter = when { + headerImageKey.startsWith("content://") -> rememberAsyncImagePainter(model = Uri.parse(headerImageKey)) + headerImageKey == "Loudly-1" -> painterResource(id = R.drawable.loudly_picutre_1) + headerImageKey == "Loudly-2" -> painterResource(id = R.drawable.loudly_picutre_2) + headerImageKey == "Loudly-3" -> painterResource(id = R.drawable.loudly_picutre_3) + else -> painterResource(id = R.drawable.loudly_picutre_1) + } + + AlertDialog( + onDismissRequest = { showAlignmentDialog = false }, + title = { Text(stringResource(R.string.text_alignment)) }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16 / 9f), + shape = RoundedCornerShape(headerCardCornerRadius.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = tempAlignment.alignment + ) { + Image(painter = painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + Box(modifier = Modifier.fillMaxSize().background(Brush.verticalGradient(colorStops = arrayOf(0.5f to Color.Transparent, 1.0f to Color.Black.copy(alpha = gradientIntensity))))) + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (profilePictureUri.isNotBlank()) { + Image(painter = rememberAsyncImagePainter(model = Uri.parse(profilePictureUri)), contentDescription = null, modifier = Modifier.size(24.dp).clip(CircleShape), contentScale = ContentScale.Crop) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = accountName.ifBlank { "User Name" }, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + maxLines = 2 + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + + Column( + modifier = Modifier.width(150.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.TopStart }) { + Icon(Icons.Rounded.NorthWest, contentDescription = "Sol Üst") + } + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.TopCenter }) { + Icon(Icons.Rounded.North, contentDescription = "Orta Üst") + } + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.TopEnd }) { + Icon(Icons.Rounded.NorthEast, contentDescription = "Sağ Üst") + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.CenterStart }) { + Icon(Icons.Rounded.West, contentDescription = "Sol Orta") + } + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.Center }) { + Icon(Icons.Rounded.FilterCenterFocus, contentDescription = "Orta") + } + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.CenterEnd }) { + Icon(Icons.Rounded.East, contentDescription = "Sağ Orta") + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.BottomStart }) { + Icon(Icons.Rounded.SouthWest, contentDescription = "Sol Alt") + } + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.BottomCenter }) { + Icon(Icons.Rounded.South, contentDescription = "Orta Alt") + } + IconButton(onClick = { tempAlignment = HeaderCardContentAlignment.BottomEnd }) { + Icon(Icons.Rounded.SouthEast, contentDescription = "Sağ Alt") + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onHeaderCardAlignmentChange(tempAlignment) + showAlignmentDialog = false + } + ) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = { showAlignmentDialog = false }) { Text(stringResource(R.string.cancel)) } + } + ) + } + + if (showGradientDialog) { + var tempIntensity by remember { mutableFloatStateOf(gradientIntensity) } + AlertDialog( + onDismissRequest = { showGradientDialog = false }, + title = { Text(stringResource(R.string.gradient_intensity)) }, + text = { + Column { + Text("${"%.0f".format(tempIntensity * 100)}%", modifier = Modifier.padding(bottom = 8.dp)) + Slider( + value = tempIntensity, + onValueChange = { tempIntensity = it }, + valueRange = 0f..1f + ) + } + }, + confirmButton = { + TextButton( + onClick = { + onGradientIntensityChange(tempIntensity) + showGradientDialog = false + } + ) { Text(stringResource(R.string.save)) } + }, + dismissButton = { + TextButton(onClick = { showGradientDialog = false }) { Text(stringResource(R.string.cancel)) } + } + ) + } + + if (showHeaderCardCornerDialog) { + CounterDialog( + title = stringResource(R.string.corner_round), + onDismiss = { showHeaderCardCornerDialog = false }, + initialValue = headerCardCornerRadius, + upperBound = 40, + lowerBound = 0, + resetValue = 24, + unitDisplay = "dp", + onConfirm = { + onHeaderCardCornerRadiusChange(it) + showHeaderCardCornerDialog = false + }, + onCancel = { + showHeaderCardCornerDialog = false + }, + onReset = { onHeaderCardCornerRadiusChange(24) }, + ) + } + + if (showProfilePictureDialog) { + AlertDialog( + onDismissRequest = { showProfilePictureDialog = false }, + title = { Text(stringResource(R.string.profile_picture)) }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Image( + painter = if (profilePictureUri.isBlank()) { + painterResource(id = R.drawable.person) + } else { + rememberAsyncImagePainter(model = Uri.parse(profilePictureUri)) + }, + contentDescription = "Profile Picture", + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { + profilePicturePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }) { + Text(stringResource(R.string.select_from_gallery)) + } + if (profilePictureUri.isNotBlank()) { + TextButton(onClick = { + onProfilePictureUriChange("") + }) { + Text(stringResource(R.string.remove_profile_picture)) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showProfilePictureDialog = false }) { + Text(stringResource(R.string.close)) + } + } + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (showCornerRadiusDialog) { + CounterDialog( + title = stringResource(R.string.thumbnail_corner_radius), + onDismiss = { showCornerRadiusDialog = false }, + initialValue = thumbnailCornerRadius, + upperBound = 10, + lowerBound = 0, + resetValue = 6, + unitDisplay = "0%", + onConfirm = { + showCornerRadiusDialog = false + onThumbnailCornerRadius(it) + }, + onCancel = { + showCornerRadiusDialog = false + }, + onReset = { onThumbnailCornerRadius(6) }, + ) + } + } + + var showSliderOptionDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSliderOptionDialog) { + DefaultDialog( + buttons = { + TextButton( + onClick = { showSliderOptionDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + onDismiss = { + showSliderOptionDialog = false + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .aspectRatio(1f) + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .border( + 1.dp, + if (sliderStyle == SliderStyle.DEFAULT) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(16.dp) + ) + .clickable { + onSliderStyleChange(SliderStyle.DEFAULT) + showSliderOptionDialog = false + } + .padding(16.dp) + ) { + var sliderValue by remember { + mutableFloatStateOf(0.5f) + } + Slider( + value = sliderValue, + valueRange = 0f..1f, + onValueChange = { + sliderValue = it + }, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = SliderDefaults.colors() + ) + }, + modifier = Modifier + .weight(1f) + .pointerInput(Unit) { + detectTapGestures( + onPress = {} + ) + } + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .aspectRatio(1f) + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .border( + 1.dp, + if (sliderStyle == SliderStyle.SQUIGGLY) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(16.dp) + ) + .clickable { + onSliderStyleChange(SliderStyle.SQUIGGLY) + showSliderOptionDialog = false + } + .padding(16.dp) + ) { + var sliderValue by remember { + mutableFloatStateOf(0.5f) + } + SquigglySlider( + value = sliderValue, + valueRange = 0f..1f, + onValueChange = { + sliderValue = it + }, + modifier = Modifier.weight(1f) + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .aspectRatio(1f) + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .border( + 1.dp, + if (sliderStyle == SliderStyle.COMPOSE) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(16.dp) + ) + .clickable { + onSliderStyleChange(SliderStyle.COMPOSE) + showSliderOptionDialog = false + } + .padding(16.dp) + ) { + var sliderValue by remember { + mutableFloatStateOf(0.5f) + } + Slider( + value = sliderValue, + valueRange = 0f..1f, + onValueChange = { + sliderValue = it + }, + modifier = Modifier.weight(1f) + ) + } + } + } + } + val (selectedPreset, onSelectedPresetChange) = rememberPreference(UiPresetKey, "1.5") + + var showUiDialog by remember { mutableStateOf(false) } + + if (showUiDialog) { + UiPresetDialog( + onDismiss = { showUiDialog = false }, + onPresetSelected = { version -> + onSelectedPresetChange(version) + when (version) { + "1.0" -> { + onAppDesignVariantChange(AppDesignVariantType.OLD) + onPlayerStyle(PlayerStyle.OLD) + } + "1.5" -> { + onAppDesignVariantChange(AppDesignVariantType.NEW) + onPlayerStyle(PlayerStyle.NEW) + } + "2.0" -> { + onPlayerStyle(PlayerStyle.UI_2_0) + onAppDesignVariantChange(AppDesignVariantType.NEW) + } + } + }, + activePreset = selectedPreset + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.appearance)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = stringResource(R.string.back) + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(4.dp)) + + SettingCategory(title = stringResource(R.string.theme)) + SettingsBox( + title = stringResource(R.string.enable_dynamic_theme), + icon = IconResource.Drawable(painterResource(R.drawable.palette)), + shape = shapeManager(isFirst = true), + actionType = ActionType.SWITCH, + isChecked = dynamicTheme, + onCheckedChange = onDynamicThemeChange, + onClick = { onDynamicThemeChange(!dynamicTheme) } + ) + val darkModeDesc = when (darkMode) { + DarkMode.ON -> stringResource(R.string.dark_theme_on) + DarkMode.OFF -> stringResource(R.string.dark_theme_off) + DarkMode.AUTO -> stringResource(R.string.dark_theme_follow_system) + } + SettingsBox( + title = stringResource(R.string.dark_theme), + description = darkModeDesc, + icon = IconResource.Drawable(painterResource(R.drawable.dark_mode)), + shape = shapeManager(), + onClick = { showDarkModeDialog = true } + ) + AnimatedVisibility(useDarkTheme) { + SettingsBox( + title = stringResource(R.string.pure_black), + icon = IconResource.Drawable(painterResource(R.drawable.contrast)), + shape = shapeManager(isLast = !useDarkTheme), + actionType = ActionType.SWITCH, + isChecked = pureBlack, + onCheckedChange = onPureBlackChange, + onClick = { onPureBlackChange(!pureBlack) } + ) + } + + if (selectedPreset == "Custom") { + SettingsBox( + title = stringResource(R.string.app_ui), + description = "v$selectedPreset", + icon = IconResource.Vector(Icons.Rounded.DesignServices), + shape = shapeManager(isFirst = false, isLast = false), + onClick = { showUiDialog = true } + ) + Column { + val appDesignDesc = when (appDesignVariant) { + AppDesignVariantType.NEW -> stringResource(R.string.app_design_ui_1_5) + AppDesignVariantType.OLD -> stringResource(R.string.app_design_ui_1_0) + } + SettingsBox( + title = stringResource(R.string.app_design_variant), + description = appDesignDesc, + icon = IconResource.Vector(Icons.Rounded.DesignServices), + shape = shapeManager(isFirst = false), + onClick = { showAppDesignDialog = true } + ) + + val playerStyleDesc = when (playerStyle) { + PlayerStyle.OLD -> stringResource(R.string.player_style_old_ui) + PlayerStyle.NEW -> stringResource(R.string.player_style_ui_1_0) + PlayerStyle.UI_2_0 -> stringResource(R.string.player_style_ui_2_0) + } + SettingsBox( + title = stringResource(R.string.player_style), + description = playerStyleDesc, + icon = IconResource.Drawable(painterResource(R.drawable.play)), + shape = shapeManager(isLast = true), + onClick = { showPlayerStyleDialog = true } + ) + } + + } else { + SettingsBox( + title = stringResource(R.string.app_ui), + description = "v$selectedPreset", + icon = IconResource.Vector(Icons.Rounded.DesignServices), + shape = shapeManager(isFirst = false, isLast = true), + onClick = { showUiDialog = true } + ) + } + + SettingCategory(title = stringResource(R.string.player)) + val sliderStyleDesc = when (sliderStyle) { + SliderStyle.DEFAULT -> stringResource(R.string.default_) + SliderStyle.SQUIGGLY -> stringResource(R.string.squiggly) + SliderStyle.COMPOSE -> stringResource(R.string.compose) + } + SettingsBox( + title = stringResource(R.string.slider_style), + description = sliderStyleDesc, + icon = IconResource.Drawable(painterResource(R.drawable.sliders)), + shape = shapeManager(isFirst = true), + onClick = { showSliderOptionDialog = true } + ) + val playerBackgroundDesc = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> stringResource(R.string.follow_theme) + PlayerBackgroundStyle.GRADIENT -> stringResource(R.string.gradient) + PlayerBackgroundStyle.BLURMOV -> stringResource(R.string.blurmv) + PlayerBackgroundStyle.BLUR -> stringResource(R.string.blur) + } + SettingsBox( + title = stringResource(R.string.player_background_style), + description = playerBackgroundDesc, + icon = IconResource.Drawable(painterResource(R.drawable.gradient)), + shape = shapeManager(), + onClick = { showPlayerBackgroundDialog = true } + ) + + val miniPlayerActionDesc = when (miniPlayerAction) { + MiniPlayerAction.Like -> stringResource(R.string.like) + MiniPlayerAction.Next -> stringResource(R.string.next) + MiniPlayerAction.Previous -> stringResource(R.string.previous) + MiniPlayerAction.Download -> stringResource(R.string.download) + MiniPlayerAction.None -> stringResource(R.string.none) + } + SettingsBox( + title = stringResource(R.string.mini_player_action_button), + description = miniPlayerActionDesc, + icon = IconResource.Drawable(painterResource(R.drawable.play)), + shape = shapeManager(), + onClick = { showMiniPlayerActionDialog = true } + ) + + SettingsBox( + title = stringResource(R.string.enable_swipe_thumbnail), + icon = IconResource.Drawable(painterResource(R.drawable.swipe)), + shape = shapeManager(), + actionType = ActionType.SWITCH, + isChecked = swipeThumbnail, + onCheckedChange = onSwipeThumbnailChange, + onClick = { onSwipeThumbnailChange(!swipeThumbnail) } + ) + SettingsBox( + title = stringResource(R.string.thumbnail_corner_radius), + description = "$thumbnailCornerRadius" + "0%", + icon = IconResource.Vector(Icons.Rounded.Image), + shape = shapeManager(isLast = true), + onClick = { showCornerRadiusDialog = true } + ) + + SettingCategory(title = stringResource(R.string.profile_card)) + SettingsBox( + title = stringResource(R.string.show_texts), + description = stringResource(R.string.show_texts_description), + icon = IconResource.Drawable(painterResource(R.drawable.badge)), + shape = shapeManager(isFirst = true, isLast = false), + actionType = ActionType.SWITCH, + isChecked = showHeaderCardText, + onCheckedChange = onShowHeaderCardTextChange, + onClick = { onShowHeaderCardTextChange(!showHeaderCardText) } + ) + SettingsBox( + title = stringResource(R.string.header_background), + description = stringResource(R.string.header_background_description), + icon = IconResource.Vector(Icons.Rounded.Image), + shape = shapeManager(isFirst = false, isLast = false), + onClick = { showHeaderImageDialog = true } + ) + SettingsBox( + title = stringResource(R.string.text_alignment), + description = stringResource(R.string.text_alignment_description), + icon = IconResource.Drawable(painterResource(R.drawable.format_align_left)), + shape = shapeManager(isFirst = false, isLast = false), + onClick = { showAlignmentDialog = true } + ) + SettingsBox( + title = stringResource(R.string.gradient_intensity), + description = "%${"%.0f".format(gradientIntensity * 100)}", + icon = IconResource.Drawable(painterResource(R.drawable.gradient)), + shape = shapeManager(isFirst = false, isLast = false), + onClick = { showGradientDialog = true } + ) + SettingsBox( + title = stringResource(R.string.corner_round), + description = "${headerCardCornerRadius}dp", + icon = IconResource.Drawable(painterResource(R.drawable.rounded_corner)), + shape = shapeManager(isFirst = false, isLast = false), + onClick = { showHeaderCardCornerDialog = true } + ) + SettingsBox( + title = stringResource(R.string.profile_picture), + description = if (profilePictureUri.isBlank()) stringResource(R.string.not_selected) else stringResource(R.string.tap_to_change), + icon = IconResource.Drawable(painterResource(R.drawable.account_circle)), + shape = shapeManager(isFirst = false, isLast = true), + onClick = { showProfilePictureDialog = true } + ) + + SettingCategory(title = stringResource(R.string.home)) + + if (isLoggedIn) { + SettingsBox( + title = stringResource(R.string.show_content_filter), + icon = IconResource.Vector(Icons.Rounded.FilterList), + shape = shapeManager(isFirst = true, isLast = false), + actionType = ActionType.SWITCH, + isChecked = showContentFilter, + onCheckedChange = onShowContentFilterChange, + onClick = { onShowContentFilterChange(!showContentFilter) } + ) + SettingsBox( + title = stringResource(R.string.recent_activity), + icon = IconResource.Vector(Icons.Rounded.QueryStats), + shape = shapeManager(isFirst = false, isLast = true), + actionType = ActionType.SWITCH, + isChecked = showRecentActivity, + onCheckedChange = onShowRecentActivityChange, + onClick = { onShowRecentActivityChange(!showRecentActivity) } + ) + } else { + SettingsBox( + title = stringResource(R.string.show_content_filter), + icon = IconResource.Vector(Icons.Rounded.FilterList), + shape = shapeManager(isBoth = true), + actionType = ActionType.SWITCH, + isChecked = showContentFilter, + onCheckedChange = onShowContentFilterChange, + onClick = { onShowContentFilterChange(!showContentFilter) } + ) + } + + SettingCategory(title = stringResource(R.string.misc)) + SettingsBox( + title = stringResource(R.string.other_settings), + description = stringResource(R.string.other_settings_description), + icon = IconResource.Vector(Icons.Rounded.Tune), + shape = shapeManager(isBoth = true), + onClick = { showMiscSheet = true } + ) + Spacer(Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Bottom))) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +fun MiniPlayerActionDialog( + initialAction: MiniPlayerAction, + onDismiss: () -> Unit, + onSave: (MiniPlayerAction) -> Unit +) { + var selectedAction by remember { mutableStateOf(initialAction) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.mini_player_action_button)) }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // --- MiniPlayer PREVIEW --- + Text( + text = stringResource(R.string.preview), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + MiniPlayerPreview(action = selectedAction) + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + + Column { + val options = MiniPlayerAction.values() + options.forEach { option -> + val description = when (option) { + MiniPlayerAction.Like -> stringResource(R.string.like) + MiniPlayerAction.Next -> stringResource(R.string.next) + MiniPlayerAction.Previous -> stringResource(R.string.previous) + MiniPlayerAction.Download -> stringResource(R.string.download) + MiniPlayerAction.None -> stringResource(R.string.none) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { selectedAction = option } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (selectedAction == option), + onClick = { selectedAction = option } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = description) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(selectedAction) }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun MiniPlayerPreview(action: MiniPlayerAction) { + val cardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f) + ) + val cardBorder = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)) + + Card( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + shape = RoundedCornerShape(16.dp), + colors = cardColors, + border = cardBorder, + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Placeholder Album Art + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + Icon( + painter = painterResource(id = R.drawable.loudly_monochrome), + contentDescription = "Album Art", + modifier = Modifier.align(Alignment.Center).size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + // Placeholder Song Info + Column(modifier = Modifier.weight(1f)) { + Text("Song Name", fontWeight = FontWeight.SemiBold) + Text("Artist", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)) + } + + // Placeholder Controls + Row(verticalAlignment = Alignment.CenterVertically) { + when (action) { + MiniPlayerAction.Like -> { + Icon( + imageVector = Icons.Rounded.FavoriteBorder, + contentDescription = "Like", + modifier = Modifier.size(26.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + MiniPlayerAction.Next -> { + Icon( + painter = painterResource(R.drawable.skip_next), + contentDescription = "Next", + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + MiniPlayerAction.Previous -> { + Icon( + painter = painterResource(R.drawable.skip_previous), + contentDescription = "Previous", + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + MiniPlayerAction.None -> {} + MiniPlayerAction.Download -> { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = "Download", + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } + Icon( + painter = painterResource(R.drawable.play), + contentDescription = "Play/Pause", + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f) + ) + } + } + } +} +private data class PredefinedImage(val key: String, val drawableRes: Int) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HeaderImagePickerDialog( + initialValue: String, + tempSelectedUri: Uri?, + onDismiss: () -> Unit, + onSave: (String) -> Unit, + onGalleryClick: () -> Unit, +) { + val predefinedImages = remember { + listOf( + PredefinedImage("Loudly-1", R.drawable.loudly_picutre_1), + PredefinedImage("Loudly-2", R.drawable.loudly_picutre_2), + PredefinedImage("Loudly-3", R.drawable.loudly_picutre_3), + PredefinedImage("Loudly-4", R.drawable.loudly_picutre_4), + PredefinedImage("Loudly-5", R.drawable.loudly_picutre_5), + PredefinedImage("Loudly-6", R.drawable.loudly_picutre_6) + ) + } + + val initialPageIndex = remember { + predefinedImages.indexOfFirst { it.key == initialValue }.coerceAtLeast(0) + } + + val pagerState = rememberPagerState(initialPage = initialPageIndex) { + predefinedImages.size + } + + val scope = rememberCoroutineScope() + + var hasGallerySelection by remember { mutableStateOf(tempSelectedUri != null) } + LaunchedEffect(tempSelectedUri) { + hasGallerySelection = tempSelectedUri != null + } + + val currentSelectionKey by remember { + derivedStateOf { + if (hasGallerySelection) { + tempSelectedUri.toString() + } else { + predefinedImages[pagerState.currentPage].key + } + } + } + + val (profilePictureUri) = rememberPreference(HeaderCardProfilePictureUriKey, "") + val (accountName) = rememberPreference(AccountNameKey, "") + val (headerCardAlignment) = rememberEnumPreference(HeaderCardAlignmentKey, HeaderCardContentAlignment.BottomStart) + val (gradientIntensity) = rememberPreference(HeaderCardGradientIntensityKey, 0.6f) + + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(id = R.string.select_header_image), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // --- PREVIEW --- + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .aspectRatio(16 / 9f), + shape = RoundedCornerShape(16.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = headerCardAlignment.alignment + ) { + val currentImageSource: Any by remember { + derivedStateOf { + if (hasGallerySelection) { + tempSelectedUri ?: R.drawable.loudly_picutre_1 + } else { + predefinedImages[pagerState.currentPage].drawableRes + } + } + } + + Crossfade(targetState = currentImageSource, animationSpec = tween(durationMillis = 500), label = "imageCrossfade") { imageSource -> + Image( + painter = when (imageSource) { + is Uri -> rememberAsyncImagePainter(model = imageSource) + is Int -> painterResource(id = imageSource) + else -> painterResource(id = R.drawable.loudly_picutre_1) + }, + contentDescription = "Preview", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0.5f to Color.Transparent, + 1.0f to Color.Black.copy(alpha = gradientIntensity) + ) + ) + ) + ) + + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (profilePictureUri.isNotBlank()) { + Image( + painter = rememberAsyncImagePainter(model = Uri.parse(profilePictureUri)), + contentDescription = "Profile Picture", + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = accountName.ifBlank { "Your Account Name" }, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + maxLines = 2 + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + val itemWidth = 200.dp // Width of items + val itemHeight = itemWidth * (9f / 16f) // Height in 16:9 aspect ratio + val itemPadding = (-16).dp // Negative space between elements, to make them closer together + val horizontalPadding = 40.dp // Total padding from the left and right edges + + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(itemWidth), + contentPadding = PaddingValues(horizontal = horizontalPadding), + pageSpacing = itemPadding, + modifier = Modifier + .fillMaxWidth() + .height(itemHeight + 32.dp) + ) { page -> + val image = predefinedImages[page] + val pageOffset = ( + (pagerState.currentPage - page) + pagerState + .currentPageOffsetFraction + ).absoluteValue + + // Material 3 Expressive animations + val scaleFactor = lerp(0.7f, 1f, 1f - pageOffset.coerceIn(0f, 1f)) // Scale factor + val alphaFactor = lerp(0.3f, 1f, 1f - pageOffset.coerceIn(0f, 1f)) // Visibility + val blurFactor = lerp(8f, 0f, 1f - pageOffset.coerceIn(0f, 1f)) // Blur (center 0) + + Card( + shape = RoundedCornerShape(20.dp), + modifier = Modifier + .width(itemWidth) + .height(itemHeight) + .graphicsLayer { + scaleX = scaleFactor + scaleY = scaleFactor + transformOrigin = TransformOrigin.Center + this.alpha = alphaFactor + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + renderEffect = if (blurFactor > 0) { + RenderEffect.createBlurEffect( + blurFactor, + blurFactor, + android.graphics.Shader.TileMode.DECAL + ).asComposeRenderEffect() + } else { + null + } + } else if (blurFactor > 0) { + this.alpha = alphaFactor * (1f - blurFactor / 8f) + } + } + .clickable { + hasGallerySelection = false + if (pagerState.currentPage != page) { + scope.launch { + pagerState.animateScrollToPage(page) + } + } + } + ) { + Image( + painter = painterResource(id = image.drawableRes), + contentDescription = image.key, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // --- GALLERY BUTTON --- + Button( + onClick = { + hasGallerySelection = true + onGalleryClick() + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + Text( + text = stringResource(id = R.string.select_from_gallery), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(currentSelectionKey) }) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +@Composable +private fun UiPresetDialog( + onDismiss: () -> Unit, + onPresetSelected: (String) -> Unit, + activePreset: String +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.ui_version_selection)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + mapOf( + "1.0" to stringResource(R.string.ui_version_1_0_desc), + "1.5" to stringResource(R.string.ui_version_1_5_desc), + "2.0" to stringResource(R.string.ui_version_2_0_desc), + "Custom" to stringResource(R.string.ui_version_custom_desc) + ).forEach { (version, description) -> + val isSelected = activePreset == version + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + onPresetSelected(version) + onDismiss() + } + .border( + width = 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + ) { + Column(Modifier.padding(16.dp)) { + Text("App UI v$version", fontWeight = FontWeight.Bold) + Spacer(Modifier.height(4.dp)) + Text(description, style = MaterialTheme.typography.bodySmall) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + } + ) +} + +enum class DarkMode { + ON, OFF, AUTO +} + +enum class NavigationTabOld(val stringId: Int) { + HOME(R.string.home), + EXPLORE(R.string.explore), + SONGS(R.string.songs), + ARTISTS(R.string.artists), + ALBUMS(R.string.albums), + PLAYLISTS(R.string.playlists) +} + +enum class NavigationTab(val stringId: Int) { + HOME(R.string.home), + LIBRARY(R.string.library), + EXPLORE(R.string.explore), + SONGS(R.string.songs), + ARTISTS(R.string.artists), + ALBUMS(R.string.albums), + PLAYLISTS(R.string.playlists) +} + +enum class LyricsPosition { + LEFT, CENTER, RIGHT +} + +enum class HeaderCardContentAlignment(val alignment: Alignment) { + TopStart(Alignment.TopStart), + TopCenter(Alignment.TopCenter), + TopEnd(Alignment.TopEnd), + CenterStart(Alignment.CenterStart), + Center(Alignment.Center), + CenterEnd(Alignment.CenterEnd), + BottomStart(Alignment.BottomStart), + BottomCenter(Alignment.BottomCenter), + BottomEnd(Alignment.BottomEnd) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/BackupAndRestore.kt new file mode 100644 index 0000000..58c785e --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/BackupAndRestore.kt @@ -0,0 +1,200 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.ScannerSensitivity +import com.babelsoftware.loudly.constants.ScannerSensitivityKey +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.menu.AddToPlaylistDialog +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.viewmodels.BackupRestoreViewModel +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackupAndRestore( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: BackupRestoreViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + if (uri != null) { + viewModel.backup(context, uri) + } + } + val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.restore(context, uri) + } + } + val (scannerSensitivity) = rememberEnumPreference( + key = ScannerSensitivityKey, + defaultValue = ScannerSensitivity.LEVEL_2 + ) + + var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } + val importedTitle by remember { mutableStateOf("") } + val importedSongs = remember { mutableStateListOf() } + val rejectedSongs = remember { mutableStateListOf() } + val importM3uLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + val result = viewModel.loadM3u(context, uri, matchStrength = scannerSensitivity) + importedSongs.clear() + importedSongs.addAll(result.first) + rejectedSongs.clear() + rejectedSongs.addAll(result.second) + + if (importedSongs.isNotEmpty()) { + showChoosePlaylistDialog = true + } + } + } + + AddToPlaylistDialog( + navController = navController, + isVisible = showChoosePlaylistDialog, + initialTextFieldValue = importedTitle, + onGetSong = { importedSongs.map { it.id } }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.backup_restore)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + SettingCategory(title = stringResource(R.string.backup_restore)) + SettingsBox( + title = stringResource(R.string.backup), + icon = IconResource.Drawable(painterResource(R.drawable.backup)), + shape = shapeManager(isFirst = true), + onClick = { + val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") + backupLauncher.launch( + "${context.getString(R.string.app_name)}_${ + LocalDateTime.now().format(formatter) + }.backup" + ) + } + ) + SettingsBox( + title = stringResource(R.string.restore), + icon = IconResource.Drawable(painterResource(R.drawable.restore)), + shape = shapeManager(isLast = true), + onClick = { restoreLauncher.launch(arrayOf("application/octet-stream")) } + ) + + SettingCategory(title = stringResource(R.string.misc)) + SettingsBox( + title = stringResource(R.string.import_m3u), + icon = IconResource.Drawable(painterResource(R.drawable.playlist_add)), + shape = shapeManager(isBoth = true), + onClick = { importM3uLauncher.launch(arrayOf("audio/*")) } + ) + + if (rejectedSongs.isNotEmpty()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 1.dp + ) { + LazyColumn( + modifier = Modifier + .heightIn(max = 250.dp) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + item { + Text( + text = stringResource(R.string.could_not_import), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + items(rejectedSongs) { item -> + Text( + text = item, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ComradeNotificationSettingsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ComradeNotificationSettingsScreen.kt new file mode 100644 index 0000000..ee0d76b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ComradeNotificationSettingsScreen.kt @@ -0,0 +1,217 @@ +/** + * Babel Software 2025 + * + * The concept and features of "Comrade" are wholly owned by Babel Software. + * Are made available for use under an open source license. + */ + +package com.babelsoftware.loudly.ui.screens.settings + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.ComradeActivityDetectionEnabledKey +import com.babelsoftware.loudly.constants.ComradeNotificationsEnabledKey +import com.babelsoftware.loudly.constants.ComradeVehicleDetectionEnabledKey +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.utils.NotificationHelper +import com.babelsoftware.loudly.utils.rememberPreference +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun ComradeNotificationSettingsScreen(navController: NavController) { + val context = LocalContext.current + var masterSwitch by rememberPreference(ComradeNotificationsEnabledKey, false) + var vehicleSwitch by rememberPreference(ComradeVehicleDetectionEnabledKey, true) + var activitySwitch by rememberPreference(ComradeActivityDetectionEnabledKey, true) + + var permissionGranted by remember { mutableStateOf(false) } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + permissionGranted = isGranted + } + + val checkNotificationPermission = { + permissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + + // We manage Activity Recognition permission for Android 10 (Q) and above + val activityPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + rememberPermissionState(permission = Manifest.permission.ACTIVITY_RECOGNITION) + } else { + null // This permission is not required in older versions. + } + + LaunchedEffect(Unit) { + checkNotificationPermission() + } + + LaunchedEffect(permissionGranted) {} + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.comrade_notifications_title)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(painterResource(id = R.drawable.arrow_back), contentDescription = "Geri") + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + item { + Spacer(modifier = Modifier.height(8.dp)) + SettingsBox( + title = stringResource(R.string.comrade_notifications_enable), + description = stringResource(R.string.comrade_notifications_enable_desc), + icon = IconResource.Drawable(painterResource(id = R.drawable.ic_smart_hub)), + actionType = ActionType.SWITCH, + isChecked = masterSwitch, + onCheckedChange = { masterSwitch = it }, + shape = shapeManager(isBoth = true) + ) + } + + if (masterSwitch && activityPermissionState != null) { + item { + SettingCategory(title = stringResource(R.string.permissions_category)) + SettingsBox( + title = stringResource(R.string.comrade_permission_activity_title), + description = stringResource(R.string.comrade_permission_activity_desc), + icon = IconResource.Drawable(painterResource(id = R.drawable.directions_run)), + actionType = if (activityPermissionState.status.isGranted) ActionType.TEXT else ActionType.NAVIGATION, + actionText = if (activityPermissionState.status.isGranted) stringResource(R.string.permission_button_granted) else "", + onClick = if (activityPermissionState.status.isGranted) null else { { activityPermissionState.launchPermissionRequest() } }, + shape = shapeManager(isFirst = true) + ) + } + item { + SettingsBox( + title = stringResource(R.string.enable_notifications), + icon = IconResource.Drawable(painterResource(id = if (permissionGranted) R.drawable.notification_on else R.drawable.notifications_off)), + actionType = ActionType.SWITCH, + isChecked = permissionGranted, + onCheckedChange = { checked -> + if (checked && !permissionGranted) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } else if (!checked) { + checkNotificationPermission() + } + }, + shape = shapeManager(isLast = true) + ) + } + } + + if (masterSwitch && (activityPermissionState == null || activityPermissionState.status.isGranted)) { + item { + SettingCategory(title = stringResource(R.string.comrade_recommendation_types)) + SettingsBox( + shape = shapeManager(isFirst = true) + ) { + Column { + Row( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = stringResource(R.string.comrade_driving_detection), style = MaterialTheme.typography.bodyLarge) + Text(text = stringResource(R.string.comrade_driving_detection_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch(checked = vehicleSwitch, onCheckedChange = { vehicleSwitch = it }) + } + OutlinedButton( + onClick = { + NotificationHelper.showContextualNotification( + context = context, + title = context.getString(R.string.notification_vehicle_title), + text = context.getString(R.string.notification_vehicle_text), + playlistId = "DRIVING_PLAYLIST_ID_DEMO", + payloadId = "PAYLOAD" + ) + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) { + Text(stringResource(R.string.demo_button_text)) + } + } + } + SettingsBox( + shape = shapeManager(isLast = true) + ) { + Column { + Row( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 14.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = stringResource(R.string.comrade_activity_detection), style = MaterialTheme.typography.bodyLarge) + Text(text = stringResource(R.string.comrade_activity_detection_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch(checked = activitySwitch, onCheckedChange = { activitySwitch = it }) + } + OutlinedButton( + onClick = { + NotificationHelper.showContextualNotification( + context = context, + title = context.getString(R.string.notification_running_title), + text = context.getString(R.string.notification_running_text), + playlistId = "DRIVING_PLAYLIST_ID_DEMO", + payloadId = "PAYLOAD" + ) + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) + ) { + Text(stringResource(R.string.demo_button_text)) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ContentSettings.kt new file mode 100644 index 0000000..5af27ae --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/ContentSettings.kt @@ -0,0 +1,393 @@ +@file:Suppress("DEPRECATION") +package com.babelsoftware.loudly.ui.screens.settings + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList +import android.provider.Settings +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.NoCell +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.ContentCountryKey +import com.babelsoftware.loudly.constants.ContentLanguageKey +import com.babelsoftware.loudly.constants.CountryCodeToName +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.constants.LanguageCodeToName +import com.babelsoftware.loudly.constants.LikedAutoDownloadKey +import com.babelsoftware.loudly.constants.LikedAutodownloadMode +import com.babelsoftware.loudly.constants.ProxyEnabledKey +import com.babelsoftware.loudly.constants.ProxyTypeKey +import com.babelsoftware.loudly.constants.ProxyUrlKey +import com.babelsoftware.loudly.constants.SYSTEM_DEFAULT +import com.babelsoftware.loudly.constants.SelectedLanguageKey +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.TextFieldDialog +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import java.net.Proxy +import java.util.Locale + +@SuppressLint("PrivateResource") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val context = LocalContext.current + val (likedAutoDownload, onLikedAutoDownload) = rememberEnumPreference(LikedAutoDownloadKey, LikedAutodownloadMode.OFF) + val (contentLanguage, onContentLanguageChange) = rememberPreference(key = ContentLanguageKey, defaultValue = "system") + val (contentCountry, onContentCountryChange) = rememberPreference(key = ContentCountryKey, defaultValue = "system") + val (selectedLanguage, onSelectedLanguage) = rememberPreference(key = SelectedLanguageKey, defaultValue = "system") + val (hideExplicit, onHideExplicitChange) = rememberPreference(key = HideExplicitKey, defaultValue = false) + val (proxyEnabled, onProxyEnabledChange) = rememberPreference(key = ProxyEnabledKey, defaultValue = false) + val (proxyType, onProxyTypeChange) = rememberEnumPreference(key = ProxyTypeKey, defaultValue = Proxy.Type.HTTP) + val (proxyUrl, onProxyUrlChange) = rememberPreference(key = ProxyUrlKey, defaultValue = "host:port") + + var showContentLanguageDialog by remember { mutableStateOf(false) } + var showContentCountryDialog by remember { mutableStateOf(false) } + var showLikeAutodownloadDialog by remember { mutableStateOf(false) } + var showAppLanguageDialog by remember { mutableStateOf(false) } + var showProxyTypeDialog by remember { mutableStateOf(false) } + var showProxyUrlDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.content)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon(painterResource(R.drawable.arrow_back), contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + if (showContentLanguageDialog) { + SelectionListDialog( + title = stringResource(R.string.content_language), + items = listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList(), + itemText = { LanguageCodeToName.getOrElse(it) { stringResource(R.string.system_default) } }, + selectedItem = contentLanguage, + onItemSelected = { + onContentLanguageChange(it) + showContentLanguageDialog = false + }, + onDismissRequest = { showContentLanguageDialog = false } + ) + } + + if (showContentCountryDialog) { + SelectionListDialog( + title = stringResource(R.string.content_country), + items = listOf(SYSTEM_DEFAULT) + CountryCodeToName.keys.toList(), + itemText = { CountryCodeToName.getOrElse(it) { stringResource(R.string.system_default) } }, + selectedItem = contentCountry, + onItemSelected = { + onContentCountryChange(it) + showContentCountryDialog = false + }, + onDismissRequest = { showContentCountryDialog = false } + ) + } + + if (showLikeAutodownloadDialog) { + SelectionListDialog( + title = stringResource(R.string.like_autodownload), + items = LikedAutodownloadMode.values().toList(), + itemText = { + when (it) { + LikedAutodownloadMode.OFF -> stringResource(R.string.state_off) + LikedAutodownloadMode.ON -> stringResource(R.string.state_on) + LikedAutodownloadMode.WIFI_ONLY -> stringResource(R.string.wifi_only) + } + }, + selectedItem = likedAutoDownload, + onItemSelected = { + onLikedAutoDownload(it) + showLikeAutodownloadDialog = false + }, + onDismissRequest = { showLikeAutodownloadDialog = false } + ) + } + + if (showAppLanguageDialog) { + SelectionListDialog( + title = stringResource(R.string.app_language), + items = listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList(), + itemText = { LanguageCodeToName[it] ?: stringResource(R.string.system_default) }, + selectedItem = selectedLanguage, + onItemSelected = { + onSelectedLanguage(it) + updateLanguage(context, it) + saveLanguagePreference(context, it) + showAppLanguageDialog = false + }, + onDismissRequest = { showAppLanguageDialog = false } + ) + } + + if (showProxyTypeDialog) { + SelectionListDialog( + title = stringResource(R.string.proxy_type), + items = listOf(Proxy.Type.HTTP, Proxy.Type.SOCKS), + itemText = { it.name }, + selectedItem = proxyType, + onItemSelected = { + onProxyTypeChange(it) + showProxyTypeDialog = false + }, + onDismissRequest = { showProxyTypeDialog = false } + ) + } + + if (showProxyUrlDialog) { + TextFieldDialog( + initialTextFieldValue = TextFieldValue(proxyUrl), + onDone = { + onProxyUrlChange(it) + showProxyUrlDialog = false + }, + onDismiss = { showProxyUrlDialog = false }, + isInputValid = { it.contains(":") } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + SettingCategory(title = stringResource(R.string.content)) + SettingsBox( + title = stringResource(R.string.content_language), + description = LanguageCodeToName.getOrElse(contentLanguage) { stringResource(R.string.system_default) }, + icon = IconResource.Drawable(painterResource(R.drawable.language)), + shape = shapeManager(isFirst = true), + onClick = { showContentLanguageDialog = true } + ) + SettingsBox( + title = stringResource(R.string.content_country), + description = CountryCodeToName.getOrElse(contentCountry) { stringResource(R.string.system_default) }, + icon = IconResource.Drawable(painterResource(R.drawable.location_on)), + shape = shapeManager(), + onClick = { showContentCountryDialog = true } + ) + SettingsBox( + title = stringResource(R.string.hide_explicit), + icon = IconResource.Drawable(painterResource(R.drawable.explicit)), + actionType = ActionType.SWITCH, + isChecked = hideExplicit, + onCheckedChange = onHideExplicitChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.like_autodownload), + description = when (likedAutoDownload) { + LikedAutodownloadMode.OFF -> stringResource(R.string.state_off) + LikedAutodownloadMode.ON -> stringResource(R.string.state_on) + LikedAutodownloadMode.WIFI_ONLY -> stringResource(R.string.wifi_only) + }, + icon = IconResource.Vector(Icons.Rounded.Favorite), + shape = shapeManager(isLast = true), + onClick = { showLikeAutodownloadDialog = true } + ) + + SettingCategory(title = stringResource(R.string.system_integration)) + SettingsBox( + title = stringResource(R.string.keep_to_alive_settings), + icon = IconResource.Vector(Icons.Rounded.NoCell), + shape = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) shapeManager(isFirst = true) else shapeManager(isBoth = true), + onClick = { navController.navigate("settings/content/notification") } + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SettingsBox( + title = stringResource(R.string.open_supported_links), + description = stringResource(R.string.configure_supported_links), + icon = IconResource.Drawable(painterResource(R.drawable.add_link)), + shape = shapeManager(isLast = true), + onClick = { + try { + context.startActivity( + Intent(Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, "package:${context.packageName}".toUri()), + ) + } catch (e: ActivityNotFoundException) { + Toast.makeText(context, R.string.intent_supported_links_not_found, Toast.LENGTH_LONG).show() + } + } + ) + } + + SettingCategory(title = stringResource(R.string.app_language)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + SettingsBox( + title = stringResource(R.string.app_language), + icon = IconResource.Drawable(painterResource(R.drawable.translate)), + shape = shapeManager(isBoth = true), + onClick = { + context.startActivity(Intent(Settings.ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())) + } + ) + } else { + SettingsBox( + title = stringResource(R.string.app_language), + description = LanguageCodeToName[selectedLanguage] ?: stringResource(R.string.system_default), + icon = IconResource.Drawable(painterResource(R.drawable.translate)), + shape = shapeManager(isBoth = true), + onClick = { showAppLanguageDialog = true } + ) + } + + SettingCategory(title = stringResource(R.string.proxy)) + SettingsBox( + title = stringResource(R.string.enable_proxy), + icon = IconResource.Drawable(painterResource(R.drawable.wifi_proxy)), + actionType = ActionType.SWITCH, + isChecked = proxyEnabled, + onCheckedChange = onProxyEnabledChange, + shape = if (proxyEnabled) shapeManager(isFirst = true) else shapeManager(isBoth = true) + ) + + AnimatedVisibility(proxyEnabled) { + Column { + SettingsBox( + title = stringResource(R.string.proxy_type), + description = proxyType.name, + shape = shapeManager(), + onClick = { showProxyTypeDialog = true } + ) + SettingsBox( + title = stringResource(R.string.proxy_url), + description = proxyUrl, + shape = shapeManager(isLast = true), + onClick = { showProxyUrlDialog = true } + ) + } + } + } + } +} + +/** + * A reusable selection dialog for use only within this file. + */ +@Composable +private fun SelectionListDialog( + title: String, + items: List, + itemText: @Composable (T) -> String, + selectedItem: T, + onItemSelected: (T) -> Unit, + onDismissRequest: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + LazyColumn { + items(items) { item -> + val isSelected = item == selectedItem + Text( + text = itemText(item), + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxWidth() + .clickable { onItemSelected(item) } + .padding(vertical = 14.dp) + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + +fun saveLanguagePreference(context: Context, languageCode: String) { + val sharedPreferences = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE) + sharedPreferences.edit { putString("app_language", languageCode) } +} + +fun updateLanguage(context: Context, languageCode: String) { + val locale = if (languageCode == "system") { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + context.resources.configuration.locales[0] + } else { + context.resources.configuration.locale + } + } else { + Locale(languageCode) + } + + val config = Configuration(context.resources.configuration) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + config.setLocales(LocaleList(locale)) + } else { + @Suppress("DEPRECATION") + config.setLocale(locale) + } + + @Suppress("DEPRECATION") + context.resources.updateConfiguration(config, context.resources.displayMetrics) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordLoginScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordLoginScreen.kt new file mode 100644 index 0000000..b41b51a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordLoginScreen.kt @@ -0,0 +1,113 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.constants.DiscordTokenKey +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberPreference +import com.babelsoftware.loudly.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("SetJavaScriptEnabled") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscordLoginScreen( + navController: NavController, +) { + val scope = rememberCoroutineScope() + var discordToken by rememberPreference(DiscordTokenKey, "") + var webView: WebView? = null + + AndroidView( + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .fillMaxSize(), + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + webView: WebView, + request: WebResourceRequest, + ): Boolean { + stopLoading() + if (request.url.toString().endsWith("/app")) { + loadUrl("javascript:Android.onRetrieveToken((webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken());") + } + return false + } + } + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + setSupportZoom(true) + builtInZoomControls = true + } + val cookieManager = CookieManager.getInstance() + cookieManager.removeAllCookies(null) + cookieManager.flush() + + WebStorage.getInstance().deleteAllData() + addJavascriptInterface(object { + @JavascriptInterface + fun onRetrieveToken(token: String) { + discordToken = token + scope.launch(Dispatchers.Main) { + navController.navigateUp() + } + } + }, "Android") + webView = this + loadUrl("https://discord.com/login") + } + } + ) + + TopAppBar( + title = { Text(stringResource(R.string.login)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + ) + + BackHandler(enabled = webView?.canGoBack() == true) { + webView?.goBack() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordSettings.kt new file mode 100644 index 0000000..6b3c9a3 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/DiscordSettings.kt @@ -0,0 +1,459 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import android.content.Intent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.my.kizzy.rpc.KizzyRPC +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.DiscordNameKey +import com.babelsoftware.loudly.constants.DiscordTokenKey +import com.babelsoftware.loudly.constants.DiscordUsernameKey +import com.babelsoftware.loudly.constants.EnableDiscordRPCKey +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.component.PreferenceGroupTitle +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import androidx.core.net.toUri + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscordSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val song by playerConnection.currentSong.collectAsState(null) + val coroutineScope = rememberCoroutineScope() + + var discordToken by rememberPreference(DiscordTokenKey, "") + var discordUsername by rememberPreference(DiscordUsernameKey, "") + var discordName by rememberPreference(DiscordNameKey, "") + + LaunchedEffect(discordToken) { + val token = discordToken + if (token.isEmpty()) return@LaunchedEffect + coroutineScope.launch(Dispatchers.IO) { + KizzyRPC.getUserInfo(token).onSuccess { + discordUsername = it.username + discordName = it.name + } + } + } + + val (discordRPC, onDiscordRPCChange) = rememberPreference( + key = EnableDiscordRPCKey, + defaultValue = true + ) + val isLoggedIn = remember(discordToken) { discordToken != "" } + + Column( + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top))) + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + PreferenceGroupTitle( + title = stringResource(R.string.account), + modifier = Modifier.padding(bottom = 8.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.discord), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .padding(8.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isLoggedIn) discordName else stringResource(R.string.not_logged_in), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.alpha(if (isLoggedIn) 1f else 0.5f), + ) + if (discordUsername.isNotEmpty()) { + Text( + text = "@$discordUsername", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (isLoggedIn) { + OutlinedButton( + onClick = { + discordName = "" + discordToken = "" + discordUsername = "" + }, + shape = RoundedCornerShape(12.dp) + ) { + Text(stringResource(R.string.logout)) + } + } else { + OutlinedButton( + onClick = { navController.navigate("settings/discord/login") }, + shape = RoundedCornerShape(12.dp) + ) { + Text(stringResource(R.string.login)) + } + } + } + } + } + + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + PreferenceGroupTitle( + title = stringResource(R.string.options), + modifier = Modifier.padding(bottom = 8.dp) + ) + + SwitchPreferenceDiscord( + title = { + Text( + stringResource(R.string.enable_discord_rpc), + style = MaterialTheme.typography.bodyLarge + ) + }, + checked = discordRPC, + onCheckedChange = onDiscordRPCChange, + isEnabled = isLoggedIn, + ) + } + } + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + PreferenceGroupTitle( + title = stringResource(R.string.preview), + modifier = Modifier.padding(bottom = 8.dp) + ) + RichPresence(song) + } + } + + TopAppBar( + title = { Text(stringResource(R.string.discord_integration)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + scrollBehavior = scrollBehavior + ) +} + +@Composable +fun RichPresence(song: Song?) { + val context = LocalContext.current + + ElevatedCard( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.listen_to_loudly), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.ExtraBold + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth() + ) { + Box( + Modifier.size(108.dp), + ) { + AsyncImage( + model = song?.song?.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(96.dp) + .clip(RoundedCornerShape(12.dp)) + .align(Alignment.TopStart) + .run { + if (song == null) { + border( + 2.dp, + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(12.dp) + ) + } else { + this + } + }, + ) + + song?.artists?.firstOrNull()?.thumbnailUrl?.let { + Box( + modifier = Modifier + .border( + 2.dp, + MaterialTheme.colorScheme.surface, + CircleShape + ) + .padding(2.dp) + .align(Alignment.BottomEnd), + ) { + AsyncImage( + model = it, + contentDescription = null, + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + ) + } + } + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + ) { + Text( + text = song?.song?.title ?: stringResource(R.string.song_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.ExtraBold + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = song?.artists?.joinToString { it.name } ?: stringResource(R.string.artist), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + song?.album?.title?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton( + enabled = song != null, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, + "https://music.youtube.com/watch?v=${song?.id}".toUri()) + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = stringResource(R.string.listen_youtube_music), + style = MaterialTheme.typography.labelLarge + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, + "https://github.com/RRechz/Loudly".toUri()) + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = stringResource(R.string.visit_the_loudly), + style = MaterialTheme.typography.labelLarge + ) + } + } + } +} + +@Composable +fun PreferenceEntryDiscord( + modifier: Modifier, + title: @Composable () -> Unit, + description: String? = null, + content: (@Composable () -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, + isEnabled: Boolean = true, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = isEnabled && onClick != null, + onClick = onClick ?: {} + ) + .alpha(if (isEnabled) 1f else 0.5f) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + if (icon != null) { + Box( + modifier = Modifier.padding(horizontal = 4.dp) + ) { + icon() + } + + Spacer(Modifier.width(12.dp)) + } + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + title() + } + + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + + content?.invoke() + } + + if (trailingContent != null) { + Spacer(Modifier.width(12.dp)) + trailingContent() + } + } +} + +@Composable +fun SwitchPreferenceDiscord( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + description: String? = null, + icon: (@Composable () -> Unit)? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true, +) { + PreferenceEntryDiscord( + modifier = modifier, + title = title, + description = description, + icon = icon, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + }, + onClick = { onCheckedChange(!checked) }, + isEnabled = isEnabled + ) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LocalPlayerSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LocalPlayerSettings.kt new file mode 100644 index 0000000..a718dfe --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LocalPlayerSettings.kt @@ -0,0 +1,208 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.FolderCopy +import androidx.compose.material.icons.rounded.GraphicEq +import androidx.compose.material.icons.rounded.TextFields +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AutoSyncLocalSongsKey +import com.babelsoftware.loudly.constants.FlatSubfoldersKey +import com.babelsoftware.loudly.constants.ScannerSensitivity +import com.babelsoftware.loudly.constants.ScannerSensitivityKey +import com.babelsoftware.loudly.constants.ScannerStrictExtKey +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocalPlayerSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (autoSyncLocalSongs, onAutoSyncLocalSongs) = rememberPreference(key = AutoSyncLocalSongsKey, defaultValue = true) + val (scannerSensitivity, onScannerSensitivityChange) = rememberEnumPreference(key = ScannerSensitivityKey, defaultValue = ScannerSensitivity.LEVEL_2) + val (strictExtensions, onStrictExtensionsChange) = rememberPreference(ScannerStrictExtKey, defaultValue = false) + val (flatSubfolders, onFlatSubfoldersChange) = rememberPreference(FlatSubfoldersKey, defaultValue = true) + + var showSensitivityDialog by remember { mutableStateOf(false) } + + if (showSensitivityDialog) { + SelectionListDialog( + title = stringResource(R.string.scanner_sensitivity_title), + items = ScannerSensitivity.values().toList(), + itemText = { + when (it) { + ScannerSensitivity.LEVEL_1 -> stringResource(R.string.scanner_sensitivity_L1) + ScannerSensitivity.LEVEL_2 -> stringResource(R.string.scanner_sensitivity_L2) + ScannerSensitivity.LEVEL_3 -> stringResource(R.string.scanner_sensitivity_L3) + } + }, + selectedItem = scannerSensitivity, + onItemSelected = { + onScannerSensitivityChange(it) + showSensitivityDialog = false + }, + onDismissRequest = { showSensitivityDialog = false } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.local_player_settings_title)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painter = painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + SettingCategory(title = stringResource(R.string.scanner_settings_title)) + SettingsBox( + title = stringResource(R.string.auto_scanner_title), + description = stringResource(R.string.auto_scanner_description), + icon = IconResource.Drawable(painterResource(R.drawable.sync)), + actionType = ActionType.SWITCH, + isChecked = autoSyncLocalSongs, + onCheckedChange = onAutoSyncLocalSongs, + shape = shapeManager(isFirst = true) + ) + SettingsBox( + title = stringResource(R.string.scanner_sensitivity_title), + description = when (scannerSensitivity) { + ScannerSensitivity.LEVEL_1 -> stringResource(R.string.scanner_sensitivity_L1) + ScannerSensitivity.LEVEL_2 -> stringResource(R.string.scanner_sensitivity_L2) + ScannerSensitivity.LEVEL_3 -> stringResource(R.string.scanner_sensitivity_L3) + }, + icon = IconResource.Vector(Icons.Rounded.GraphicEq), + onClick = { showSensitivityDialog = true }, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.scanner_strict_file_name_title), + description = stringResource(R.string.scanner_strict_file_name_description), + icon = IconResource.Vector(Icons.Rounded.TextFields), + actionType = ActionType.SWITCH, + isChecked = strictExtensions, + onCheckedChange = onStrictExtensionsChange, + shape = shapeManager(isLast = true) + ) + + SettingCategory(title = stringResource(R.string.folders_settings_title)) + SettingsBox( + title = stringResource(R.string.flat_subfolders_title), + description = stringResource(R.string.flat_subfolders_description), + icon = IconResource.Vector(Icons.Rounded.FolderCopy), + actionType = ActionType.SWITCH, + isChecked = flatSubfolders, + onCheckedChange = onFlatSubfoldersChange, + shape = shapeManager(isBoth = true) + ) + } + } +} + +@Composable +private fun SelectionListDialog( + title: String, + items: List, + itemText: @Composable (T) -> String, + selectedItem: T, + onItemSelected: (T) -> Unit, + onDismissRequest: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + LazyColumn { + items(items) { item -> + val isSelected = item == selectedItem + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onItemSelected(item) } + .padding(vertical = 14.dp) + ) { + RadioButton( + selected = isSelected, + onClick = { onItemSelected(item) } + ) + Spacer(Modifier.width(16.dp)) + Text( + text = itemText(item), + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LyricsSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LyricsSettings.kt new file mode 100644 index 0000000..1bbb9cb --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/LyricsSettings.kt @@ -0,0 +1,307 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Sort +import androidx.compose.material.icons.rounded.ContentCut +import androidx.compose.material.icons.rounded.Lyrics +import androidx.compose.material.icons.rounded.TextFields +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.toLowerCase +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.EnableKugouKey +import com.babelsoftware.loudly.constants.EnableLrcLibKey +import com.babelsoftware.loudly.constants.LyricFontSizeKey +import com.babelsoftware.loudly.constants.LyricTrimKey +import com.babelsoftware.loudly.constants.LyricsTextPositionKey +import com.babelsoftware.loudly.constants.MultilineLrcKey +import com.babelsoftware.loudly.constants.PreferredLyricsProvider +import com.babelsoftware.loudly.constants.PreferredLyricsProviderKey +import com.babelsoftware.loudly.ui.component.CounterDialog +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LyricsSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) + val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference(LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER) + val (enableLrcLib, onEnableLrcLibChange) = rememberPreference(key = EnableLrcLibKey, defaultValue = true) + val (preferredProvider, onPreferredProviderChange) = rememberEnumPreference(key = PreferredLyricsProviderKey, defaultValue = PreferredLyricsProvider.LRCLIB) + val (multilineLrc, onMultilineLrcChange) = rememberPreference(MultilineLrcKey, defaultValue = true) + val (lyricTrim, onLyricTrimChange) = rememberPreference(LyricTrimKey, defaultValue = false) + val (lyricFontSize, onLyricFontSizeChange) = rememberPreference(LyricFontSizeKey, defaultValue = 20) + + var showFontSizeDialog by remember { mutableStateOf(false) } + var showLyricsPositionDialog by remember { mutableStateOf(false) } + val providerSheetState = rememberModalBottomSheetState() + var showProviderSheet by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + + if (showFontSizeDialog) { + CounterDialog( + title = stringResource(R.string.lyrics_font_size), + initialValue = lyricFontSize, + upperBound = 28, + lowerBound = 10, + resetValue = 20, + unitDisplay = " sp", + onDismiss = { showFontSizeDialog = false }, + onConfirm = { + onLyricFontSizeChange(it) + showFontSizeDialog = false + }, + onCancel = { showFontSizeDialog = false }, + onReset = { onLyricFontSizeChange(20) }, + ) + } + + if (showLyricsPositionDialog) { + SelectionListDialog( + title = stringResource(R.string.lyrics_text_position), + items = LyricsPosition.values().toList(), + itemText = { + when (it) { + LyricsPosition.LEFT -> stringResource(R.string.left) + LyricsPosition.CENTER -> stringResource(R.string.center) + LyricsPosition.RIGHT -> stringResource(R.string.right) + } + }, + selectedItem = lyricsPosition, + onItemSelected = { + onLyricsPositionChange(it) + showLyricsPositionDialog = false + }, + onDismissRequest = { showLyricsPositionDialog = false } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.lyrics_settings_title)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + + if (showProviderSheet) { + ModalBottomSheet( + onDismissRequest = { showProviderSheet = false }, + sheetState = providerSheetState + ) { + Column(modifier = Modifier.padding(bottom = 32.dp)) { + Text( + text = stringResource(R.string.default_lyrics_provider), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) + ) + PreferredLyricsProvider.values().forEach { provider -> + val isSelected = preferredProvider == provider + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onPreferredProviderChange(provider) + scope + .launch { providerSheetState.hide() } + .invokeOnCompletion { + if (!providerSheetState.isVisible) { + showProviderSheet = false + } + } + } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + RadioButton(selected = isSelected, onClick = null) + Spacer(Modifier.width(16.dp)) + Text( + text = provider.name.toLowerCase(Locale.current).capitalize(Locale.current), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + SettingCategory(title = stringResource(R.string.main)) + SettingsBox( + title = stringResource(R.string.enable_kugou), + icon = IconResource.Vector(Icons.Rounded.Lyrics), + actionType = ActionType.SWITCH, + isChecked = enableKugou, + onCheckedChange = onEnableKugouChange, + shape = shapeManager(isFirst = true) + ) + SettingsBox( + title = stringResource(R.string.enable_lrclib), + icon = IconResource.Vector(Icons.Rounded.Lyrics), + actionType = ActionType.SWITCH, + isChecked = enableLrcLib, + onCheckedChange = onEnableLrcLibChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.lyrics_multiline_title), + description = stringResource(R.string.lyrics_multiline_description), + icon = IconResource.Vector(Icons.AutoMirrored.Rounded.Sort), + actionType = ActionType.SWITCH, + isChecked = multilineLrc, + onCheckedChange = onMultilineLrcChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.lyrics_trim_title), + icon = IconResource.Vector(Icons.Rounded.ContentCut), + actionType = ActionType.SWITCH, + isChecked = lyricTrim, + onCheckedChange = onLyricTrimChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.lyrics_text_position), + description = when (lyricsPosition) { + LyricsPosition.LEFT -> stringResource(R.string.left) + LyricsPosition.CENTER -> stringResource(R.string.center) + LyricsPosition.RIGHT -> stringResource(R.string.right) + }, + icon = IconResource.Vector(Icons.Rounded.Lyrics), + onClick = { showLyricsPositionDialog = true }, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.lyrics_font_size), + description = "$lyricFontSize sp", + icon = IconResource.Vector(Icons.Rounded.TextFields), + onClick = { showFontSizeDialog = true }, + shape = shapeManager(isLast = true) + ) + + SettingCategory(title = stringResource(R.string.misc)) + SettingsBox( + title = stringResource(R.string.default_lyrics_provider), + description = preferredProvider.name.toLowerCase(Locale.current).capitalize(Locale.current), + onClick = { showProviderSheet = true }, + shape = shapeManager(isBoth = true) + ) + } + } +} + +@Composable +private fun SelectionListDialog( + title: String, + items: List, + itemText: @Composable (T) -> String, + selectedItem: T, + onItemSelected: (T) -> Unit, + onDismissRequest: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + LazyColumn { + items(items) { item -> + val isSelected = item == selectedItem + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onItemSelected(item) } + .padding(vertical = 14.dp) + ) { + RadioButton( + selected = isSelected, + onClick = { onItemSelected(item) } + ) + Spacer(Modifier.width(16.dp)) + Text( + text = itemText(item), + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/NotificationSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/NotificationSettings.kt new file mode 100644 index 0000000..80fb995 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/NotificationSettings.kt @@ -0,0 +1,128 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.NoCell +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.KeepAliveKey +import com.babelsoftware.loudly.playback.KeepAlive +import com.babelsoftware.loudly.reportException +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val context = LocalContext.current + val (keepAlive, onKeepAliveChange) = rememberPreference(key = KeepAliveKey, defaultValue = false) + + fun toggleKeepAlive(newValue: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + onKeepAliveChange(false) + Toast.makeText(context, context.getString(R.string.notification_permission_is_required), Toast.LENGTH_SHORT).show() + (context as? Activity)?.let { + ActivityCompat.requestPermissions(it, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 0) + } + return + } + + if (keepAlive != newValue) { + onKeepAliveChange(newValue) + try { + if (newValue) { + context.startService(Intent(context, KeepAlive::class.java)) + } else { + context.stopService(Intent(context, KeepAlive::class.java)) + } + } catch (e: Exception) { + reportException(e) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.keep_to_alive_settings)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + SettingsBox( + title = stringResource(R.string.keep_alive_title), + description = stringResource(R.string.keep_alive_description), + icon = IconResource.Vector(Icons.Rounded.NoCell), + actionType = ActionType.SWITCH, + isChecked = keepAlive, + onCheckedChange = { toggleKeepAlive(it) }, + shape = shapeManager(isBoth = true) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PlayerSettings.kt new file mode 100644 index 0000000..9843572 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PlayerSettings.kt @@ -0,0 +1,368 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.BluetoothConnected +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Lyrics +import androidx.compose.material.icons.rounded.SdCard +import androidx.compose.material.icons.rounded.Sync +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AddingPlayedSongsToYTMHistoryKey +import com.babelsoftware.loudly.constants.AudioNormalizationKey +import com.babelsoftware.loudly.constants.AudioOffload +import com.babelsoftware.loudly.constants.AudioQuality +import com.babelsoftware.loudly.constants.AudioQualityKey +import com.babelsoftware.loudly.constants.AutoLoadMoreKey +import com.babelsoftware.loudly.constants.AutoPlaySongWhenBluetoothDeviceConnectedKey +import com.babelsoftware.loudly.constants.AutoSkipNextOnErrorKey +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.PersistentQueueKey +import com.babelsoftware.loudly.constants.SkipSilenceKey +import com.babelsoftware.loudly.constants.StopMusicOnTaskClearKey +import com.babelsoftware.loudly.constants.StopPlayingSongWhenMinimumVolumeKey +import com.babelsoftware.loudly.constants.minPlaybackDurKey +import com.babelsoftware.loudly.ui.component.CounterDialog +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (audioQuality, onAudioQualityChange) = rememberEnumPreference(AudioQualityKey, defaultValue = AudioQuality.AUTO) + val (persistentQueue, onPersistentQueueChange) = rememberPreference(PersistentQueueKey, defaultValue = true) + val (skipSilence, onSkipSilenceChange) = rememberPreference(SkipSilenceKey, defaultValue = false) + val (audioNormalization, onAudioNormalizationChange) = rememberPreference(AudioNormalizationKey, defaultValue = true) + val (autoPlaySongWhenBluetoothDeviceConnected, onAutoPlaySongWhenBluetoothDeviceConnectedChange) = rememberPreference(AutoPlaySongWhenBluetoothDeviceConnectedKey, defaultValue = true) + val (stopPlayingSongWhenMinimumVolume, onStopPlayingSongWhenMinimumVolumeChange) = rememberPreference(StopPlayingSongWhenMinimumVolumeKey, defaultValue = true) + val (autoSkipNextOnError, onAutoSkipNextOnErrorChange) = rememberPreference(AutoSkipNextOnErrorKey, defaultValue = false) + val (stopMusicOnTaskClear, onStopMusicOnTaskClearChange) = rememberPreference(StopMusicOnTaskClearKey, defaultValue = false) + val (autoLoadMore, onAutoLoadMoreChange) = rememberPreference(AutoLoadMoreKey, defaultValue = true) + val (minPlaybackDur, onMinPlaybackDurChange) = rememberPreference(minPlaybackDurKey, defaultValue = 30) + val (audioOffload, onAudioOffloadChange) = rememberPreference(key = AudioOffload, defaultValue = false) + val (addingPlayedSongsToYtmHistory, onAddingPlayedSongsToYtmHistoryChange) = rememberPreference(AddingPlayedSongsToYTMHistoryKey, defaultValue = true) + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } + var showMinPlaybackDurDialog by remember { mutableStateOf(false) } + var showAudioQualityDialog by remember { mutableStateOf(false) } + val miscSheetState = rememberModalBottomSheetState() + var showMiscSheet by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + + if (showMinPlaybackDurDialog) { + CounterDialog( + title = stringResource(R.string.minimum_playback_duration), + description = stringResource(R.string.minimum_playback_duration_info), + initialValue = minPlaybackDur, + upperBound = 100, + lowerBound = 0, + resetValue = 30, + unitDisplay = "%", + onDismiss = { showMinPlaybackDurDialog = false }, + onConfirm = { + onMinPlaybackDurChange(it) + showMinPlaybackDurDialog = false + }, + onCancel = { showMinPlaybackDurDialog = false }, + onReset = { onMinPlaybackDurChange(30) }, + ) + } + + if (showAudioQualityDialog) { + AudioQualitySelectionDialog( + selectedValue = audioQuality, + onValueSelected = { + onAudioQualityChange(it) + showAudioQualityDialog = false + }, + onDismissRequest = { showAudioQualityDialog = false } + ) + } + + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.player_and_audio)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon(painterResource(R.drawable.arrow_back), contentDescription = null) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + + if (showMiscSheet) { + ModalBottomSheet( + onDismissRequest = { showMiscSheet = false }, + sheetState = miscSheetState, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).padding(bottom = 24.dp)) { + SettingCategory(title = stringResource(R.string.misc)) + SettingsBox( + title = stringResource(R.string.audio_offload), + description = stringResource(R.string.audio_offload_description), + icon = IconResource.Vector(Icons.Rounded.Bolt), + actionType = ActionType.SWITCH, + isChecked = audioOffload, + onCheckedChange = onAudioOffloadChange, + shape = shapeManager(isFirst = true) + ) + Spacer(modifier = Modifier.padding(top=2.dp)) + SettingsBox( + title = stringResource(R.string.stop_music_on_task_clear), + icon = IconResource.Drawable(painterResource(R.drawable.clear_all)), + actionType = ActionType.SWITCH, + isChecked = stopMusicOnTaskClear, + onCheckedChange = onStopMusicOnTaskClearChange, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(vertical = 16.dp) + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + SettingCategory(title = stringResource(R.string.player)) + SettingsBox( + title = stringResource(R.string.audio_quality), + description = when (audioQuality) { + AudioQuality.AUTO -> stringResource(R.string.audio_quality_auto) + AudioQuality.MAX -> stringResource(R.string.audio_quality_max) + AudioQuality.HIGH -> stringResource(R.string.audio_quality_high) + AudioQuality.LOW -> stringResource(R.string.audio_quality_low) + }, + icon = IconResource.Drawable(painterResource(R.drawable.graphic_eq)), + shape = shapeManager(isFirst = true), + onClick = { showAudioQualityDialog = true } + ) + SettingsBox( + title = stringResource(R.string.lyrics_settings_title), + icon = IconResource.Vector(Icons.Rounded.Lyrics), + shape = shapeManager(), + onClick = { navController.navigate("settings/player/lyrics") } + ) + SettingsBox( + title = stringResource(R.string.local_player_settings_title), + icon = IconResource.Vector(Icons.Rounded.SdCard), + shape = shapeManager(), + onClick = { navController.navigate("player/local") } + ) + SettingsBox( + title = stringResource(R.string.minimum_playback_duration), + description = "$minPlaybackDur %", + icon = IconResource.Vector(Icons.Rounded.Sync), + shape = shapeManager(), + onClick = { showMinPlaybackDurDialog = true } + ) + SettingsBox( + title = stringResource(R.string.skip_silence), + icon = IconResource.Drawable(painterResource(R.drawable.fast_forward)), + actionType = ActionType.SWITCH, + isChecked = skipSilence, + onCheckedChange = onSkipSilenceChange, + shape = shapeManager() + ) + if (isLoggedIn) { + SettingsBox( + title = stringResource(R.string.adding_played_songs_to_ytm_history), + icon = IconResource.Drawable(painterResource(R.drawable.history)), + actionType = ActionType.SWITCH, + isChecked = addingPlayedSongsToYtmHistory, + onCheckedChange = onAddingPlayedSongsToYtmHistoryChange, + shape = shapeManager() + ) + } + SettingsBox( + title = stringResource(R.string.audio_normalization), + icon = IconResource.Vector(Icons.AutoMirrored.Rounded.VolumeUp), + actionType = ActionType.SWITCH, + isChecked = audioNormalization, + onCheckedChange = onAudioNormalizationChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.play_song_when_bluetooth_device_connected), + icon = IconResource.Vector(Icons.Rounded.BluetoothConnected), + actionType = ActionType.SWITCH, + isChecked = autoPlaySongWhenBluetoothDeviceConnected, + onCheckedChange = onAutoPlaySongWhenBluetoothDeviceConnectedChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.stop_playing_when_song_sound_minimum_volume), + icon = IconResource.Vector(Icons.AutoMirrored.Rounded.VolumeOff), + actionType = ActionType.SWITCH, + isChecked = stopPlayingSongWhenMinimumVolume, + onCheckedChange = onStopPlayingSongWhenMinimumVolumeChange, + shape = shapeManager(isLast = true) + ) + + SettingCategory(title = stringResource(R.string.queue)) + SettingsBox( + title = stringResource(R.string.persistent_queue), + description = stringResource(R.string.persistent_queue_desc), + icon = IconResource.Drawable(painterResource(R.drawable.queue_music)), + actionType = ActionType.SWITCH, + isChecked = persistentQueue, + onCheckedChange = onPersistentQueueChange, + shape = shapeManager(isFirst = true) + ) + SettingsBox( + title = stringResource(R.string.auto_load_more), + description = stringResource(R.string.auto_load_more_desc), + icon = IconResource.Drawable(painterResource(R.drawable.playlist_add)), + actionType = ActionType.SWITCH, + isChecked = autoLoadMore, + onCheckedChange = onAutoLoadMoreChange, + shape = shapeManager() + ) + SettingsBox( + title = stringResource(R.string.auto_skip_next_on_error), + description = stringResource(R.string.auto_skip_next_on_error_desc), + icon = IconResource.Drawable(painterResource(R.drawable.skip_next)), + actionType = ActionType.SWITCH, + isChecked = autoSkipNextOnError, + onCheckedChange = onAutoSkipNextOnErrorChange, + shape = shapeManager(isLast = true) + ) + + SettingCategory(title = stringResource(R.string.advanced)) + SettingsBox( + title = stringResource(R.string.other_settings), + description = stringResource(R.string.other_settings_description), + icon = IconResource.Vector(Icons.Rounded.Tune), + shape = shapeManager(isBoth = true), + onClick = { + scope.launch { + showMiscSheet = true + } + } + ) + } + } + } +} + +@Composable +private fun AudioQualitySelectionDialog( + selectedValue: AudioQuality, + onValueSelected: (AudioQuality) -> Unit, + onDismissRequest: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(R.string.audio_quality)) }, + text = { + Column { + AudioQuality.values().forEach { quality -> + val isSelected = selectedValue == quality + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onValueSelected(quality) } + .padding(vertical = 8.dp) + ) { + RadioButton(selected = isSelected, onClick = { onValueSelected(quality) }) + Spacer(Modifier.width(16.dp)) + Column { + Text( + text = when (quality) { + AudioQuality.AUTO -> stringResource(R.string.audio_quality_auto) + AudioQuality.MAX -> stringResource(R.string.audio_quality_max) + AudioQuality.HIGH -> stringResource(R.string.audio_quality_high) + AudioQuality.LOW -> stringResource(R.string.audio_quality_low) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = when (quality) { + AudioQuality.AUTO -> stringResource(R.string.audio_quality_auto_desc) + AudioQuality.MAX -> stringResource(R.string.audio_quality_max_desc) + AudioQuality.HIGH -> stringResource(R.string.audio_quality_high_desc) + AudioQuality.LOW -> stringResource(R.string.audio_quality_low_desc) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PrivacySettings.kt new file mode 100644 index 0000000..23a20ca --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/PrivacySettings.kt @@ -0,0 +1,196 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.babelsoftware.loudly.LocalDatabase +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.DisableScreenshotKey +import com.babelsoftware.loudly.constants.PauseListenHistoryKey +import com.babelsoftware.loudly.constants.PauseSearchHistoryKey +import com.babelsoftware.loudly.ui.component.DefaultDialog +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.screens.settings.card_design.ActionType +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacySettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val database = LocalDatabase.current + val (pauseListenHistory, onPauseListenHistoryChange) = rememberPreference( + key = PauseListenHistoryKey, + defaultValue = false + ) + val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference( + key = PauseSearchHistoryKey, + defaultValue = false + ) + val (disableScreenshot, onDisableScreenshotChange) = rememberPreference( + key = DisableScreenshotKey, + defaultValue = false + ) + + var showClearListenHistoryDialog by remember { mutableStateOf(false) } + var showClearSearchHistoryDialog by remember { mutableStateOf(false) } + + if (showClearListenHistoryDialog) { + DefaultDialog( + onDismiss = { showClearListenHistoryDialog = false }, + title = { Text(stringResource(R.string.clear_listen_history)) }, + content = { + Text( + text = stringResource(R.string.clear_listen_history_confirm), + style = MaterialTheme.typography.bodyLarge + ) + }, + buttons = { + TextButton( + onClick = { showClearListenHistoryDialog = false } + ) { + Text(text = stringResource(R.string.cancel)) + } + + TextButton( + onClick = { + database.query { clearListenHistory() } + showClearListenHistoryDialog = false + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + if (showClearSearchHistoryDialog) { + DefaultDialog( + onDismiss = { showClearSearchHistoryDialog = false }, + title = { Text(stringResource(R.string.clear_search_history)) }, + content = { + Text( + text = stringResource(R.string.clear_search_history_confirm), + style = MaterialTheme.typography.bodyLarge + ) + }, + buttons = { + TextButton( + onClick = { showClearSearchHistoryDialog = false } + ) { + Text(text = stringResource(R.string.cancel)) + } + TextButton( + onClick = { + database.query { clearSearchHistory() } + showClearSearchHistoryDialog = false + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.privacy)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + SettingCategory(title = stringResource(R.string.listen_history)) + SettingsBox( + title = stringResource(R.string.pause_listen_history), + icon = IconResource.Drawable(painterResource(R.drawable.history)), + actionType = ActionType.SWITCH, + isChecked = pauseListenHistory, + onCheckedChange = onPauseListenHistoryChange, + shape = shapeManager(isFirst = true) + ) + SettingsBox( + title = stringResource(R.string.clear_listen_history), + icon = IconResource.Drawable(painterResource(R.drawable.delete_history)), + onClick = { showClearListenHistoryDialog = true }, + shape = shapeManager(isLast = true) + ) + + SettingCategory(title = stringResource(R.string.search_history)) + SettingsBox( + title = stringResource(R.string.pause_search_history), + icon = IconResource.Drawable(painterResource(R.drawable.search_off)), + actionType = ActionType.SWITCH, + isChecked = pauseSearchHistory, + onCheckedChange = onPauseSearchHistoryChange, + shape = shapeManager(isFirst = true) + ) + SettingsBox( + title = stringResource(R.string.clear_search_history), + icon = IconResource.Drawable(painterResource(R.drawable.clear_all)), + onClick = { showClearSearchHistoryDialog = true }, + shape = shapeManager(isLast = true) + ) + + SettingCategory(title = stringResource(R.string.misc)) + SettingsBox( + title = stringResource(R.string.disable_screenshot), + description = stringResource(R.string.disable_screenshot_desc), + icon = IconResource.Drawable(painterResource(R.drawable.screenshot)), + actionType = ActionType.SWITCH, + isChecked = disableScreenshot, + onCheckedChange = onDisableScreenshotChange, + shape = shapeManager(isBoth = true) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..ecc9e87 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,297 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.rememberAsyncImagePainter +import com.babelsoftware.innertube.utils.parseCookieString +import com.babelsoftware.loudly.LocalPlayerAwareWindowInsets +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.AccountNameKey +import com.babelsoftware.loudly.constants.HeaderCardAlignmentKey +import com.babelsoftware.loudly.constants.HeaderCardCornerRadiusKey +import com.babelsoftware.loudly.constants.HeaderCardGradientIntensityKey +import com.babelsoftware.loudly.constants.HeaderCardProfilePictureUriKey +import com.babelsoftware.loudly.constants.HeaderImageKey +import com.babelsoftware.loudly.constants.InnerTubeCookieKey +import com.babelsoftware.loudly.constants.ShowHeaderCardTextKey +import com.babelsoftware.loudly.ui.screens.settings.card_design.IconResource +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingCategory +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.screens.settings.card_design.shapeManager +import com.babelsoftware.loudly.ui.screens.settings.viewmodel.SettingsViewModel +import com.babelsoftware.loudly.utils.rememberEnumPreference +import com.babelsoftware.loudly.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + settingsViewModel: SettingsViewModel = hiltViewModel() +) { + val updateAvailable by settingsViewModel.updateAvailable + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(painterResource(R.drawable.arrow_back), stringResource(R.string.back)) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal)) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + ) { + AccountHeaderCard() + + SettingCategory(title = stringResource(R.string.category_interface)) + SettingsBox( + title = stringResource(R.string.appearance), + description = stringResource(R.string.appearance_description), + icon = IconResource.Drawable(painterResource(R.drawable.palette)), + shape = shapeManager(isBoth = true), + onClick = { navController.navigate("settings/appearance") } + ) + + SettingCategory(title = stringResource(R.string.category_content)) + SettingsBox( + title = stringResource(R.string.account), + description = stringResource(R.string.account_description), + icon = IconResource.Drawable(painterResource(R.drawable.person)), + shape = shapeManager(isFirst = true), + onClick = { navController.navigate("settings/account") } + ) + SettingsBox( + title = stringResource(R.string.content), + description = stringResource(R.string.content_description), + icon = IconResource.Drawable(painterResource(R.drawable.language)), + shape = shapeManager(isLast = true), + onClick = { navController.navigate("settings/content") } + ) + + SettingCategory(title = stringResource(R.string.category_player)) + SettingsBox( + title = stringResource(R.string.player_and_audio), + description = stringResource(R.string.player_and_audio_description), + icon = IconResource.Drawable(painterResource(R.drawable.play)), + shape = shapeManager(isBoth = true), + onClick = { navController.navigate("settings/player") } + ) + + SettingCategory(title = stringResource(R.string.category_system)) + SettingsBox( + title = stringResource(R.string.tips_and_features), + description = stringResource(R.string.tips_and_fetures_description), + icon = IconResource.Drawable(painterResource(R.drawable.tips)), + shape = shapeManager(isFirst = true), + onClick = { navController.navigate("settings/tips") } + ) + SettingsBox( + title = stringResource(R.string.ComradeNotification), + description = stringResource(R.string.comrade_notification_description), + icon = IconResource.Drawable(painterResource(R.drawable.ic_smart_hub)), + shape = shapeManager(), + onClick = { navController.navigate("settings/comradenotification") } + ) + SettingsBox( + title = stringResource(R.string.storage), + description = stringResource(R.string.storage_description), + icon = IconResource.Drawable(painterResource(R.drawable.storage)), + shape = shapeManager(), + onClick = { navController.navigate("settings/storage") } + ) + SettingsBox( + title = stringResource(R.string.privacy), + description = stringResource(R.string.privacy_description), + icon = IconResource.Drawable(painterResource(R.drawable.security)), + // Ortadaki eleman olduğu için şekil parametresi yok (düz köşeler) + shape = shapeManager(), + onClick = { navController.navigate("settings/privacy") } + ) + SettingsBox( + title = stringResource(R.string.backup_restore), + description = stringResource(R.string.backup_restore_description), + icon = IconResource.Drawable(painterResource(R.drawable.restore)), + shape = shapeManager(), + onClick = { navController.navigate("settings/backup_restore") } + ) + + // Hakkında öğesinin alt başlığını dinamik yap + val aboutSubtitle = if (updateAvailable) { + stringResource(R.string.new_version_available) + } else { + stringResource(R.string.about_subtitle) + } + SettingsBox( + title = stringResource(R.string.about), + description = aboutSubtitle, + icon = IconResource.Drawable(painterResource(R.drawable.info)), + // Grubun son öğesi + shape = shapeManager(isLast = true), + onClick = { navController.navigate("settings/about") } + ) + + // Alt boşluk + Spacer(Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Bottom))) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun AccountHeaderCard() { + val accountName by rememberPreference(AccountNameKey, "") + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } + val (headerImageKey, _) = rememberPreference(HeaderImageKey, "Loudly-1") + val (showHeaderCardText, _) = rememberPreference(ShowHeaderCardTextKey, true) + val (headerCardAlignment, _) = rememberEnumPreference(HeaderCardAlignmentKey, HeaderCardContentAlignment.BottomStart) + val (gradientIntensity, _) = rememberPreference(HeaderCardGradientIntensityKey, 0.6f) + val (headerCardCornerRadius, _) = rememberPreference(HeaderCardCornerRadiusKey, 24) + val (profilePictureUri, _) = rememberPreference(HeaderCardProfilePictureUriKey, "") + + val painter = when { + headerImageKey.startsWith("content://") -> { + rememberAsyncImagePainter(model = Uri.parse(headerImageKey)) + } + headerImageKey == "Loudly-1" -> painterResource(id = R.drawable.loudly_picutre_1) + headerImageKey == "Loudly-2" -> painterResource(id = R.drawable.loudly_picutre_2) + headerImageKey == "Loudly-3" -> painterResource(id = R.drawable.loudly_picutre_3) + headerImageKey == "Loudly-4" -> painterResource(id = R.drawable.loudly_picutre_4) + headerImageKey == "Loudly-5" -> painterResource(id = R.drawable.loudly_picutre_5) + headerImageKey == "Loudly-6" -> painterResource(id = R.drawable.loudly_picutre_6) + else -> painterResource(id = R.drawable.loudly_picutre_1) // default + } + + + Card( + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + shape = RoundedCornerShape(headerCardCornerRadius.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = headerCardAlignment.alignment + ) { + Image( + painter = painter, + contentDescription = "Header Background", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0.5f to Color.Transparent, + 1.0f to Color.Black.copy(alpha = gradientIntensity) + ) + ) + ) + ) + if (showHeaderCardText) { + Row( + modifier = Modifier.padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (profilePictureUri.isNotBlank()) { + Image( + painter = rememberAsyncImagePainter(model = Uri.parse(profilePictureUri)), + contentDescription = "Profile Image", + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + Column( + verticalArrangement = Arrangement.Bottom, + ) { + if (isLoggedIn) { + Text( + stringResource(R.string.Hi), + color = Color.White.copy(alpha = 0.8f), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + accountName.replace("@", ""), + color = Color.White, + fontSize = 22.sp, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } else { + Text( + stringResource(R.string.not_logged_in), + color = Color.White, + fontSize = 22.sp, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/StorageSettings.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/StorageSettings.kt new file mode 100644 index 0000000..0de1447 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/StorageSettings.kt @@ -0,0 +1,440 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import android.annotation.SuppressLint +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.FileDownload +import androidx.compose.material.icons.rounded.DeleteForever +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Translate +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavController +import coil.annotation.ExperimentalCoilApi +import coil.imageLoader +import com.babelsoftware.loudly.BuildConfig +import com.babelsoftware.loudly.LocalPlayerConnection +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.constants.MaxImageCacheSizeKey +import com.babelsoftware.loudly.constants.MaxSongCacheSizeKey +import com.babelsoftware.loudly.extensions.tryOrNull +import com.babelsoftware.loudly.ui.component.IconButton +import com.babelsoftware.loudly.ui.screens.settings.card_design.SettingsBox +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.ui.utils.formatFileSize +import com.babelsoftware.loudly.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("PrivateResource") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoilApi::class) +@Composable +fun StorageSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val context = LocalContext.current + val imageDiskCache = remember { context.imageLoader.diskCache } + val playerConnection = LocalPlayerConnection.current + val playerCache = remember { playerConnection?.service?.playerCache } + val downloadCache = remember { playerConnection?.service?.downloadCache } + val coroutineScope = rememberCoroutineScope() + val (maxImageCacheSize, onMaxImageCacheSizeChange) = rememberPreference(key = MaxImageCacheSizeKey, defaultValue = 512) + val (maxSongCacheSize, onMaxSongCacheSizeChange) = rememberPreference(key = MaxSongCacheSizeKey, defaultValue = 1024) + var imageCacheSize by remember { mutableLongStateOf(0L) } + var playerCacheSize by remember { mutableLongStateOf(0L) } + var downloadCacheSize by remember { mutableLongStateOf(0L) } + var translationModelsSize by remember { mutableLongStateOf(0L) } + + val refreshCacheSizes: () -> Unit = { + coroutineScope.launch(Dispatchers.IO) { + imageCacheSize = imageDiskCache?.size ?: 0L + playerCacheSize = tryOrNull { playerCache?.cacheSpace } ?: 0L + downloadCacheSize = tryOrNull { downloadCache?.cacheSpace } ?: 0L + translationModelsSize = 123 * 1024 * 1024L + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + refreshCacheSizes() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + var showClearDownloadsDialog by remember { mutableStateOf(false) } + var showClearSongCacheDialog by remember { mutableStateOf(false) } + var showClearImagesCacheDialog by remember { mutableStateOf(false) } + var showClearTranslationModelsDialog by remember { mutableStateOf(false) } + var showSelectImageCacheSizeDialog by remember { mutableStateOf(false) } + var showSelectSongCacheSizeDialog by remember { mutableStateOf(false) } + + if (showClearDownloadsDialog) { + ConfirmationDialog( + icon = Icons.Outlined.DeleteForever, + title = stringResource(R.string.clear_all_downloads), + text = stringResource(R.string.clear_all_downloads_confirmation), + onConfirm = { + coroutineScope.launch(Dispatchers.IO) { + downloadCache?.keys?.forEach { downloadCache.removeResource(it) } + refreshCacheSizes() + } + }, + onDismiss = { showClearDownloadsDialog = false } + ) + } + + if (showSelectSongCacheSizeDialog) { + CacheSizeSelectionDialog( + title = stringResource(R.string.max_song_cache_size), + onDismiss = { showSelectSongCacheSizeDialog = false }, + onSelect = onMaxSongCacheSizeChange + ) + } + if (showSelectImageCacheSizeDialog) { + CacheSizeSelectionDialog( + title = stringResource(R.string.max_image_cache_size), + onDismiss = { showSelectImageCacheSizeDialog = false }, + onSelect = onMaxImageCacheSizeChange + ) + } + + + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(R.string.storage)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp, onLongClick = navController::backToMain) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + val totalUsed = downloadCacheSize + playerCacheSize + imageCacheSize + translationModelsSize + val vibrantColors = listOf( + Color(0xFF6C63FF), Color(0xFFFF6584), Color(0xFF36C1C1), Color(0xFFFFBD4A) + ) + val storageItems = listOf( + StorageItemData(stringResource(R.string.downloaded_songs), downloadCacheSize, vibrantColors[0]), + StorageItemData(stringResource(R.string.song_cache), playerCacheSize, vibrantColors[1]), + StorageItemData(stringResource(R.string.image_cache), imageCacheSize, vibrantColors[2]), + StorageItemData(stringResource(R.string.translation_models), translationModelsSize, vibrantColors[3]) + ) + StorageOverview(totalUsed = totalUsed, items = storageItems.filter { it.size > 0 }) + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + val vibrantColors = listOf( + Color(0xFF6C63FF), Color(0xFFFF6584), Color(0xFF36C1C1), Color(0xFFFFBD4A) + ) + val storageItems = listOf( + StorageItemData(stringResource(R.string.downloaded_songs), downloadCacheSize, vibrantColors[0]), + StorageItemData(stringResource(R.string.song_cache), playerCacheSize, vibrantColors[1]), + StorageItemData(stringResource(R.string.image_cache), imageCacheSize, vibrantColors[2]), + StorageItemData(stringResource(R.string.translation_models), translationModelsSize, vibrantColors[3]) + ).filter { it.size > 0 } + + storageItems.forEach { item -> + StorageLegend(color = item.color, text = item.label, size = item.size) + } + } + } + + item { + Text( + text = stringResource(R.string.caches), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp, start = 4.dp) + ) + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + CacheControlBox( + icon = Icons.Rounded.MusicNote, + title = stringResource(R.string.song_cache), + usedSpace = playerCacheSize, + maxSize = maxSongCacheSize, + onMaxSizeClick = { showSelectSongCacheSizeDialog = true }, + onClearClick = { showClearSongCacheDialog = true } + ) + + CacheControlBox( + icon = Icons.Rounded.Image, + title = stringResource(R.string.image_cache), + usedSpace = imageCacheSize, + maxSize = maxImageCacheSize, + onMaxSizeClick = { showSelectImageCacheSizeDialog = true }, + onClearClick = { showClearImagesCacheDialog = true } + ) + + CacheControlBox( + icon = Icons.Outlined.FileDownload, + title = stringResource(R.string.downloaded_songs), + usedSpace = downloadCacheSize, + onClearClick = { showClearDownloadsDialog = true } + ) + + if (BuildConfig.FLAVOR != "foss") { + CacheControlBox( + icon = Icons.Rounded.Translate, + title = stringResource(R.string.translation_models), + usedSpace = translationModelsSize, + onClearClick = { showClearTranslationModelsDialog = true } + ) + } + } + } + } + } +} + +/** + * Advanced settings card containing the title, field used, maximum size setting, and delete button. + */ +@Composable +private fun CacheControlBox( + icon: ImageVector, + title: String, + usedSpace: Long, + maxSize: Int? = null, + onMaxSizeClick: (() -> Unit)? = null, + onClearClick: () -> Unit +) { + SettingsBox( + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // Icon + Box( + modifier = Modifier + .size(40.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = title, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + + // Title and Field Used + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.currently_used_formatted, formatFileSize(usedSpace)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Actions (Max Size and Delete) + if (onMaxSizeClick != null && maxSize != null) { + TextButton(onClick = onMaxSizeClick) { + Text(formatCacheSizeLabel(maxSize)) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + IconButton(onClick = onClearClick) { + Icon( + imageVector = Icons.Rounded.DeleteForever, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + ) +} + +@Composable +private fun formatCacheSizeLabel(size: Int): String { + return when (size) { + 0 -> stringResource(R.string.off) + -1 -> stringResource(R.string.unlimited) + else -> formatFileSize(size * 1024 * 1024L) + } +} + +@Composable +private fun CacheSizeSelectionDialog( + title: String, + onDismiss: () -> Unit, + onSelect: (Int) -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + LazyColumn { + items(listOf(0, 128, 256, 512, 1024, 2048, 4096, 8192, -1).count()) { index -> + val size = listOf(0, 128, 256, 512, 1024, 2048, 4096, 8192, -1)[index] + TextButton( + onClick = { onSelect(size); onDismiss() }, + modifier = Modifier.fillMaxWidth() + ) { + Text(formatCacheSizeLabel(size), modifier = Modifier.fillMaxWidth()) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@Composable +private fun StorageOverview( + totalUsed: Long, + items: List +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center + ) { + val totalForProgress = items.sumOf { it.size }.toFloat().coerceAtLeast(1f) + + val animatedProgress = items.map { + animateFloatAsState(targetValue = it.size / totalForProgress, label = "${it.label}Progress").value + } + + Canvas(modifier = Modifier.fillMaxSize()) { + var startAngle = -90f + animatedProgress.forEachIndexed { index, progress -> + val sweepAngle = progress * 360f + if (sweepAngle > 0.1f) { + drawArc( + color = items[index].color, + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = false, + style = Stroke(width = 32.dp.toPx(), cap = StrokeCap.Butt) + ) + } + startAngle += sweepAngle + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.total_used_space), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatFileSize(totalUsed), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +data class StorageItemData(val label: String, val size: Long, val color: Color) + +@Composable +private fun StorageLegend(color: Color, text: String, size: Long) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .size(12.dp) + .background(color, CircleShape) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(text = formatFileSize(size), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun ConfirmationDialog( + icon: ImageVector, + title: String, + text: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(icon, null, tint = MaterialTheme.colorScheme.error) }, + title = { Text(title) }, + text = { Text(text) }, + confirmButton = { + TextButton( + onClick = { + onConfirm() + onDismiss() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/TipsScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/TipsScreen.kt new file mode 100644 index 0000000..427f41a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/TipsScreen.kt @@ -0,0 +1,40 @@ +package com.babelsoftware.loudly.ui.screens.settings + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.babelsoftware.loudly.ui.screens.guides.* +import com.babelsoftware.loudly.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TipsScreen( + navController: NavController +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.tips_and_features)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Geri") + } + } + ) + } + ) { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues)) { + item { StoryShareFeatureStep() } + item { SettingsHeaderFeatureStep() } + item { PlayerPlaylistFeatureStep() } + item { ClassicWidgetFeatureStep() } + item { NewSettingsUiFeatureStep() } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsBox.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsBox.kt new file mode 100644 index 0000000..93a4468 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsBox.kt @@ -0,0 +1,151 @@ +package com.babelsoftware.loudly.ui.screens.settings.card_design + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +sealed class IconResource { + data class Drawable(val painter: Painter) : IconResource() + data class Vector(val imageVector: androidx.compose.ui.graphics.vector.ImageVector) : IconResource() +} + +enum class ActionType { + NAVIGATION, + TEXT, + SWITCH +} + +@Composable +fun SettingsBox( + modifier: Modifier = Modifier, + title: String? = null, + description: String? = null, + icon: IconResource? = null, + actionType: ActionType = ActionType.NAVIGATION, + actionText: String = "", + shape: Shape = RoundedCornerShape(24.dp), + isChecked: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {}, + onClick: (() -> Unit)? = null, + content: @Composable (() -> Unit)? = null +) { + val finalOnClick = onClick ?: { + if (actionType == ActionType.SWITCH) { + onCheckedChange(!isChecked) + } + } + + Surface( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .then(if (onClick != null || actionType == ActionType.SWITCH) Modifier.clickable(onClick = finalOnClick) else Modifier), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 1.dp + ) { + if (content != null) { + content() + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp) + ) { + if (icon != null) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center + ) { + when (icon) { + is IconResource.Drawable -> Icon( + painter = icon.painter, + contentDescription = title, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + is IconResource.Vector -> Icon( + imageVector = icon.imageVector, + contentDescription = title, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + if (title != null) { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + if (!description.isNullOrBlank()) { + if (title != null) Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + when (actionType) { + ActionType.NAVIGATION -> { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForwardIos, + contentDescription = null, + modifier = Modifier.scale(0.8f), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ActionType.TEXT -> { + Text( + text = actionText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(start = 8.dp) + ) + } + ActionType.SWITCH -> { + val iconVector = if (isChecked) Icons.Default.Check else Icons.Default.Close + val iconSize = if (isChecked) 18.dp else 12.dp + + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange, + modifier = Modifier.scale(0.9f), + thumbContent = { + Icon( + imageVector = iconVector, + contentDescription = null, + modifier = Modifier.size(iconSize) + ) + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsGroup.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsGroup.kt new file mode 100644 index 0000000..7697ac0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/card_design/SettingsGroup.kt @@ -0,0 +1,37 @@ +package com.babelsoftware.loudly.ui.screens.settings.card_design + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun SettingCategory(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp) + ) +} + +@Composable +fun shapeManager( + isFirst: Boolean = false, + isLast: Boolean = false, + isBoth: Boolean = false, + radius: Int = 24 +): Shape { + return when { + isBoth -> RoundedCornerShape(radius.dp) + isFirst -> RoundedCornerShape(topStart = radius.dp, topEnd = radius.dp) + isLast -> RoundedCornerShape(bottomStart = radius.dp, bottomEnd = radius.dp) + else -> RoundedCornerShape(0.dp) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/ImportFromSpotifyScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/ImportFromSpotifyScreen.kt new file mode 100644 index 0000000..9b61a4b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/ImportFromSpotifyScreen.kt @@ -0,0 +1,713 @@ +@file:Suppress("DEPRECATION") + +package com.babelsoftware.loudly.ui.screens.settings.import_from_spotify + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.model.Playlist +import com.babelsoftware.loudly.ui.utils.backToMain +import com.babelsoftware.loudly.viewmodels.ImportFromSpotifyViewModel +import com.babelsoftware.loudly.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImportFromSpotifyScreen( + navController: NavController, scrollBehavior: TopAppBarScrollBehavior +) { + val importFromSpotifyViewModel: ImportFromSpotifyViewModel = hiltViewModel() + val importFromSpotifyScreenState = importFromSpotifyViewModel.importFromSpotifyScreenState + val userPlaylists = importFromSpotifyViewModel.importFromSpotifyScreenState.value.playlists + val spotifyClientId = rememberSaveable { + mutableStateOf("") + } + val spotifyClientSecret = rememberSaveable { + mutableStateOf("") + } + val spotifyAuthorizationCode = rememberSaveable { + mutableStateOf("") + } + val textFieldPaddingValues = remember { + PaddingValues(start = 15.dp, end = 15.dp, top = 7.5.dp, bottom = 7.5.dp) + } + val localClipBoardManager = LocalClipboardManager.current + val context = LocalContext.current + val localUriHandler = LocalUriHandler.current + val lazyListState = rememberLazyListState() + val isLikedSongsDestinationDialogShown = rememberSaveable { + mutableStateOf(false) + } + val saveToDefaultLikedSongs: MutableState = rememberSaveable { + mutableStateOf(null) + } + val logsListState = rememberLazyListState() + val selectAll = rememberSaveable { + mutableStateOf(false) + } + LaunchedEffect( + lazyListState.canScrollForward, importFromSpotifyScreenState.value.isRequesting + ) { + if (importFromSpotifyScreenState.value.isObtainingAccessTokenSuccessful && lazyListState.canScrollForward.not() && importFromSpotifyScreenState.value.reachedEndForPlaylistPagination.not() && importFromSpotifyScreenState.value.isRequesting.not()) { + importFromSpotifyViewModel.retrieveNextPageOfPlaylists(context) + } + } + Box( + modifier = Modifier + .fillMaxSize() + ) { + if (importFromSpotifyScreenState.value.accessToken.isNotBlank() && importFromSpotifyScreenState.value.isObtainingAccessTokenSuccessful) { + Column( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopCenter) + ) { + Spacer(Modifier.windowInsetsPadding(WindowInsets.statusBars)) + Text( + text = "Logged in as ${importFromSpotifyScreenState.value.userName}. Found ${importFromSpotifyScreenState.value.totalPlaylistsCount} playlists.\n\nNow, select the items you want to import:", + fontSize = 16.sp, + modifier = Modifier.padding(start = 15.dp, bottom = 7.5.dp) + ) + if (selectAll.value.not()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + if (importFromSpotifyViewModel.selectedAllPlaylists.value) { + importFromSpotifyViewModel.selectedAllPlaylists.value = false + importFromSpotifyViewModel.selectedPlaylists.clear() + importFromSpotifyViewModel.isLikedSongsSelectedForImport.value = + false + return@clickable + } + selectAll.value = selectAll.value.not() + importFromSpotifyViewModel.selectAllPlaylists( + context, onCompletion = { + selectAll.value = false + }) + } + .animateContentSize()) { + Checkbox( + checked = importFromSpotifyViewModel.selectedAllPlaylists.value, + onCheckedChange = { + selectAll.value = it + }) + Spacer(Modifier.width(5.dp)) + Text(text = stringResource(R.string.select_all)) + } + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(start = 15.dp, end = 15.dp, top = 7.5.dp, bottom = 7.5.dp) + ) + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + userScrollEnabled = selectAll.value.not() + ) { + item { + Row(modifier = Modifier + .fillMaxWidth() + .clickable { + if (selectAll.value) { + return@clickable + } + importFromSpotifyViewModel.isLikedSongsSelectedForImport.value = + importFromSpotifyViewModel.isLikedSongsSelectedForImport.value.not() + } + .padding(start = 15.dp, end = 15.dp, top = 7.5.dp, bottom = 7.5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(50.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + } + Spacer(Modifier.width(15.dp)) + Text( + text = stringResource(R.string.liked_songs), modifier = Modifier.fillMaxWidth(0.75f) + ) + } + Checkbox( + checked = importFromSpotifyViewModel.isLikedSongsSelectedForImport.value, + onCheckedChange = { + if (selectAll.value) { + return@Checkbox + } + importFromSpotifyViewModel.isLikedSongsSelectedForImport.value = + importFromSpotifyViewModel.isLikedSongsSelectedForImport.value.not() + }) + } + } + items(userPlaylists) { playlist -> + Row(modifier = Modifier + .fillMaxWidth() + .clickable { + if (selectAll.value) { + return@clickable + } + if (importFromSpotifyViewModel.selectedPlaylists.map { it.id } + .contains( + playlist.playlistId + ).not()) { + importFromSpotifyViewModel.selectedPlaylists.add( + Playlist( + name = playlist.playlistName, id = playlist.playlistId + ) + ) + } else { + importFromSpotifyViewModel.selectedPlaylists.removeIf { + it.id == playlist.playlistId + } + } + } + .padding(start = 15.dp, end = 15.dp, top = 7.5.dp, bottom = 7.5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(0.75f) + ) { + AsyncImage( + model = playlist.images.first().url, + contentDescription = null, + modifier = Modifier + .size(50.dp) + .clip( + RoundedCornerShape(10.dp) + ) + ) + Spacer(Modifier.width(15.dp)) + Text( + text = playlist.playlistName + ) + } + Checkbox(checked = importFromSpotifyViewModel.selectedPlaylists.map { it.id } + .contains( + playlist.playlistId + ), onCheckedChange = { + if (selectAll.value) { + return@Checkbox + } + if (importFromSpotifyViewModel.selectedPlaylists.map { it.id } + .contains( + playlist.playlistId + ).not()) { + importFromSpotifyViewModel.selectedPlaylists.add( + Playlist( + name = playlist.playlistName, id = playlist.playlistId + ) + ) + } else { + importFromSpotifyViewModel.selectedPlaylists.removeIf { + it.id == playlist.playlistId + } + } + }) + } + } + if (importFromSpotifyScreenState.value.isRequesting && importFromSpotifyScreenState.value.reachedEndForPlaylistPagination.not()) { + item { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp) + ) + } + } + item { + Spacer(Modifier.height(100.dp)) + } + } + } + BottomAppBar( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + Button( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp), onClick = { + if (selectAll.value) { + return@Button + } + if (importFromSpotifyViewModel.isLikedSongsSelectedForImport.value && saveToDefaultLikedSongs.value == null) { + isLikedSongsDestinationDialogShown.value = true + } else { + importFromSpotifyViewModel.importSelectedItems( + saveToDefaultLikedSongs.value, context + ) + } + }) { + if (selectAll.value) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + color = ButtonDefaults.buttonColors().contentColor, + modifier = Modifier.size(18.dp), + strokeWidth = 2.5.dp + ) + Spacer(Modifier.width(10.dp)) + Text(text = stringResource(R.string.fetching_all_playlists)) + } + } else { + Text(text = stringResource(R.string.import_selected_items)) + } + } + } + return@Box + } + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .imePadding() + .verticalScroll(rememberScrollState()) + .animateContentSize() + .align(Alignment.BottomCenter) + .animateContentSize() + ) { + Spacer(Modifier.windowInsetsPadding(WindowInsets.statusBars)) + val isInstructionExpanded = rememberSaveable { + mutableStateOf(false) + } + Row(modifier = Modifier + .fillMaxWidth() + .clickable { + isInstructionExpanded.value = isInstructionExpanded.value.not() + } + .padding(top = 7.5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = stringResource(R.string.instructions_to_get_required_credentials), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(start = 15.dp, bottom = 7.5.dp) + .fillMaxWidth(0.7f) + ) + IconButton(onClick = { + isInstructionExpanded.value = isInstructionExpanded.value.not() + }) { + Icon( + contentDescription = null, + imageVector = if (isInstructionExpanded.value) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore + ) + } + } + if (isInstructionExpanded.value) { + Column(modifier = Modifier.fillMaxWidth()) { + val instructionPadding = remember { + PaddingValues(start = 15.dp, bottom = 7.5.dp, end = 7.5.dp) + } + val firstInstruction = remember { + buildAnnotatedString { + pushStringAnnotation( + tag = "spotify for developers", + annotation = "https://developer.spotify.com/dashboard/" + ) + } + } + Text( + fontSize = 16.sp, + text = stringResource(R.string.visit_spotify_for_developers), + modifier = Modifier.padding(instructionPadding) + ) + ClickableText( + text = firstInstruction, + onClick = { offset -> + firstInstruction.getStringAnnotations( + tag = "spotify for developers", start = offset, end = offset + ).first().let { + localUriHandler.openUri(it.item) + } + }, + style = TextStyle(fontSize = 16.sp, color = LocalContentColor.current), + modifier = Modifier.padding(instructionPadding) + ) + SelectionContainer { + Text( + fontSize = 16.sp, + text = stringResource(R.string.enter_the_necessary_details), + modifier = Modifier.padding(instructionPadding) + ) + } + Text( + fontSize = 16.sp, + text = stringResource(R.string.make_sure_to_click), + modifier = Modifier.padding(instructionPadding) + ) + Text( + fontSize = 16.sp, + text = stringResource(R.string.toggle_wep_api), + modifier = Modifier.padding(instructionPadding) + ) + + Text( + fontSize = 16.sp, + text = stringResource(R.string.accept_the_terms_of_service), + modifier = Modifier.padding(instructionPadding) + ) + Text( + fontSize = 16.sp, + modifier = Modifier.padding(instructionPadding), + text = stringResource(R.string.you_ll_be_redirected_to_a_new_page) + ) + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(start = 15.dp, end = 15.dp, bottom = 7.5.dp), + color = LocalContentColor.current.copy(0.1f) + ) + Text( + fontSize = 16.sp, + modifier = Modifier.padding(instructionPadding), + text = stringResource(R.string.now_click_the_authorize_and_continue) + ) + } + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(start = 15.dp, end = 15.dp, bottom = 7.5.dp) + ) + if (importFromSpotifyScreenState.value.error) { + val isStackTraceVisible = rememberSaveable { + mutableStateOf(false) + } + Text( + text = importFromSpotifyScreenState.value.exception?.message + ?: stringResource(R.string.well_thats_embarrassing), + modifier = Modifier.padding(start = 15.dp, end = 15.dp), + color = MaterialTheme.colorScheme.error + ) + Row(modifier = Modifier + .fillMaxWidth() + .clickable { + isStackTraceVisible.value = isStackTraceVisible.value.not() + } + .padding(start = 15.dp, end = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = "Stacktrace", fontWeight = FontWeight.Bold) + importFromSpotifyScreenState.value.exception?.stackTrace?.joinToString() + ?.let { + IconButton(onClick = { + localClipBoardManager.setText(AnnotatedString(text = it)) + }) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = null + ) + } + } + } + IconButton(onClick = { + isStackTraceVisible.value = isStackTraceVisible.value.not() + }) { + Icon( + imageVector = if (isStackTraceVisible.value) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = null + ) + } + } + if (isStackTraceVisible.value) { + Text( + text = importFromSpotifyScreenState.value.exception?.stackTrace?.joinToString() + ?: stringResource(R.string.something_went_wrong), + modifier = Modifier.padding(start = 15.dp, end = 15.dp) + ) + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp) + ) + } + Text( + text = stringResource(R.string.login_with_spotify), + fontSize = 16.sp, + modifier = Modifier.padding(start = 15.dp, bottom = 7.5.dp), + fontWeight = FontWeight.SemiBold + ) + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(textFieldPaddingValues), + value = spotifyClientId.value, + onValueChange = { + spotifyClientId.value = it + }, + label = { + Text(text = stringResource(R.string.client_id)) + }, + readOnly = importFromSpotifyScreenState.value.isRequesting + ) + Button( + onClick = { + val encodedUrl = "https://accounts.spotify.com/authorize?client_id=${spotifyClientId.value}&response_type=code&redirect_uri=http://127.0.0.1:45454&scope=user-library-read%20playlist-read-private" + localUriHandler.openUri(encodedUrl) + }, modifier = Modifier + .fillMaxWidth() + .padding(start = 15.dp, end = 15.dp) + ) { + Text(text = stringResource(R.string.authorize_and_continue)) + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(start = 15.dp, end = 15.dp, top = 7.5.dp, bottom = 7.5.dp) + ) + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(textFieldPaddingValues), + value = spotifyClientSecret.value, + onValueChange = { + spotifyClientSecret.value = it + }, + label = { + Text(text = stringResource(R.string.client_secret)) + }, + readOnly = importFromSpotifyScreenState.value.isRequesting + ) + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(textFieldPaddingValues), + value = spotifyAuthorizationCode.value, + onValueChange = { + spotifyAuthorizationCode.value = + it.substringAfter("http://127.0.0.1:45454/?code=").trim() + }, + label = { + Text(text = stringResource(R.string.authorization_code)) + }, + readOnly = importFromSpotifyScreenState.value.isRequesting + ) + if (importFromSpotifyScreenState.value.isRequesting.not()) { + Button( + onClick = { + importFromSpotifyViewModel.spotifyLoginAndFetchPlaylists( + clientId = spotifyClientId.value.trim(), + clientSecret = spotifyClientSecret.value.trim(), + authorizationCode = spotifyAuthorizationCode.value.trim(), + context + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 15.dp, bottom = 15.dp, end = 15.dp) + ) { + Text(text = stringResource(R.string.authenticate)) + } + } else { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(start = 15.dp, bottom = 30.dp, end = 15.dp, top = 7.5.dp) + ) + } + } + } + if (importFromSpotifyViewModel.isImportingInProgress.value) { + Scaffold(topBar = { + Column(modifier = Modifier + .clickable { } + .fillMaxWidth() + .padding(15.dp) + .windowInsetsPadding(WindowInsets.statusBars)) { + Text(stringResource(R.string.import_in_progress), fontWeight = FontWeight.SemiBold, fontSize = 18.sp) + Spacer(Modifier.height(5.dp)) + Text( + stringResource(R.string.dont_close_app_message), + fontSize = 14.sp + ) + Spacer(Modifier.height(5.dp)) + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + } + }) { it -> + Box(modifier = Modifier + .padding(it) + .clickable { } + .fillMaxSize() + .padding(start = 15.dp, end = 15.dp, bottom = 15.dp), + contentAlignment = Alignment.BottomCenter) { + LazyColumn( + userScrollEnabled = false, + modifier = Modifier.fillMaxSize(), + state = logsListState + ) { + items(importFromSpotifyViewModel.importLogs) { + Text(text = it) + } + } + } + } + } + if (isLikedSongsDestinationDialogShown.value) { + BasicAlertDialog( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp) + .clip( + RoundedCornerShape(15.dp) + ) + .background(AlertDialogDefaults.containerColor), onDismissRequest = { + isLikedSongsDestinationDialogShown.value = false + }) { + Column(modifier = Modifier.padding(15.dp)) { + Text( + text = stringResource(R.string.choose_liked_songs), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.height(5.dp)) + Text(text = stringResource(R.string.where_should_the_liked_songs)) + Spacer(Modifier.height(15.dp)) + Button(onClick = { + saveToDefaultLikedSongs.value = false + importFromSpotifyViewModel.importSelectedItems( + saveToDefaultLikedSongs.value, context + ) + isLikedSongsDestinationDialogShown.value = false + }, modifier = Modifier.fillMaxWidth()) { + Text(text = stringResource(R.string.new_playlist_named)) + } + Spacer(Modifier.height(5.dp)) + Button(onClick = { + saveToDefaultLikedSongs.value = true + importFromSpotifyViewModel.importSelectedItems( + saveToDefaultLikedSongs.value, context + ) + isLikedSongsDestinationDialogShown.value = false + }, modifier = Modifier.fillMaxWidth()) { + Text(text = stringResource(R.string.default_liked_songs)) + } + } + } + } + TopAppBar( + title = { Text(stringResource(R.string.import_from_spotify)) }, + navigationIcon = { + com.babelsoftware.loudly.ui.component.IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + LaunchedEffect(importFromSpotifyViewModel.isImportingCompleted.value) { + if (importFromSpotifyViewModel.isImportingCompleted.value) { + Toast.makeText(context, "Import Succeeded!", Toast.LENGTH_LONG).show() + navController.navigateUp() + } + } + BackHandler { + if (importFromSpotifyViewModel.isImportingInProgress.value) { + Toast.makeText( + context, + "Don't close the app or go back. This operation doesn't run in the background, so stay put until it's done!", + Toast.LENGTH_SHORT + ).show() + } else { + navController.navigateUp() + } + } + LaunchedEffect(logsListState.canScrollForward) { + if (logsListState.canScrollForward) { + logsListState.animateScrollToItem(logsListState.layoutInfo.totalItemsCount - 1) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportFromSpotifyScreenState.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportFromSpotifyScreenState.kt new file mode 100644 index 0000000..25ecdb5 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportFromSpotifyScreenState.kt @@ -0,0 +1,15 @@ +package com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.model + +import com.babelsoftware.loudly.models.spotify.playlists.SpotifyPlaylistItem + +data class ImportFromSpotifyScreenState( + val isRequesting: Boolean, + val accessToken: String, + val error: Boolean, + val exception: Exception?, + val userName:String, + val isObtainingAccessTokenSuccessful:Boolean, + val playlists:List, + val totalPlaylistsCount:Int, + val reachedEndForPlaylistPagination:Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportProgressEvent.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportProgressEvent.kt new file mode 100644 index 0000000..6960df4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/ImportProgressEvent.kt @@ -0,0 +1,14 @@ +package com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.model + +sealed class ImportProgressEvent { + data class LikedSongsProgress(val completed: Boolean, val currentCount: Int,val totalTracksCount: Int) : + ImportProgressEvent() + + data class PlaylistsProgress( + val completed: Boolean, + val playlistName: String, + val progressedTrackCount: Int, + val totalTracksCount: Int, + val currentPlaylistIndex: Int + ) : ImportProgressEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/Playlist.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/Playlist.kt new file mode 100644 index 0000000..b1eeea0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/import_from_spotify/model/Playlist.kt @@ -0,0 +1,6 @@ +package com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.model + +data class Playlist( + val name: String, + val id: String +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/AppUpdateViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/viewmodel/AppUpdateViewModel.kt similarity index 61% rename from app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/AppUpdateViewModel.kt rename to app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/viewmodel/AppUpdateViewModel.kt index 3d09c7e..f889477 100644 --- a/app/src/main/java/com/babelsoftware/airnote/presentation/screens/settings/model/AppUpdateViewModel.kt +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/viewmodel/AppUpdateViewModel.kt @@ -1,25 +1,21 @@ -package com.babelsoftware.airnote.presentation.screens.settings.model +package com.babelsoftware.loudly.ui.screens.settings.viewmodel import android.app.Application import android.net.Uri import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.babelsoftware.airnote.data.provider.StringProvider +import com.babelsoftware.loudly.BuildConfig import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File import java.io.FileOutputStream import java.net.URL import javax.inject.Inject -// A sealed class representing different states of the update process sealed class UpdateState { object Idle : UpdateState() data class Downloading(val progress: Int) : UpdateState() @@ -29,64 +25,48 @@ sealed class UpdateState { @HiltViewModel class AppUpdateViewModel @Inject constructor( - private val stringProvider: StringProvider, private val app: Application ) : ViewModel() { private val _updateState = MutableStateFlow(UpdateState.Idle) val updateState = _updateState.asStateFlow() - private var downloadJob: Job? = null fun downloadAndInstallApk(downloadUrl: String) { - downloadJob?.cancel() - downloadJob = viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(Dispatchers.IO) { try { _updateState.value = UpdateState.Downloading(0) val url = URL(downloadUrl) val connection = url.openConnection() connection.connect() + val fileSize = connection.contentLength val inputStream = connection.getInputStream() val file = File(app.cacheDir, "update.apk") val outputStream = FileOutputStream(file) + var total: Long = 0 - val data = ByteArray(1024 * 4) + val data = ByteArray(1024) var count: Int - while (inputStream.read(data).also { count = it } != -1) { - if (!isActive) { - outputStream.close() - inputStream.close() - file.delete() - throw CancellationException("Download cancelled by user.") - } + while (inputStream.read(data).also { count = it } != -1) { total += count.toLong() outputStream.write(data, 0, count) - if (fileSize > 0) { - val progress = (total * 100 / fileSize).toInt() - _updateState.value = UpdateState.Downloading(progress) - } + val progress = (total * 100 / fileSize).toInt() + _updateState.value = UpdateState.Downloading(progress) } + outputStream.flush() outputStream.close() inputStream.close() - val authority = "${app.packageName}.provider" + val authority = "${BuildConfig.APPLICATION_ID}.FileProvider" val apkUri = FileProvider.getUriForFile(app, authority, file) _updateState.value = UpdateState.ReadyToInstall(apkUri) - } catch (e: CancellationException) { - _updateState.value = UpdateState.Idle - println("İndirme kullanıcı tarafından iptal edildi.") } catch (e: Exception) { e.printStackTrace() _updateState.value = UpdateState.Failed("Güncelleme indirilirken bir hata oluştu: ${e.message}") } } } - fun resetState() { - downloadJob?.cancel() - downloadJob = null - _updateState.value = UpdateState.Idle - } } \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..ddd95b4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/settings/viewmodel/SettingsViewModel.kt @@ -0,0 +1,29 @@ +package com.babelsoftware.loudly.ui.screens.settings.viewmodel + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.BuildConfig +import com.babelsoftware.loudly.utils.getLatestReleaseInfo +import com.babelsoftware.loudly.utils.isNewerVersion +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor() : ViewModel() { + val updateAvailable = mutableStateOf(false) + + init { + checkForUpdates() + } + + private fun checkForUpdates() { + viewModelScope.launch { + val releaseInfo = getLatestReleaseInfo() + if (releaseInfo != null) { + updateAvailable.value = isNewerVersion(releaseInfo.tagName, BuildConfig.VERSION_NAME) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/screens/share/SocialShareScreen.kt b/app/src/main/java/com/babelsoftware/loudly/ui/screens/share/SocialShareScreen.kt new file mode 100644 index 0000000..5b9b6b8 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/screens/share/SocialShareScreen.kt @@ -0,0 +1,420 @@ +package com.babelsoftware.loudly.ui.screens.share + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.FileProvider +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.view.drawToBitmap +import androidx.palette.graphics.Palette +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.models.MediaMetadata +import com.babelsoftware.loudly.ui.component.ChipStyleButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +class CaptureController { + var capture: (() -> Unit)? = null +} + +@Composable +fun rememberCaptureController(): CaptureController { + return remember { CaptureController() } +} + +@Composable +fun Capturable( + controller: CaptureController, + onBitmapCaptured: (Bitmap) -> Unit, + content: @Composable () -> Unit +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var viewToCapture: View? by remember { mutableStateOf(null) } + + controller.capture = { + viewToCapture?.let { view -> + coroutineScope.launch { + view.isDrawingCacheEnabled = true + view.drawingCacheQuality = View.DRAWING_CACHE_QUALITY_HIGH + val cachedBitmap = view.drawingCache + val bitmap = Bitmap.createBitmap(cachedBitmap) + view.isDrawingCacheEnabled = false + + onBitmapCaptured(bitmap) + } + } + } + + AndroidView( + factory = { + ComposeView(it).apply { + setContent { + content() + } + } + }, + modifier = Modifier.fillMaxSize(), + update = { + viewToCapture = it + } + ) +} + + +@Composable +fun SocialShareScreen( + mediaMetadata: MediaMetadata, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var isGenerating by remember { mutableStateOf(false) } + + val captureController = rememberCaptureController() + + val defaultDominantColor = MaterialTheme.colorScheme.surfaceVariant + val defaultVibrantColor = MaterialTheme.colorScheme.primary + + var dominantColor by remember { mutableStateOf(defaultDominantColor) } + var vibrantColor by remember { mutableStateOf(defaultVibrantColor) } + + LaunchedEffect(mediaMetadata.thumbnailUrl) { + withContext(Dispatchers.IO) { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(mediaMetadata.thumbnailUrl) + .allowHardware(false) + .build() + val bitmap = imageLoader.execute(request).drawable?.toBitmap() + if (bitmap != null) { + Palette.from(bitmap).generate { palette -> + palette?.let { + dominantColor = Color(it.getDominantColor(defaultDominantColor.toArgb())) + vibrantColor = Color(it.getVibrantColor(defaultVibrantColor.toArgb())) + } + } + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnClickOutside = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + ) { + Card( + modifier = Modifier + .fillMaxSize() + .padding(top = 48.dp), + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(R.string.create_your_story), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onDismiss) { + Icon(Icons.Rounded.Close, contentDescription = "Kapat") + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(9f / 16f) + ){ + Capturable( + controller = captureController, + onBitmapCaptured = { bitmap -> + shareBitmap(context, bitmap) + isGenerating = false + onDismiss() + } + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf(vibrantColor.copy(alpha = 0.8f), dominantColor.copy(alpha = 0.6f)) + ) + ), + contentAlignment = Alignment.Center + ) { + SharePreviewContent(mediaMetadata) + } + } + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + if (isGenerating) { + CircularProgressIndicator() + } else { + ChipStyleButton( + onClick = { + isGenerating = true + captureController.capture?.invoke() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.share_on_story)) + } + TextButton(onClick = { + val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val songUrl = "https://music.youtube.com/watch?v=${mediaMetadata.id}" + val clipData = ClipData.newPlainText("Loudly Şarkı Linki", songUrl) + clipboardManager.setPrimaryClip(clipData) + Toast.makeText(context, "Link copied to the clipboard!", Toast.LENGTH_SHORT).show() + }) { + Text(stringResource(R.string.copy_song_link)) + } + } + } + } + } + } + } +} + +@Composable +private fun SharePreviewContent(mediaMetadata: MediaMetadata) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(0.5f)) + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = "Albüm Kapağı", + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth(0.6f) + .aspectRatio(1f) + .shadow(elevation = 16.dp, shape = RoundedCornerShape(12.dp), clip = true) + ) + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = mediaMetadata.title, + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 24.dp), + style = LocalTextStyle.current.copy(shadow = Shadow(color = Color.Black.copy(alpha = 0.5f), offset = Offset(2f, 2f), blurRadius = 4f)) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = mediaMetadata.artists.joinToString { it.name }, + color = Color.White.copy(alpha = 0.8f), + fontSize = 14.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 24.dp), + style = LocalTextStyle.current.copy(shadow = Shadow(color = Color.Black.copy(alpha = 0.5f), offset = Offset(1f, 1f), blurRadius = 2f)) + ) + Spacer(modifier = Modifier.weight(0.2f)) + + Column( + modifier = Modifier + .fillMaxWidth(0.8f) + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Slider( + value = 0.3f, + onValueChange = {}, + enabled = false, + colors = SliderDefaults.colors( + disabledThumbColor = Color.White, + disabledActiveTrackColor = Color.White, + disabledInactiveTrackColor = Color.White.copy(alpha = 0.3f) + ) + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(R.drawable.skip_previous), "Önceki", tint = Color.White, modifier = Modifier.size(36.dp)) + Icon(painterResource(R.drawable.pause), "Durdur", tint = Color.Black, modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color.White) + .padding(8.dp)) + Icon(painterResource(R.drawable.skip_next), "Sonraki", tint = Color.White, modifier = Modifier.size(36.dp)) + } + } + Spacer(modifier = Modifier.weight(0.3f)) + } + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(R.drawable.loudly_monochrome), + contentDescription = "Loudly Logo", tint = Color.White.copy(alpha = 0.7f), modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text( + stringResource(R.string.streamed_on_loudly), + color = Color.White.copy(alpha = 0.7f), + fontWeight = FontWeight.SemiBold, + style = LocalTextStyle.current.copy(shadow = Shadow(color = Color.Black.copy(alpha = 0.5f), offset = Offset(1f, 1f), blurRadius = 2f)) + ) + } + } +} + +private fun drawableToBitmap(context: Context, @DrawableRes drawableId: Int, size: Int): Bitmap { + val drawable: Drawable = context.getDrawable(drawableId)!! + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val tintColor = if (drawableId == R.drawable.pause) android.graphics.Color.BLACK else android.graphics.Color.WHITE + DrawableCompat.setTint(drawable, tintColor) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap +} + +private fun drawableToBitmap(context: Context, @DrawableRes drawableId: Int): Bitmap { + val drawable: Drawable = context.getDrawable(drawableId)!! + DrawableCompat.setTint(drawable, android.graphics.Color.WHITE) + if (drawableId == R.drawable.pause) { + DrawableCompat.setTint(drawable, android.graphics.Color.BLACK) + } + val bitmap = Bitmap.createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bitmap +} + +private fun shareBitmap(context: Context, bitmap: Bitmap) { + val cachePath = File(context.cacheDir, "images") + cachePath.mkdirs() + val file = File(cachePath, "social_share.png") + val fileOutputStream = FileOutputStream(file) + bitmap.compress(Bitmap.CompressFormat.PNG, 100, fileOutputStream) + fileOutputStream.close() + + val authority = "${context.packageName}.FileProvider" + val contentUri = FileProvider.getUriForFile(context, authority, file) + val clipData = ClipData.newUri(context.contentResolver, "Loudly Share", contentUri) + + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "image/png" + this.clipData = clipData + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(shareIntent, "Hikayede Paylaş")) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/theme/Style.kt b/app/src/main/java/com/babelsoftware/loudly/ui/theme/Style.kt new file mode 100644 index 0000000..23b25ad --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/theme/Style.kt @@ -0,0 +1,49 @@ +package com.babelsoftware.loudly.ui.theme + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun FrostedCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit +) { + val cardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.60f) + ) + val cardBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.20f)) + + if (onClick != null) { + Card( + modifier = modifier, + shape = RoundedCornerShape(24.dp), + colors = cardColors, + border = cardBorder, + onClick = onClick, + ) { + content() + } + } else { + Card( + modifier = modifier, + shape = RoundedCornerShape(24.dp), + colors = cardColors, + border = cardBorder, + ) { + content() + } + } +} + +object AppSpacing { + val Default = 16.dp + val Small = 8.dp + val Large = 24.dp +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/theme/Theme.kt b/app/src/main/java/com/babelsoftware/loudly/ui/theme/Theme.kt new file mode 100644 index 0000000..10b1d4c --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/theme/Theme.kt @@ -0,0 +1,195 @@ +package com.babelsoftware.loudly.ui.theme + +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.palette.graphics.Palette +import com.google.material.color.dynamiccolor.DynamicScheme +import com.google.material.color.hct.Hct +import com.google.material.color.scheme.SchemeTonalSpot +import com.google.material.color.score.Score +import androidx.compose.foundation.shape.RoundedCornerShape + +val DefaultThemeColor = Color(0xFFED5564) + +val ExpressiveShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp) +) + +val ExpressiveTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp + ) +) + +@Composable +fun LoudlyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + pureBlack: Boolean = false, + themeColor: Color = DefaultThemeColor, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colorScheme = + remember(darkTheme, pureBlack, themeColor) { + if (themeColor == DefaultThemeColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (darkTheme) { + dynamicDarkColorScheme(context).pureBlack(pureBlack) + } else { + dynamicLightColorScheme(context) + } + } else { + SchemeTonalSpot(Hct.fromInt(themeColor.toArgb()), darkTheme, 0.0) + .toColorScheme() + .pureBlack(darkTheme && pureBlack) + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = ExpressiveTypography, + shapes = ExpressiveShapes, + content = content, + ) +} + +fun Bitmap.extractThemeColor(): Color { + val colorsToPopulation = + Palette + .from(this) + .maximumColorCount(8) + .generate() + .swatches + .associate { it.rgb to it.population } + val rankedColors = Score.score(colorsToPopulation) + return Color(rankedColors.first()) +} + +fun Bitmap.extractGradientColors(darkTheme: Boolean = false): List { + val extractedColors = Palette.from(this) + .maximumColorCount(16) + .generate() + .swatches + .associate { it.rgb to it.population } + + val orderedColors = if (darkTheme) { + Score.order(extractedColors) + .sortedBy { Color(it).luminance() } + .take(2) + .reversed() + } else { + Score.order(extractedColors) + .take(2) + .sortedByDescending { Color(it).luminance() } + } + return if (orderedColors.size >= 2) + listOf(Color(orderedColors[0]), Color(orderedColors[1])) + else + listOf(Color(0xFF595959), Color(0xFF0D0D0D)) +} + +fun DynamicScheme.toColorScheme() = + ColorScheme( + primary = Color(primary), + onPrimary = Color(onPrimary), + primaryContainer = Color(primaryContainer), + onPrimaryContainer = Color(onPrimaryContainer), + inversePrimary = Color(inversePrimary), + secondary = Color(secondary), + onSecondary = Color(onSecondary), + secondaryContainer = Color(secondaryContainer), + onSecondaryContainer = Color(onSecondaryContainer), + tertiary = Color(tertiary), + onTertiary = Color(onTertiary), + tertiaryContainer = Color(tertiaryContainer), + onTertiaryContainer = Color(onTertiaryContainer), + background = Color(background), + onBackground = Color(onBackground), + surface = Color(surface), + onSurface = Color(onSurface), + surfaceVariant = Color(surfaceVariant), + onSurfaceVariant = Color(onSurfaceVariant), + surfaceTint = Color(primary), + inverseSurface = Color(inverseSurface), + inverseOnSurface = Color(inverseOnSurface), + error = Color(error), + onError = Color(onError), + errorContainer = Color(errorContainer), + onErrorContainer = Color(onErrorContainer), + outline = Color(outline), + outlineVariant = Color(outlineVariant), + scrim = Color(scrim), + surfaceBright = Color(surfaceBright), + surfaceDim = Color(surfaceDim), + surfaceContainer = Color(surfaceContainer), + surfaceContainerHigh = Color(surfaceContainerHigh), + surfaceContainerHighest = Color(surfaceContainerHighest), + surfaceContainerLow = Color(surfaceContainerLow), + surfaceContainerLowest = Color(surfaceContainerLowest), + ) + +fun ColorScheme.pureBlack(apply: Boolean) = + if (apply) copy( + surface = Color.Black, + background = Color.Black + ) else this + +val ColorSaver = object : Saver { + override fun restore(value: Int): Color = Color(value) + override fun SaverScope.save(value: Color): Int = value.toArgb() +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/AppBar.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/AppBar.kt new file mode 100644 index 0000000..a913a99 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/AppBar.kt @@ -0,0 +1,67 @@ +package com.babelsoftware.loudly.ui.utils + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TopAppBarState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun appBarScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true }, + snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay(), +): TopAppBarScrollBehavior = + AppBarScrollBehavior( + state = state, + snapAnimationSpec = snapAnimationSpec, + flingAnimationSpec = flingAnimationSpec, + canScroll = canScroll + ) + +@Suppress("SameReturnValue") +@ExperimentalMaterial3Api +class AppBarScrollBehavior( + override val state: TopAppBarState, + override val snapAnimationSpec: AnimationSpec?, + override val flingAnimationSpec: DecayAnimationSpec?, + val canScroll: () -> Boolean = { true }, +) : TopAppBarScrollBehavior { + override val isPinned: Boolean = true + override var nestedScrollConnection = object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { + if (consumed.y == 0f && available.y > 0f) { + state.contentOffset = 0f + } + } + state.heightOffset += consumed.y + return Offset.Zero + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +suspend fun TopAppBarState.resetHeightOffset() { + if (heightOffset != 0f) { + animate( + initialValue = heightOffset, + targetValue = 0f + ) { value, _ -> + heightOffset = value + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/FadingEdge.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/FadingEdge.kt new file mode 100644 index 0000000..bbd8411 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/FadingEdge.kt @@ -0,0 +1,81 @@ +package com.babelsoftware.loudly.ui.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp + +fun Modifier.fadingEdge( + left: Dp? = null, + top: Dp? = null, + right: Dp? = null, + bottom: Dp? = null, +) = graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + if (top != null) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ), + startY = 0f, + endY = top.toPx() + ), + blendMode = BlendMode.DstIn + ) + } + if (bottom != null) { + drawRect( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black, + Color.Transparent + ), + startY = size.height - bottom.toPx(), + endY = size.height + ), + blendMode = BlendMode.DstIn + ) + } + if (left != null) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Black, + Color.Transparent + ), + startX = 0f, + endX = left.toPx() + ), + blendMode = BlendMode.DstIn + ) + } + if (right != null) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + Color.Black + ), + startX = size.width - right.toPx(), + endX = size.width + ), + blendMode = BlendMode.DstIn + ) + } + } + +fun Modifier.fadingEdge( + horizontal: Dp? = null, + vertical: Dp? = null, +) = fadingEdge( + left = horizontal, + right = horizontal, + top = vertical, + bottom = vertical +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/LazyGridSnapLayoutInfoProvider.kt new file mode 100644 index 0000000..c98fefc --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/LazyGridSnapLayoutInfoProvider.kt @@ -0,0 +1,69 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.babelsoftware.loudly.ui.utils + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.ui.util.fastForEach + + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + lazyGridState: LazyGridState, + positionInLayout: (layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize -> + (layoutSize / 2f - itemSize / 2f) + }, +): SnapLayoutInfoProvider = + object : SnapLayoutInfoProvider { + private val layoutInfo: LazyGridLayoutInfo + get() = lazyGridState.layoutInfo + + override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f + override fun calculateSnapOffset(velocity: Float): Float { + val bounds = calculateSnappingOffsetBounds() + return when { + velocity < 0 -> bounds.start + velocity > 0 -> bounds.endInclusive + else -> 0f + } + } + + fun calculateSnappingOffsetBounds(): ClosedFloatingPointRange { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return lowerBoundOffset.rangeTo(upperBoundOffset) + } + } + +fun calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: (layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = layoutInfo.singleAxisViewportSize - layoutInfo.beforeContentPadding - layoutInfo.afterContentPadding + + val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) + val itemCurrentPosition = item.offset.x.toFloat() + + return itemCurrentPosition - desiredDistance +} + +private val LazyGridLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/LocalMediaUtils.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/LocalMediaUtils.kt new file mode 100644 index 0000000..d349fd0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/LocalMediaUtils.kt @@ -0,0 +1,341 @@ +@file:Suppress("NAME_SHADOWING") + +package com.babelsoftware.loudly.ui.utils + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.provider.MediaStore +import com.babelsoftware.loudly.constants.ScannerSensitivity +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.utils.LmImageCacheMgr +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.time.LocalDateTime +import java.util.Locale + +const val sdcardRoot = "/storage/emulated/0/" +val testScanPaths = arrayListOf("Music") +var directoryUID = 0 +var cachedDirectoryTree: DirectoryTree? = null +var imageCache: LmImageCacheMgr = LmImageCacheMgr() + +val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ARTIST_ID, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.ALBUM_ID, + MediaStore.Audio.Media.RELATIVE_PATH, +) + +class DirectoryTree(path: String) { + var currentDir = path + var parent: String = "" + var subdirs = ArrayList() + var files = ArrayList() + val uid = directoryUID + + init { + directoryUID++ + } + + constructor(path: String, files: ArrayList) : this(path) { + this.files = files + } + + fun insert(path: String, song: Song) { + if (path.indexOf('/') == -1) { + files.add(song) + println(path) + return + } + var tmppath = path + if (path[path.length - 1] == '/') { + tmppath = path.substring(0, path.length - 1) + } + val subdirPath = tmppath.substringBefore('/') + var existingSubdir: DirectoryTree? = null + subdirs.forEach { subdir -> + if (subdir.currentDir == subdirPath) { + existingSubdir = subdir + return@forEach + } + } + if (existingSubdir == null) { + val tree = DirectoryTree(subdirPath) + tree.parent = "$parent/$currentDir" + tree.insert(tmppath.substringAfter('/'), song) + subdirs.add(tree) + + } else { + existingSubdir!!.insert(tmppath.substringAfter('/'), song) + } + } + + fun toList(): List { + val songs = ArrayList() + fun traverseHELPME(tree: DirectoryTree, result: ArrayList) { + result.addAll(tree.files) + tree.subdirs.forEach { traverseHELPME(it, result) } + } + traverseHELPME(this, songs) + return songs + } + + fun toFlattenedTree(): DirectoryTree { + val result = DirectoryTree(sdcardRoot) + getSubdirsRecursive(this, result.subdirs) + return result + } + + private fun getSubdirsRecursive(it: DirectoryTree, result: ArrayList) { + if (it.files.size > 0) { + result.add(DirectoryTree(it.currentDir, it.files)) + } + + if (it.subdirs.size > 0) { + it.subdirs.forEach { getSubdirsRecursive(it, result) } + } + } + + fun copy( + currentDir: String = this.currentDir, + subdirs: List = this.subdirs, + files: List = this.files + ): DirectoryTree { + return DirectoryTree(currentDir).apply { + this.subdirs = subdirs.toMutableList() as ArrayList + this.files = files.toMutableList() as ArrayList + } + } +} + +fun refreshLocal(context: Context, database: MusicDatabase) = + refreshLocal(context, database, testScanPaths) + +fun refreshLocal( + context: Context, + database: MusicDatabase, + scanPaths: ArrayList +): MutableStateFlow { + val newDirectoryStructure = DirectoryTree(sdcardRoot) + var existingSongs: List + runBlocking(Dispatchers.IO) { + existingSongs = database.allLocalSongsData().first() + } + val contentResolver: ContentResolver = context.contentResolver + val cursor = contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + "${MediaStore.Audio.Media.IS_MUSIC} != 0 AND ${MediaStore.Audio.Media.DATA} LIKE ?", + scanPaths.map { "$sdcardRoot$it%" }.toTypedArray(), + null + ) + cursor?.use { cursor -> + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.RELATIVE_PATH) + + while (cursor.moveToNext()) { + val name = cursor.getString(nameColumn) + val path = cursor.getString(pathColumn) + val possibleMatch = + existingSongs.firstOrNull { it.song.localPath == "$sdcardRoot$path$name" } + + if (possibleMatch != null) { + newDirectoryStructure.insert("$path$name", possibleMatch) + } + + } + } + + cachedDirectoryTree = newDirectoryStructure + return MutableStateFlow(newDirectoryStructure) +} + +fun scanLocal(context: Context) = + scanLocal(context, testScanPaths) + + +@OptIn(ExperimentalCoroutinesApi::class) +fun scanLocal( + context: Context, + scanPaths: ArrayList +): MutableStateFlow { + val newDirectoryStructure = DirectoryTree(sdcardRoot) + val contentResolver: ContentResolver = context.contentResolver + val cursor = contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + "${MediaStore.Audio.Media.IS_MUSIC} != 0 AND ${MediaStore.Audio.Media.DATA} LIKE ?", + scanPaths.map { "$sdcardRoot$it%" }.toTypedArray(), + null + ) + + val scannerJobs = ArrayList>() + runBlocking { + cursor?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) + val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val artistIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumIDColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val pathColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.RELATIVE_PATH) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val name = cursor.getString(nameColumn) + val title = cursor.getString(titleColumn) + val duration = cursor.getInt(durationColumn) + val artist = cursor.getString(artistColumn) + val artistID = cursor.getString(artistIdColumn) + val albumID = cursor.getString(albumIDColumn) + val album = cursor.getString(albumColumn) + val path = cursor.getString(pathColumn) + fun advancedScan(): Song { + val artists = ArrayList() + artists.add(ArtistEntity(artistID, artist)) + return Song( + SongEntity( + id.toString(), + title, + (duration / 1000), + albumId = albumID, + albumName = album, + isLocal = true, + liked = false, + inLibrary = LocalDateTime.now(), + localPath = "$sdcardRoot$path$name" + ), + artists + ) + } + + scannerJobs.add(async(Dispatchers.IO) { advancedScan() }) + } + } + + scannerJobs.awaitAll() + } + scannerJobs.forEach { it -> + val song = it.getCompleted() + song.song.localPath?.let { + newDirectoryStructure.insert( + it.substringAfter(sdcardRoot), song + ) + } + } + + cachedDirectoryTree = newDirectoryStructure + return MutableStateFlow(newDirectoryStructure) +} + +fun syncDB( + database: MusicDatabase, + directoryStructure: List, + matchStrength: ScannerSensitivity, + strictFileNames: Boolean +) { + runBlocking(Dispatchers.IO) { + val existingSongs = database.allLocalSongsData().first() + .associateBy { it.song.localPath } + directoryStructure.forEach { scannedSong -> + val localPath = scannedSong.song.localPath ?: return@forEach + val existingSong = existingSongs[localPath] + if (existingSong != null) { + val updatedSong = scannedSong.song.copy( + id = existingSong.song.id, + liked = existingSong.song.liked, + inLibrary = existingSong.song.inLibrary, + totalPlayTime = existingSong.song.totalPlayTime + ) + database.update(updatedSong) + } else { + database.insert(scannedSong.toMediaMetadata()) + } + } + val scannedPaths = directoryStructure.mapNotNull { it.song.localPath }.toSet() + existingSongs.keys.forEach { path -> + if (path != null && path !in scannedPaths) { + database.delete(existingSongs[path]!!.song) + } + } + } +} + +fun compareArtist(a: List, b: List): Boolean { + if (a.isEmpty() && b.isEmpty()) { + return true + } else if (a.isEmpty() || b.isEmpty()) { + return false + } + if (a.size != b.size) { + return false + } + val matchingArtists = a.filter { artist -> + b.any { it.name.lowercase(Locale.getDefault()) == artist.name.lowercase(Locale.getDefault()) } + } + return matchingArtists.size == a.size +} +fun compareSong( + a: Song, + b: Song, + matchStrength: ScannerSensitivity = ScannerSensitivity.LEVEL_2, + strictFileNames: Boolean = false +): Boolean { + if (strictFileNames && + (a.song.localPath?.substringAfterLast('/') != + b.song.localPath?.substringAfterLast('/')) + ) { + return false + } + fun closeEnough(): Boolean { + return a.song.localPath == b.song.localPath + } + return when (matchStrength) { + ScannerSensitivity.LEVEL_1 -> a.song.title == b.song.title + ScannerSensitivity.LEVEL_2 -> closeEnough() || (a.song.title == b.song.title && + compareArtist(a.artists, b.artists)) + + ScannerSensitivity.LEVEL_3 -> closeEnough() || (a.song.title == b.song.title && + compareArtist(a.artists, b.artists)) + } +} +object CachedBitmap { + var path: String? = null + var image: Bitmap? = null + fun cache(path: String, image: Bitmap?) { + if (image == null) { + return + } + + this.path = path + this.image = image + bitmapCache.add(this) + } + +} + +var bitmapCache = ArrayList() +fun getDirectorytree(): DirectoryTree? { + if (cachedDirectoryTree == null) { + return null + } + return cachedDirectoryTree +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/NavControllerUtils.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/NavControllerUtils.kt new file mode 100644 index 0000000..121e199 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/NavControllerUtils.kt @@ -0,0 +1,11 @@ +package com.babelsoftware.loudly.ui.utils + +import androidx.compose.ui.util.fastAny +import androidx.navigation.NavController +import com.babelsoftware.loudly.ui.screens.Screens + +fun NavController.backToMain() { + while (!Screens.Companion.MainScreens.fastAny { it.route == currentBackStackEntry?.destination?.route }) { + navigateUp() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/ScrollUtils.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/ScrollUtils.kt new file mode 100644 index 0000000..412aae1 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/ScrollUtils.kt @@ -0,0 +1,59 @@ +package com.babelsoftware.loudly.ui.utils + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +@Composable +fun LazyListState.isScrollingUp(): Boolean { + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } + return remember(this) { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} + +@Composable +fun LazyGridState.isScrollingUp(): Boolean { + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } + return remember(this) { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} + +@Composable +fun ScrollState.isScrollingUp(): Boolean { + var previousScrollOffset by remember(this) { mutableIntStateOf(value) } + return remember(this) { + derivedStateOf { + (previousScrollOffset >= value).also { + previousScrollOffset = value + } + } + }.value +} diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/ShapeUtils.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/ShapeUtils.kt new file mode 100644 index 0000000..be2a338 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/ShapeUtils.kt @@ -0,0 +1,8 @@ +package com.babelsoftware.loudly.ui.utils + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.unit.dp + +fun CornerBasedShape.top(): CornerBasedShape = + copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/StringUtils.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/StringUtils.kt new file mode 100644 index 0000000..3259cc3 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/StringUtils.kt @@ -0,0 +1,48 @@ +package com.babelsoftware.loudly.ui.utils + +import kotlin.math.absoluteValue + +fun formatFileSize(sizeBytes: Long): String { + val prefix = if (sizeBytes < 0) "-" else "" + var result: Long = sizeBytes.absoluteValue + var suffix = "B" + if (result > 900) { + suffix = "KB" + result /= 1024 + } + if (result > 900) { + suffix = "MB" + result /= 1024 + } + if (result > 900) { + suffix = "GB" + result /= 1024 + } + if (result > 900) { + suffix = "TB" + result /= 1024 + } + if (result > 900) { + suffix = "PB" + result /= 1024 + } + return "$prefix$result $suffix" +} + +fun numberToAlpha(l: Long): String { + val alphabetMap = ('A'..'J').toList() + val weh = if (l < 0) "0" else l.toString() + val lengthStr = if (weh.length.toInt() < 10) { + "0" + weh.length.toInt() + } else { + weh.length.toInt().toString() + } + + return (lengthStr + weh + "\u0000").map { + if (it == '\u0000') { + "0" + } else { + alphabetMap[it.digitToInt()] + } + }.joinToString("") +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/ui/utils/YouTubeUtils.kt b/app/src/main/java/com/babelsoftware/loudly/ui/utils/YouTubeUtils.kt new file mode 100644 index 0000000..b51a202 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/ui/utils/YouTubeUtils.kt @@ -0,0 +1,20 @@ +package com.babelsoftware.loudly.ui.utils + +fun String.resize( + width: Int? = null, + height: Int? = null, +): String { + if (width == null && height == null) return this + "https://lh3\\.googleusercontent\\.com/.*=w(\\d+)-h(\\d+).*".toRegex().matchEntire(this)?.groupValues?.let { group -> + val (W, H) = group.drop(1).map { it.toInt() } + var w = width + var h = height + if (w != null && h == null) h = (w / W) * H + if (w == null && h != null) w = (h / H) * W + return "${split("=w")[0]}=w$w-h$h-p-l90-rj" + } + if (this matches "https://yt3\\.ggpht\\.com/.*=s(\\d+)".toRegex()) { + return "$this-s${width ?: height}" + } + return this +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/CoilBitmapLoader.kt b/app/src/main/java/com/babelsoftware/loudly/utils/CoilBitmapLoader.kt new file mode 100644 index 0000000..b8fe466 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/CoilBitmapLoader.kt @@ -0,0 +1,46 @@ +package com.babelsoftware.loudly.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.media3.common.util.BitmapLoader +import coil.imageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.guava.future +import java.util.concurrent.ExecutionException + +class CoilBitmapLoader( + private val context: Context, + private val scope: CoroutineScope, +) : BitmapLoader { + override fun supportsMimeType(mimeType: String): Boolean = mimeType.startsWith("image/") + + override fun decodeBitmap(data: ByteArray): ListenableFuture = + scope.future(Dispatchers.IO) { + BitmapFactory.decodeByteArray(data, 0, data.size) ?: error("Could not decode image data") + } + + override fun loadBitmap(uri: Uri): ListenableFuture = + scope.future(Dispatchers.IO) { + val result = context.imageLoader.execute( + ImageRequest.Builder(context) + .data(uri) + .allowHardware(false) + .build() + ) + if (result is ErrorResult) { + throw ExecutionException(result.throwable) + } + try { + (result.drawable as BitmapDrawable).bitmap + } catch (e: Exception) { + throw ExecutionException(e) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/DataStore.kt b/app/src/main/java/com/babelsoftware/loudly/utils/DataStore.kt new file mode 100644 index 0000000..75c5f65 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/DataStore.kt @@ -0,0 +1,100 @@ +package com.babelsoftware.loudly.utils + +import android.content.Context +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.babelsoftware.loudly.extensions.toEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.properties.ReadOnlyProperty + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +operator fun DataStore.get(key: Preferences.Key): T? = + runBlocking(Dispatchers.IO) { + data.first()[key] + } + +fun DataStore.get(key: Preferences.Key, defaultValue: T): T = + runBlocking(Dispatchers.IO) { + data.first()[key] ?: defaultValue + } + +inline fun > enumPreference( + context: Context, + key: Preferences.Key, + defaultValue: T, +) = ReadOnlyProperty { _, _ -> context.dataStore[key].toEnum(defaultValue) } + +@Composable +fun rememberPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val state = remember { + context.dataStore.data + .map { it[key] ?: defaultValue } + .distinctUntilChanged() + }.collectAsState(context.dataStore[key] ?: defaultValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value + } + } + } + + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} + +@Composable +inline fun > rememberEnumPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val initialValue = context.dataStore[key].toEnum(defaultValue = defaultValue) + val state = remember { + context.dataStore.data + .map { it[key].toEnum(defaultValue = defaultValue) } + .distinctUntilChanged() + }.collectAsState(initialValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value.name + } + } + } + + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/DiscordRPC.kt b/app/src/main/java/com/babelsoftware/loudly/utils/DiscordRPC.kt new file mode 100644 index 0000000..9611eea --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/DiscordRPC.kt @@ -0,0 +1,35 @@ +package com.babelsoftware.loudly.utils + +import android.content.Context +import com.babelsoftware.loudly.db.entities.Song +import com.my.kizzy.rpc.KizzyRPC +import com.my.kizzy.rpc.RpcImage +import com.babelsoftware.loudly.R + +class DiscordRPC( + val context: Context, + token: String, +) : KizzyRPC(token) { + suspend fun updateSong(song: Song) = runCatching { + setActivity( + name = context.getString(R.string.app_name).removeSuffix(" Debug"), + details = song.song.title, + state = song.artists.joinToString { it.name }, + largeImage = song.song.thumbnailUrl?.let { RpcImage.ExternalImage(it) }, + smallImage = song.artists.firstOrNull()?.thumbnailUrl?.let { RpcImage.ExternalImage(it) }, + largeText = song.album?.title, + smallText = song.artists.firstOrNull()?.name, + buttons = listOf( + "Listen on YouTube Music" to "https://music.youtube.com/watch?v=${song.song.id}", + "Visit Loudly" to "https://github.com/RRechz/Loudly" + ), + type = Type.LISTENING, + since = System.currentTimeMillis(), + applicationId = APPLICATION_ID + ) + } + + companion object { + private const val APPLICATION_ID = "1271273225120125040" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/ItemWrapper.kt b/app/src/main/java/com/babelsoftware/loudly/utils/ItemWrapper.kt new file mode 100644 index 0000000..573ab03 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/ItemWrapper.kt @@ -0,0 +1,13 @@ +package com.babelsoftware.loudly.utils + +import androidx.compose.runtime.mutableStateOf + +class ItemWrapper(val item: T) { + private val _isSelected = mutableStateOf(false) + + var isSelected: Boolean + get() = _isSelected.value + set(value) { + _isSelected.value = value + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/LmImageCacheMgr.kt b/app/src/main/java/com/babelsoftware/loudly/utils/LmImageCacheMgr.kt new file mode 100644 index 0000000..1c6e8b2 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/LmImageCacheMgr.kt @@ -0,0 +1,35 @@ +package com.babelsoftware.loudly.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import com.babelsoftware.loudly.models.ImageCacheManager +import androidx.core.graphics.scale + +class LmImageCacheMgr { + + private var localImageCache = ImageCacheManager(300) + fun getLocalThumbnail(path: String?): Bitmap? = getLocalThumbnail(path, false) + fun getLocalThumbnail(path: String?, resize: Boolean): Bitmap? { + if (path == null) { + return null + } + val mData = MediaMetadataRetriever() + + var image: Bitmap = try { + mData.setDataSource(path) + val art = mData.embeddedPicture + BitmapFactory.decodeByteArray(art, 0, art!!.size) + } catch (e: Exception) { + localImageCache.cache(path, null, resize) + null + } ?: return null + + if (resize) { + image = image.scale(100, 100, false) + } + + localImageCache.cache(path, image, resize) + return image + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/LogReportHelper.kt b/app/src/main/java/com/babelsoftware/loudly/utils/LogReportHelper.kt new file mode 100644 index 0000000..ddfec85 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/LogReportHelper.kt @@ -0,0 +1,67 @@ +package com.babelsoftware.loudly.utils + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.media3.common.PlaybackException +import com.babelsoftware.loudly.BuildConfig +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object LogReportHelper { + + fun createAndShareErrorReport( + context: Context, + error: PlaybackException, + mediaId: String? + ) { + val logContent = buildString { + appendLine("Loudly Error Report") + appendLine("Time Stamp: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())}") + appendLine("App Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + appendLine("--------------------") + appendLine("Error Details:") + appendLine(" Song ID: ${mediaId ?: "Unknow"}") + appendLine(" Error Code: ${error.errorCodeName} (${error.errorCode})") + appendLine(" Error Message: ${error.message}") + appendLine("\nException Chain (Cause):") + var cause: Throwable? = error.cause + while (cause != null) { + appendLine(" - ${cause.javaClass.simpleName}: ${cause.message}") + cause = cause.cause + } + appendLine("--------------------") + } + + try { + // 1. Log dosyasını oluştur + val logFile = File(context.cacheDir, "loudly_error_report_${System.currentTimeMillis()}.txt") + logFile.writeText(logContent) + + // 2. FileProvider ile URI al + val fileUri = FileProvider.getUriForFile( + context, + "${BuildConfig.APPLICATION_ID}.provider", // Manifest'te tanımlanmalı + logFile + ) + + // 3. Paylaşım Intent'ini başlat + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, "Loudly Eror Report") + putExtra(Intent.EXTRA_STREAM, fileUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val chooserIntent = Intent.createChooser(shareIntent, "Share Error Report...") + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(chooserIntent) + + } catch (e: Exception) { + // Dosya oluşturma veya paylaşma sırasında hata olursa... + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/NetworkUtils.kt b/app/src/main/java/com/babelsoftware/loudly/utils/NetworkUtils.kt new file mode 100644 index 0000000..2f60434 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/NetworkUtils.kt @@ -0,0 +1,19 @@ +package com.babelsoftware.loudly.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import androidx.core.content.getSystemService + +fun isInternetAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService() ?: return false + val activeNetwork = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + + return when { + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + else -> false + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/NotificationHelper.kt b/app/src/main/java/com/babelsoftware/loudly/utils/NotificationHelper.kt new file mode 100644 index 0000000..48ea855 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/NotificationHelper.kt @@ -0,0 +1,109 @@ +/** + * Babel Software 2025 + * + * The concept and features of "Comrade" are wholly owned by Babel Software. + * Are made available for use under an open source license. + */ + +package com.babelsoftware.loudly.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.babelsoftware.loudly.MainActivity +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.db.entities.Song + +object NotificationHelper { + private const val CHANNEL_ID = "yoldas_chip_channel" + private const val CHANNEL_NAME = "Yoldaş Bildirimleri" + + private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Loudly için akıllı öneriler ve bildirimler." + } + val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + /** + * Creates and sends the notification to be displayed when wired headphones are plugged in. + * This function now takes the ‘Song’ object as a parameter and does not perform database operations. + */ + fun showHeadsetPlugNotification(context: Context, lastPlayedSong: Song?) { + createNotificationChannel(context) + + val title = context.getString(R.string.notification_headset_title) + val text = if (lastPlayedSong != null) { + context.getString(R.string.notification_headset_continue, lastPlayedSong.song.title) + } else { + context.getString(R.string.notification_headset_generic) + } + + // Intent that will open the app and potentially the song when the notification is clicked + val intent = Intent(context, MainActivity::class.java).apply { + action = "ACTION_PLAY_SONG" + putExtra("SONG_ID", lastPlayedSong?.song?.id) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent: PendingIntent = PendingIntent.getActivity( + context, + 1, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.small_icon) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + // Set the notification ID to a fixed value (e.g., 1001) + notificationManager.notify(1001, builder.build()) + } + + fun showContextualNotification( + context: Context, + title: String, + text: String, + playlistId: String, + payloadId: String + ) { + createNotificationChannel(context) + + // When the notification is clicked, it will open MainActivity and send the play command Intent + val intent = Intent(context, MainActivity::class.java).apply { + action = "ACTION_PLAY_CONTEXTUAL_PLAYLIST" + putExtra("PLAYLIST_ID", playlistId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + + val pendingIntent: PendingIntent = PendingIntent.getActivity(context, playlistId.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.small_icon) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(playlistId.hashCode(), builder.build()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/StringUtils.kt b/app/src/main/java/com/babelsoftware/loudly/utils/StringUtils.kt new file mode 100644 index 0000000..1812e19 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/StringUtils.kt @@ -0,0 +1,26 @@ +package com.babelsoftware.loudly.utils + +import java.net.URLEncoder + +fun makeTimeString(duration: Long?): String { + if (duration == null || duration < 0) return "" + var sec = duration / 1000 + val day = sec / 86400 + sec %= 86400 + val hour = sec / 3600 + sec %= 3600 + val minute = sec / 60 + sec %= 60 + return when { + day > 0 -> "%d:%02d:%02d:%02d".format(day, hour, minute, sec) + hour > 0 -> "%d:%02d:%02d".format(hour, minute, sec) + else -> "%d:%02d".format(minute, sec) + } +} + +fun joinByBullet(vararg str: String?) = + str.filterNot { + it.isNullOrEmpty() + }.joinToString(separator = " • ") + +fun String.urlEncode(): String = URLEncoder.encode(this, "UTF-8") \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/SyncUtils.kt b/app/src/main/java/com/babelsoftware/loudly/utils/SyncUtils.kt new file mode 100644 index 0000000..0b9a1ad --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/SyncUtils.kt @@ -0,0 +1,179 @@ +package com.babelsoftware.loudly.utils + +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.db.entities.PlaylistSongMap +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.models.toMediaMetadata +import com.babelsoftware.loudly.playback.DownloadUtil +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.utils.completed +import com.babelsoftware.innertube.utils.completedLibraryPage +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.ArtistItem +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import javax.inject.Inject +import javax.inject.Singleton + +@Suppress("NAME_SHADOWING") +@Singleton +class SyncUtils @Inject constructor( + val database: MusicDatabase, + private val downloadUtil: DownloadUtil +) { + suspend fun syncLikedSongs() { + YouTube.playlist("LM").completed().onSuccess { page -> + val songs = page.songs.reversed() + database.likedSongsByNameAsc().first() + .filter { + !it.song.isLocal && it.id !in songs.map(SongItem::id) + } + .forEach { database.update(it.song.localToggleLike()) } + + songs.forEach { song -> + val dbSong = database.song(song.id).firstOrNull() + database.transaction { + when (dbSong) { + null -> insert(song.toMediaMetadata(), SongEntity::localToggleLike) + else -> if (!dbSong.song.liked && !dbSong.song.isLocal) { + update(dbSong.song.localToggleLike()) + } + } + } + val songs = database.likedSongsNotDownloaded() + .first() + .filterNot { it.song.isLocal } + .map { it.song } + + downloadUtil.autoDownloadIfLiked(songs) + } + } + } + suspend fun syncLikedAlbums() { + YouTube.library("FEmusic_liked_albums").completedLibraryPage().onSuccess { page -> + val albums = page.items.filterIsInstance().reversed() + + database.albumsLikedByNameAsc().first() + .filterNot { it.id in albums.map(AlbumItem::id) } + .forEach { database.update(it.album.localToggleLike()) } + + albums.forEach { album -> + val dbAlbum = database.album(album.id).firstOrNull() + YouTube.album(album.browseId).onSuccess { albumPage -> + when (dbAlbum) { + null -> { + database.insert(albumPage) + database.album(album.id).firstOrNull()?.let { + database.update(it.album.localToggleLike()) + } + } + else -> if (dbAlbum.album.bookmarkedAt == null) + database.update(dbAlbum.album.localToggleLike()) + } + } + } + } + } + suspend fun syncArtistsSubscriptions() { + YouTube.library("FEmusic_library_corpus_artists").completedLibraryPage().onSuccess { page -> + val artists = page.items.filterIsInstance() + + database.artistsBookmarkedByNameAsc().first() + .filterNot { it.id in artists.map(ArtistItem::id) } + .forEach { database.update(it.artist.localToggleLike()) } + artists.forEach { artist -> + val dbArtist = database.artist(artist.id).firstOrNull() + database.transaction { + when (dbArtist) { + null -> { + insert( + ArtistEntity( + id = artist.id, + name = artist.title, + thumbnailUrl = artist.thumbnail, + channelId = artist.channelId, + bookmarkedAt = LocalDateTime.now() + ) + ) + } + else -> if (dbArtist.artist.bookmarkedAt == null) + update(dbArtist.artist.localToggleLike()) + } + } + } + } + } + suspend fun syncSavedPlaylists() { + YouTube.library("FEmusic_liked_playlists").completedLibraryPage().onSuccess { page -> + val playlistList = page.items.filterIsInstance() + .filterNot { it.id == "LM" || it.id == "SE" } + .reversed() + val dbPlaylists = database.playlistsByNameAsc().first() + + dbPlaylists.filterNot { it.playlist.browseId in playlistList.map(PlaylistItem::id) } + .filterNot { it.playlist.browseId == null } + .filterNot { it.playlist.isLocal } + .forEach { database.update(it.playlist.localToggleLike()) } + + playlistList.onEach { playlist -> + var playlistEntity = + dbPlaylists.find { playlist.id == it.playlist.browseId }?.playlist + if (playlistEntity == null) { + playlistEntity = PlaylistEntity( + name = playlist.title, + browseId = playlist.id, + thumbnailUrl = playlist.thumbnail, + isEditable = playlist.isEditable, + bookmarkedAt = LocalDateTime.now(), + remoteSongCount = playlist.songCountText?.let { Regex("""\d+""").find(it)?.value?.toIntOrNull() }, + playEndpointParams = playlist.playEndpoint?.params, + shuffleEndpointParams = playlist.shuffleEndpoint.params, + radioEndpointParams = playlist.radioEndpoint?.params + ) + + database.insert(playlistEntity) + } else database.update(playlistEntity, playlist) + + syncPlaylist(playlist.id, playlistEntity.id) + } + } + } + suspend fun syncPlaylist(browseId: String, playlistId: String) { + val playlistPage = YouTube.playlist(browseId).completed().getOrNull() ?: return + database.transaction { + clearPlaylist(playlistId) + playlistPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { position, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistId, + position = position, + setVideoId = song.setVideoId + ) + } + .forEach(::insert) + } + } + suspend fun syncRecentActivity() { + YouTube.libraryRecentActivity().onSuccess { page -> + val recentActivity = page.items.take(9).drop(1) + coroutineScope { + launch(Dispatchers.IO) { + database.clearRecentActivity() + + recentActivity.reversed().forEach { database.insertRecentActivityItem(it) } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/UpdateUtils.kt b/app/src/main/java/com/babelsoftware/loudly/utils/UpdateUtils.kt new file mode 100644 index 0000000..e81e299 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/UpdateUtils.kt @@ -0,0 +1,68 @@ +// com/babelsoftware/loudly/utils/UpdateUtils.kt +package com.babelsoftware.loudly.utils + +import com.babelsoftware.loudly.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.URL + +// Sürüm bilgilerini (sürüm notu, indirme linki vb.) bir arada tutan veri sınıfı +data class ReleaseInfo( + val tagName: String, + val changelog: String, + val apkDownloadUrl: String? +) + +/** + * GitHub API'sinden en son sürüm bilgilerini çeker. + * @return ReleaseInfo nesnesi veya hata durumunda null. + */ +suspend fun getLatestReleaseInfo(): ReleaseInfo? = withContext(Dispatchers.IO) { + try { + // DİKKAT: URL, Loudly projesinin GitHub reposunu işaret edecek şekilde güncellendi. + val url = URL("https://api.github.com/repos/RRechz/Loudly/releases/latest") + val json = url.readText() + val jsonObject = JSONObject(json) + + val tagName = jsonObject.getString("tag_name") + val changelog = jsonObject.optString("body", "Değişiklik günlüğü alınamadı.") + + // .apk dosyasının indirme linkini bul + val assets = jsonObject.getJSONArray("assets") + var apkUrl: String? = null + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val downloadUrl = asset.optString("browser_download_url") + if (downloadUrl.endsWith(".apk")) { + apkUrl = downloadUrl + break + } + } + + ReleaseInfo(tagName, changelog, apkUrl) + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +/** + * Uzak sunucudaki versiyonun mevcut versiyondan daha yeni olup olmadığını kontrol eder. + * @param remoteVersion Uzak sunucudaki versiyon (örn: "v1.2"). + * @param currentVersion Cihazda yüklü olan versiyon (örn: "v1.1"). + * @return Uzak versiyon daha yeniyse true, aksi halde false. + */ +fun isNewerVersion(remoteVersion: String, currentVersion: String): Boolean { + // AirNote'tan alınan versiyon karşılaştırma mantığı + val remote = remoteVersion.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + val current = currentVersion.removePrefix("v").split("-")[0].split(".").map { it.toIntOrNull() ?: 0 } + + for (i in 0 until maxOf(remote.size, current.size)) { + val r = remote.getOrNull(i) ?: 0 + val c = current.getOrNull(i) ?: 0 + if (r > c) return true + if (r < c) return false + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/Updater.kt b/app/src/main/java/com/babelsoftware/loudly/utils/Updater.kt new file mode 100644 index 0000000..f8e7a85 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/Updater.kt @@ -0,0 +1,21 @@ +package com.babelsoftware.loudly.utils + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import org.json.JSONObject + +object Updater { + private val client = HttpClient() + var lastCheckTime = -1L + private set + + suspend fun getLatestVersionName(): Result = + runCatching { + val response = client.get("https://api.github.com/repos/RRechz/Loudly/releases/latest").bodyAsText() + val json = JSONObject(response) + val versionName = json.getString("name") + lastCheckTime = System.currentTimeMillis() + versionName + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/utils/YTPlayerUtils.kt b/app/src/main/java/com/babelsoftware/loudly/utils/YTPlayerUtils.kt new file mode 100644 index 0000000..791c6c4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/utils/YTPlayerUtils.kt @@ -0,0 +1,167 @@ +package com.babelsoftware.loudly.utils + +import android.net.ConnectivityManager +import androidx.media3.common.PlaybackException +import com.babelsoftware.loudly.constants.AudioQuality +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.YouTubeClient +import com.babelsoftware.innertube.models.YouTubeClient.Companion.IOS +import com.babelsoftware.innertube.models.YouTubeClient.Companion.TVHTML5_SIMPLY_EMBEDDED_PLAYER +import com.babelsoftware.innertube.models.YouTubeClient.Companion.WEB_REMIX +import com.babelsoftware.innertube.models.response.PlayerResponse +import com.babelsoftware.innertube.pages.NewPipeUtils +import kotlinx.coroutines.delay +import okhttp3.OkHttpClient +import okhttp3.Request + +object YTPlayerUtils { + private val httpClient = OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + + private val MAIN_CLIENT: YouTubeClient = WEB_REMIX + + private val STREAM_FALLBACK_CLIENTS: Array = arrayOf( + TVHTML5_SIMPLY_EMBEDDED_PLAYER, + IOS, + YouTubeClient.WEB, + YouTubeClient.MOBILE, + YouTubeClient.ANDROID_MUSIC + ) + + data class PlaybackData( + val audioConfig: PlayerResponse.PlayerConfig.AudioConfig?, + val videoDetails: PlayerResponse.VideoDetails?, + val playbackTracking: PlayerResponse.PlaybackTracking?, + val format: PlayerResponse.StreamingData.Format, + val streamUrl: String, + val streamExpiresInSeconds: Int, + ) + + suspend fun playerResponseForPlayback( + videoId: String, + playlistId: String? = null, + audioQuality: AudioQuality, + connectivityManager: ConnectivityManager, + ): Result = runCatching { + val signatureTimestamp = NewPipeUtils.getSignatureTimestamp(videoId).getOrNull() + val isLoggedIn = YouTube.cookie != null + + var audioConfig: PlayerResponse.PlayerConfig.AudioConfig? = null + var videoDetails: PlayerResponse.VideoDetails? = null + var playbackTracking: PlayerResponse.PlaybackTracking? = null + var format: PlayerResponse.StreamingData.Format? = null + var streamUrl: String? = null + var streamExpiresInSeconds: Int? = null + val allClients = (listOf(MAIN_CLIENT) + STREAM_FALLBACK_CLIENTS).shuffled() + var lastError: Throwable? = null + + for (client in allClients) { + if (client.loginRequired && !isLoggedIn && YouTube.cookie == null) continue + + try { + val streamPlayerResponse = YouTube.player(videoId, playlistId, client, signatureTimestamp).getOrThrow() + + if (streamPlayerResponse.playabilityStatus?.status != "OK") { + lastError = IllegalStateException("Playability status is: ${streamPlayerResponse.playabilityStatus?.status}") + continue + } + + if (videoDetails == null) { + audioConfig = streamPlayerResponse.playerConfig?.audioConfig + videoDetails = streamPlayerResponse.videoDetails + playbackTracking = streamPlayerResponse.playbackTracking + } + + val audioFormats = streamPlayerResponse.streamingData?.adaptiveFormats?.filter { it.isAudio } ?: continue + val bestFormat = when (audioQuality) { + // Low Quality (LOW) Setting: Bitrate is kept between 45 - 52 Kbps. + AudioQuality.LOW -> { + audioFormats + .filter { it.bitrate in 45_000..52_000 } + .maxByOrNull { it.bitrate } + } + + // High Quality (HIGH) Setting: Bitrate is kept between 128 - 256 Kbps. + AudioQuality.HIGH -> { + audioFormats + .filter { it.bitrate in 128_000..256_000 } + .maxByOrNull { it.bitrate } + } + + // Maximum Quality (MAX) Setting: Smart selection up to 512 Kbps. + AudioQuality.MAX -> { + audioFormats + .filter { it.bitrate <= 512_000 } + // Select the one with the highest bitrate; if equal, give priority to WEBM/OPUS. + .maxByOrNull { it.bitrate + if (it.mimeType.startsWith("audio/webm")) 1 else 0 } + } + + // Automatic Quality (AUTO) Setting: Smart selection based on network conditions. + AudioQuality.AUTO -> { + if (connectivityManager.isActiveNetworkMetered) { + // Mobile network: 45 - 128 Kbps range is targeted. + audioFormats + .filter { it.bitrate in 45_000..128_000 } + .maxByOrNull { it.bitrate } + } else { + // Wi-Fi: A range of 45 - 512 kbps is targeted. + audioFormats + .filter { it.bitrate in 45_000..512_000 } + .maxByOrNull { it.bitrate + if (it.mimeType.startsWith("audio/webm")) 1 else 0 } + } + } + } ?: continue // If no suitable format is found, try the next client + val currentStreamUrl = NewPipeUtils.getStreamUrl(bestFormat, videoId).getOrNull() + + if (currentStreamUrl != null && validateStatus(currentStreamUrl)) { + streamUrl = currentStreamUrl + format = bestFormat + streamExpiresInSeconds = streamPlayerResponse.streamingData?.expiresInSeconds ?: 3600 + break + } + + } catch (e: Exception) { + lastError = e + delay(500) + } + } + + if (streamUrl == null || format == null) { + throw PlaybackException( + "Sorry, buddy, despite all attempts, a valid publication URL could not be obtained...", + lastError, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED // Bu hata kodu daha anlamlı + ) + } + + PlaybackData( + audioConfig, + videoDetails, + playbackTracking, + format, + streamUrl, + streamExpiresInSeconds!! + ) + } + + suspend fun playerResponseForMetadata( + videoId: String, + playlistId: String? = null + ): Result = + YouTube.player(videoId, playlistId, client = WEB_REMIX) + + private fun validateStatus(url: String): Boolean { + return try { + val request = Request.Builder() + .head() + .url(url) + .build() + val response = httpClient.newCall(request).execute() + response.use { it.isSuccessful } + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/AlbumViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/AlbumViewModel.kt new file mode 100644 index 0000000..4e35056 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/AlbumViewModel.kt @@ -0,0 +1,49 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AlbumViewModel @Inject constructor( + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val albumId = savedStateHandle.get("albumId")!! + val albumWithSongs = database.albumWithSongs(albumId) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + val otherVersions = MutableStateFlow>(emptyList()) + + init { + viewModelScope.launch { + val album = database.album(albumId).first() + YouTube.album(albumId).onSuccess { + if (album == null || album.album.songCount == 0) { + database.transaction { + if (album == null) insert(it) + else update(album.album, it) + } + } + otherVersions.value = it.otherVersions + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + album?.album?.let(::delete) + } + } + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistItemsViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistItemsViewModel.kt new file mode 100644 index 0000000..6740901 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistItemsViewModel.kt @@ -0,0 +1,71 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.models.ItemsPage +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.BrowseEndpoint +import com.babelsoftware.innertube.models.filterExplicit +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ArtistItemsViewModel @Inject constructor( + @ApplicationContext val context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val browseId = savedStateHandle.get("browseId")!! + private val params = savedStateHandle.get("params") + + val title = MutableStateFlow("") + val itemsPage = MutableStateFlow(null) + + init { + viewModelScope.launch { + YouTube.artistItems( + BrowseEndpoint( + browseId = browseId, + params = params + ) + ).onSuccess { artistItemsPage -> + title.value = artistItemsPage.title + itemsPage.value = ItemsPage( + items = artistItemsPage.items.distinctBy { it.id }, + continuation = artistItemsPage.continuation + ) + }.onFailure { + reportException(it) + } + } + } + + fun loadMore() { + viewModelScope.launch { + val oldItemsPage = itemsPage.value ?: return@launch + val continuation = oldItemsPage.continuation ?: return@launch + YouTube.artistItemsContinuation(continuation) + .onSuccess { artistItemsContinuationPage -> + itemsPage.update { + ItemsPage( + items = (oldItemsPage.items + artistItemsContinuationPage.items) + .distinctBy { it.id } + .filterExplicit(context.dataStore.get(HideExplicitKey, false)), + continuation = artistItemsContinuationPage.continuation + ) + } + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistViewModel.kt new file mode 100644 index 0000000..bea5906 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ArtistViewModel.kt @@ -0,0 +1,47 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.pages.ArtistPage +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ArtistViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val artistId = savedStateHandle.get("artistId")!! + var artistPage by mutableStateOf(null) + val libraryArtist = database.artist(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val librarySongs = database.artistSongsPreview(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch { + YouTube.artist(artistId) + .onSuccess { + artistPage = it.filterExplicit(context.dataStore.get(HideExplicitKey, false)) + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/AutoPlaylistViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/AutoPlaylistViewModel.kt new file mode 100644 index 0000000..886e875 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/AutoPlaylistViewModel.kt @@ -0,0 +1,84 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download +import com.babelsoftware.loudly.constants.SongSortDescendingKey +import com.babelsoftware.loudly.constants.SongSortType +import com.babelsoftware.loudly.constants.SongSortTypeKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.extensions.reversed +import com.babelsoftware.loudly.extensions.toEnum +import com.babelsoftware.loudly.playback.DownloadUtil +import com.babelsoftware.loudly.utils.SyncUtils +import com.babelsoftware.loudly.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class AutoPlaylistViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + downloadUtil: DownloadUtil, + savedStateHandle: SavedStateHandle, + private val syncUtils: SyncUtils, +) : ViewModel() { + val playlist = savedStateHandle.get("playlist")!! + + @OptIn(ExperimentalCoroutinesApi::class) + val likedSongs = + context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] + ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + when (playlist) { + "liked" -> database.likedSongs(sortType, descending) + "downloaded" -> downloadUtil.downloads.flatMapLatest { downloads -> + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + when (sortType) { + SongSortType.CREATE_DATE -> songs.sortedBy { + downloads[it.id]?.updateTimeMs ?: 0L + } + + SongSortType.NAME -> songs.sortedBy { it.song.title } + SongSortType.ARTIST -> songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + + SongSortType.PLAY_TIME -> songs.sortedBy { it.song.totalPlayTime } + }.reversed(descending) + } + } + + else -> MutableStateFlow(emptyList()) + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun syncLikedSongs() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/BackupRestoreViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/BackupRestoreViewModel.kt new file mode 100644 index 0000000..bda3b73 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/BackupRestoreViewModel.kt @@ -0,0 +1,157 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.ViewModel +import com.babelsoftware.loudly.MainActivity +import com.babelsoftware.loudly.constants.ScannerSensitivity +import com.babelsoftware.loudly.db.InternalDatabase +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.extensions.div +import com.babelsoftware.loudly.extensions.tryOrNull +import com.babelsoftware.loudly.extensions.zipInputStream +import com.babelsoftware.loudly.extensions.zipOutputStream +import com.babelsoftware.loudly.playback.MusicService +import com.babelsoftware.loudly.R +import com.babelsoftware.loudly.ui.utils.compareSong +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.util.zip.ZipEntry +import javax.inject.Inject +import kotlin.system.exitProcess + +@HiltViewModel +class BackupRestoreViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + fun backup(context: Context, uri: Uri) { + runCatching { + context.applicationContext.contentResolver.openOutputStream(uri)?.use { + it.buffered().zipOutputStream().use { outputStream -> + (context.filesDir / "datastore" / SETTINGS_FILENAME).inputStream().buffered().use { inputStream -> + outputStream.putNextEntry(ZipEntry(SETTINGS_FILENAME)) + inputStream.copyTo(outputStream) + } + runBlocking(Dispatchers.IO) { + database.checkpoint() + } + FileInputStream(database.openHelper.writableDatabase.path).use { inputStream -> + outputStream.putNextEntry(ZipEntry(InternalDatabase.Companion.DB_NAME)) + inputStream.copyTo(outputStream) + } + } + } + }.onSuccess { + Toast.makeText(context, R.string.backup_create_success, Toast.LENGTH_SHORT).show() + }.onFailure { + reportException(it) + Toast.makeText(context, R.string.backup_create_failed, Toast.LENGTH_SHORT).show() + } + } + + fun restore(context: Context, uri: Uri) { + runCatching { + context.applicationContext.contentResolver.openInputStream(uri)?.use { + it.zipInputStream().use { inputStream -> + var entry = tryOrNull { inputStream.nextEntry } + while (entry != null) { + when (entry.name) { + SETTINGS_FILENAME -> { + (context.filesDir / "datastore" / SETTINGS_FILENAME).outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + InternalDatabase.Companion.DB_NAME -> { + runBlocking(Dispatchers.IO) { + database.checkpoint() + } + database.close() + FileOutputStream(database.openHelper.writableDatabase.path).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + entry = tryOrNull { inputStream.nextEntry } + } + } + } + context.stopService(Intent(context, MusicService::class.java)) + context.filesDir.resolve(MusicService.Companion.PERSISTENT_QUEUE_FILE).delete() + context.startActivity(Intent(context, MainActivity::class.java)) + exitProcess(0) + }.onFailure { + reportException(it) + Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() + } + } + + fun loadM3u( + context: Context, + uri: Uri, + matchStrength: ScannerSensitivity = ScannerSensitivity.LEVEL_2 + ): Pair, ArrayList> { + val songs = ArrayList() + val rejectedSongs = ArrayList() + + runCatching { + context.applicationContext.contentResolver.openInputStream(uri)?.use { stream -> + val lines = stream.readLines() + if (lines.first().startsWith("#EXTM3U")) { + lines.forEachIndexed { index, rawLine -> + if (rawLine.startsWith("#EXTINF:")) { + val artists = + rawLine.substringAfter("#EXTINF:").substringAfter(',').substringBefore(" - ").split(';') + val title = rawLine.substringAfter("#EXTINF:").substringAfter(',').substringAfter(" - ") + + val mockSong = Song( + song = SongEntity( + id = "", + title = title, + isLocal = true, + localPath = if (index + 1 < lines.size) lines[index + 1] else "" + ), + artists = artists.map { ArtistEntity("", it) }, + ) + val matches = database.searchSongs(title) + val oldSize = songs.size + runBlocking { + matches.first().forEach { + if (compareSong(mockSong, it, matchStrength = matchStrength)) { + songs.add(it) + } + } + } + + if (oldSize == songs.size) { + rejectedSongs.add(rawLine) + } + } + } + } + } + }.onFailure { + reportException(it) + Toast.makeText(context, R.string.m3u_import_failed, Toast.LENGTH_SHORT).show() + } + return Pair(songs, rejectedSongs) + } + private fun InputStream.readLines(): List { + return this.bufferedReader().useLines { it.toList() } + } + + companion object { + const val SETTINGS_FILENAME = "settings.preferences_pb" + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/CachePlaylistViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/CachePlaylistViewModel.kt new file mode 100644 index 0000000..1844385 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/CachePlaylistViewModel.kt @@ -0,0 +1,67 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.Song +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import javax.inject.Inject +import com.babelsoftware.loudly.di.PlayerCache +import com.babelsoftware.loudly.di.DownloadCache +import androidx.media3.datasource.cache.SimpleCache +import java.time.LocalDateTime + +@HiltViewModel +class CachePlaylistViewModel @Inject constructor( + private val database: MusicDatabase, + @PlayerCache private val playerCache: SimpleCache, + @DownloadCache private val downloadCache: SimpleCache +) : ViewModel() { + + private val _cachedSongs = MutableStateFlow>(emptyList()) + val cachedSongs: StateFlow> = _cachedSongs + + init { + viewModelScope.launch { + while (true) { + val cachedIds = playerCache.keys.map { it }.toSet() + val downloadedIds = downloadCache.keys.map { it }.toSet() + val pureCacheIds = cachedIds.subtract(downloadedIds) + + val songs = if (pureCacheIds.isNotEmpty()) { + database.getSongsByIds(pureCacheIds.toList()) + } else { + emptyList() + } + + val completeSongs = songs.filter { + val contentLength = it.format?.contentLength + contentLength != null && playerCache.isCached(it.song.id, 0, contentLength) + } + + if (completeSongs.isNotEmpty()) { + database.query { + completeSongs.forEach { + if (it.song.dateDownload == null) { + update(it.song.copy(dateDownload = LocalDateTime.now())) + } + } + } + } + + _cachedSongs.value = completeSongs + .filter { it.song.dateDownload != null } + .sortedByDescending { it.song.dateDownload } + + delay(1000) + } + } + } + + fun removeSongFromCache(songId: String) { + playerCache.removeResource(songId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/ExploreViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ExploreViewModel.kt new file mode 100644 index 0000000..dfc4c1a --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ExploreViewModel.kt @@ -0,0 +1,62 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.filterExplicit +import com.babelsoftware.innertube.pages.ExplorePage +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import com.babelsoftware.loudly.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ExploreViewModel @Inject constructor( + @ApplicationContext val context: Context, + val database: MusicDatabase, +) : ViewModel() { + val explorePage = MutableStateFlow(null) + + private suspend fun load() { + YouTube.explore().onSuccess { page -> + val artists: Set + val favouriteArtists: Set + database.artistsByCreateDateAsc().first().let { list -> + artists = list.map(Artist::id).toHashSet() + favouriteArtists = list + .filter { it.artist.bookmarkedAt != null } + .map { it.id } + .toHashSet() + } + explorePage.value = + page.copy( + newReleaseAlbums = + page.newReleaseAlbums + .sortedBy { album -> + if (album.artists.orEmpty().any { it.id in favouriteArtists }) 0 + else if (album.artists.orEmpty().any { it.id in artists }) 1 + else 2 + } + .filterExplicit(context.dataStore.get(HideExplicitKey, false)) + ) + }.onFailure { + reportException(it) + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + load() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/HistoryViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/HistoryViewModel.kt new file mode 100644 index 0000000..b344ea4 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/HistoryViewModel.kt @@ -0,0 +1,77 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HistorySource +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.pages.HistoryPage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + private val today = LocalDate.now() + private val thisMonday = today.with(DayOfWeek.MONDAY) + private val lastMonday = thisMonday.minusDays(7) + var historySource = MutableStateFlow(HistorySource.LOCAL) + val historyPage = mutableStateOf(null) + + val events = database.events() + .map { events -> + events.groupBy { + val date = it.event.timestamp.toLocalDate() + val daysAgo = ChronoUnit.DAYS.between(date, today).toInt() + when { + daysAgo == 0 -> DateAgo.Today + daysAgo == 1 -> DateAgo.Yesterday + date >= thisMonday -> DateAgo.ThisWeek + date >= lastMonday -> DateAgo.LastWeek + else -> DateAgo.Other(date.withDayOfMonth(1)) + } + }.toSortedMap(compareBy { dateAgo -> + when (dateAgo) { + DateAgo.Today -> 0L + DateAgo.Yesterday -> 1L + DateAgo.ThisWeek -> 2L + DateAgo.LastWeek -> 3L + is DateAgo.Other -> ChronoUnit.DAYS.between(dateAgo.date, today) + } + }).mapValues { entry -> + entry.value.distinctBy { it.song.id } + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyMap()) + init { + viewModelScope.launch(Dispatchers.IO) { + historyPage.value = YouTube.musicHistory().getOrNull() + } + } +} + +sealed class DateAgo { + data object Today : DateAgo() + data object Yesterday : DateAgo() + data object ThisWeek : DateAgo() + data object LastWeek : DateAgo() + class Other(val date: LocalDate) : DateAgo() { + override fun equals(other: Any?): Boolean { + if (other is Other) return date == other.date + return super.equals(other) + } + + override fun hashCode(): Int = date.hashCode() + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/HomeViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/HomeViewModel.kt new file mode 100644 index 0000000..0ebd25d --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/HomeViewModel.kt @@ -0,0 +1,229 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.constants.PlaylistSortType +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.LocalItem +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.models.SimilarRecommendation +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.WatchEndpoint +import com.babelsoftware.innertube.models.YTItem +import com.babelsoftware.innertube.models.filterExplicit +import com.babelsoftware.innertube.pages.ExplorePage +import com.babelsoftware.innertube.pages.HomePage +import com.babelsoftware.loudly.utils.SyncUtils +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + @ApplicationContext val context: Context, + private val syncUtils: SyncUtils, + val database: MusicDatabase, +) : ViewModel() { + val isRefreshing = MutableStateFlow(false) + val isLoading = MutableStateFlow(false) + + val quickPicks = MutableStateFlow?>(null) + val forgottenFavorites = MutableStateFlow?>(null) + val keepListening = MutableStateFlow?>(null) + val similarRecommendations = MutableStateFlow?>(null) + val accountPlaylists = MutableStateFlow?>(null) + val homePage = MutableStateFlow(null) + val explorePage = MutableStateFlow(null) + val selectedChip = MutableStateFlow(null) + val playlists = database.playlists( + PlaylistSortType.CREATE_DATE, descending = false + ).stateIn(viewModelScope, SharingStarted.Lazily, null) + val recentActivity = database.recentActivity() + .stateIn(viewModelScope, SharingStarted.Lazily, null) + private val previousHomePage = MutableStateFlow(null) + + private val allLocalItems = MutableStateFlow>(emptyList()) + private val allYtItems = MutableStateFlow>(emptyList()) + + private val _isLoadingMore = MutableStateFlow(false) + fun loadMoreYouTubeItems(continuation: String?) { + if (continuation == null || _isLoadingMore.value) return + + viewModelScope.launch(Dispatchers.IO) { + _isLoadingMore.value = true + val nextSections = YouTube.home(continuation).getOrNull() ?: run { + _isLoadingMore.value = false + return@launch + } + homePage.value = nextSections.copy( + chips = homePage.value?.chips, + sections = homePage.value?.sections.orEmpty() + nextSections.sections + ) + _isLoadingMore.value = false + } + } + + private suspend fun load() { + isLoading.value = true + + val hideExplicit = context.dataStore.get(HideExplicitKey, false) + + quickPicks.value = database.quickPicks() + .first().shuffled().take(20) + + forgottenFavorites.value = database.forgottenFavorites() + .first().shuffled().take(20) + + val fromTimeStamp = System.currentTimeMillis() - 86400000 * 7 * 2 + val keepListeningSongs = database.mostPlayedSongsWithPlayCount(fromTimeStamp, limit = 15) + .first() + .map { it.song } + .shuffled() + .take(10) + val keepListeningAlbums = database.mostPlayedAlbums(fromTimeStamp, limit = 8, offset = 2) + .first().filter { it.album.thumbnailUrl != null }.shuffled().take(5) + val keepListeningArtists = database.mostPlayedArtists(fromTimeStamp) + .first().filter { it.artist.isYouTubeArtist && it.artist.thumbnailUrl != null }.shuffled().take(5) + keepListening.value = (keepListeningSongs + keepListeningAlbums + keepListeningArtists).shuffled() + + allLocalItems.value = (quickPicks.value.orEmpty() + forgottenFavorites.value.orEmpty() + keepListening.value.orEmpty()) + .filter { it is Song || it is Album } + + + if (YouTube.cookie != null) { + YouTube.likedPlaylists().onSuccess { + accountPlaylists.value = it + }.onFailure { + reportException(it) + } + } + + val artistRecommendations = + database.mostPlayedArtists(fromTimeStamp, limit = 10).first() + .filter { it.artist.isYouTubeArtist } + .shuffled().take(3) + .mapNotNull { artistWithData -> + val items = mutableListOf() + YouTube.artist(artistWithData.artist.id).onSuccess { page -> + items += page.sections.getOrNull(page.sections.size - 2)?.items.orEmpty() + items += page.sections.lastOrNull()?.items.orEmpty() + } + SimilarRecommendation( + title = artistWithData, + items = items + .filterExplicit(hideExplicit) + .shuffled() + .ifEmpty { return@mapNotNull null } + ) + } + val songRecommendations = + database.mostPlayedSongsWithPlayCount(fromTimeStamp, limit = 10).first() + .filter { it.song.album != null } + .shuffled().take(2) + .mapNotNull { songWithCount -> + val endpoint = YouTube.next(WatchEndpoint(videoId = songWithCount.song.id)).getOrNull()?.relatedEndpoint ?: return@mapNotNull null + val page = YouTube.related(endpoint).getOrNull() ?: return@mapNotNull null + SimilarRecommendation( + title = songWithCount.song, + items = (page.songs.shuffled().take(8) + + page.albums.shuffled().take(4) + + page.artists.shuffled().take(4) + + page.playlists.shuffled().take(4)) + .filterExplicit(hideExplicit) + .shuffled() + .ifEmpty { return@mapNotNull null } + ) + } + similarRecommendations.value = (artistRecommendations + songRecommendations).shuffled() + + YouTube.home().onSuccess { page -> + homePage.value = page.filterExplicit(hideExplicit) + }.onFailure { + reportException(it) + } + + YouTube.explore().onSuccess { page -> + val artists: Set + val favouriteArtists: Set + database.artistsByCreateDateAsc().first().let { list -> + artists = list.map(Artist::id).toHashSet() + favouriteArtists = list + .filter { it.artist.bookmarkedAt != null } + .map { it.id } + .toHashSet() + } + explorePage.value = page.copy( + newReleaseAlbums = page.newReleaseAlbums + .sortedBy { album -> + if (album.artists.orEmpty().any { it.id in favouriteArtists }) 0 + else if (album.artists.orEmpty().any { it.id in artists }) 1 + else 2 + } + .filterExplicit(hideExplicit) + ) + }.onFailure { + reportException(it) + } + + syncUtils.syncRecentActivity() + + allYtItems.value = similarRecommendations.value?.flatMap { it.items }.orEmpty() + + homePage.value?.sections?.flatMap { it.items }.orEmpty() + + explorePage.value?.newReleaseAlbums.orEmpty() + + isLoading.value = false + } + + fun refresh() { + if (isRefreshing.value) return + viewModelScope.launch(Dispatchers.IO) { + isRefreshing.value = true + load() + isRefreshing.value = false + } + } + + fun toggleChip(chip: HomePage.Chip?) { + if (chip == null || chip == selectedChip.value && previousHomePage.value != null) { + homePage.value = previousHomePage.value + previousHomePage.value = null + selectedChip.value = null + return + } + if (selectedChip.value == null) { + previousHomePage.value = homePage.value + } + viewModelScope.launch(Dispatchers.IO) { + val nextSections = YouTube.home(params = chip.endpoint?.params).getOrNull() ?: return@launch + homePage.value = nextSections.copy( + chips = homePage.value?.chips, + sections = nextSections.sections + ) + selectedChip.value = chip + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + load() + } + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncSavedPlaylists() } + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedAlbums() } + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncArtistsSubscriptions() } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/ImportFromSpotifyViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ImportFromSpotifyViewModel.kt new file mode 100644 index 0000000..aea859b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/ImportFromSpotifyViewModel.kt @@ -0,0 +1,516 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.ArtistEntity +import com.babelsoftware.loudly.db.entities.PlaylistEntity +import com.babelsoftware.loudly.db.entities.PlaylistSongMap +import com.babelsoftware.loudly.db.entities.SongArtistMap +import com.babelsoftware.loudly.db.entities.SongEntity +import com.babelsoftware.loudly.models.SpotifyAuthResponse +import com.babelsoftware.loudly.models.SpotifyUserProfile +import com.babelsoftware.loudly.models.spotify.playlists.SpotifyPlaylistPaginatedResponse +import com.babelsoftware.loudly.models.spotify.tracks.SpotifyResultPaginatedResponse +import com.babelsoftware.loudly.models.spotify.tracks.TrackItem +import com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.model.ImportFromSpotifyScreenState +import com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.model.ImportProgressEvent +import com.babelsoftware.loudly.ui.screens.settings.import_from_spotify.model.Playlist +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.SongItem +import dagger.hilt.android.lifecycle.HiltViewModel +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.basicAuth +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.forms.FormDataContent +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.Parameters +import io.ktor.http.isSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject +import kotlin.collections.forEach + +@HiltViewModel +class ImportFromSpotifyViewModel @Inject constructor( + private val httpClient: HttpClient, private val localDatabase: MusicDatabase +) : ViewModel() { + val importFromSpotifyScreenState = mutableStateOf( + ImportFromSpotifyScreenState( + isRequesting = false, + accessToken = "", + error = false, + exception = null, + userName = "", + isObtainingAccessTokenSuccessful = false, + playlists = emptyList(), + totalPlaylistsCount = 0, + reachedEndForPlaylistPagination = false + ) + ) + val selectedPlaylists = mutableStateListOf() + val isLikedSongsSelectedForImport = mutableStateOf(false) + val isImportingCompleted = mutableStateOf(false) + val isImportingInProgress = mutableStateOf(false) + fun spotifyLoginAndFetchPlaylists( + clientId: String, clientSecret: String, authorizationCode: String, context: Context + ) { + viewModelScope.launch { + try { + importFromSpotifyScreenState.value = importFromSpotifyScreenState.value.copy( + isRequesting = true, + error = false, + exception = null, + accessToken = "", + userName = "", + isObtainingAccessTokenSuccessful = false, + playlists = emptyList() + ) + getSpotifyAccessTokenDataResponse( + clientId = clientId, + clientSecret = clientSecret, + authorizationCode = authorizationCode, + context = context + ).onSuccess { it -> + it.let { response -> + if (response.status.isSuccess()) { + importFromSpotifyScreenState.value = + importFromSpotifyScreenState.value.copy( + accessToken = response.body().accessToken, + isRequesting = false, + isObtainingAccessTokenSuccessful = true + ) + + logTheString(importFromSpotifyScreenState.value.accessToken) + + getUserProfileFromSpotify( + importFromSpotifyScreenState.value.accessToken, context + ).let { + importFromSpotifyScreenState.value = + importFromSpotifyScreenState.value.copy( + userName = it.displayName + ) + } + + getPlaylists( + importFromSpotifyScreenState.value.accessToken, context + ).let { + importFromSpotifyScreenState.value = + importFromSpotifyScreenState.value.copy( + playlists = importFromSpotifyScreenState.value.playlists + it.items, + totalPlaylistsCount = importFromSpotifyScreenState.value.totalPlaylistsCount, + reachedEndForPlaylistPagination = it.nextUrl == null + ) + } + + } else { + throw Exception("Request failed with status code : ${response.status.value}\n${response.bodyAsText()}") + } + } + } + } catch (e: Exception) { + importFromSpotifyScreenState.value = importFromSpotifyScreenState.value.copy( + isRequesting = false, + error = true, + exception = e, + accessToken = "", + isObtainingAccessTokenSuccessful = false + ) + } + } + } + + private var paginatedResultsLimit = 50 + private var playListPaginationOffset = -paginatedResultsLimit + + fun retrieveNextPageOfPlaylists(context: Context) { + viewModelScope.launch { + importFromSpotifyScreenState.value = + importFromSpotifyScreenState.value.copy(isRequesting = true) + getPlaylists(importFromSpotifyScreenState.value.accessToken, context).let { + importFromSpotifyScreenState.value = importFromSpotifyScreenState.value.copy( + playlists = importFromSpotifyScreenState.value.playlists + it.items, + totalPlaylistsCount = it.totalResults ?: 0, + reachedEndForPlaylistPagination = it.nextUrl == null, + isRequesting = false + ) + } + } + } + + val selectedAllPlaylists = mutableStateOf(false) + fun selectAllPlaylists(context: Context, onCompletion: () -> Unit) { + isLikedSongsSelectedForImport.value = true + selectedAllPlaylists.value = false + selectedPlaylists.clear() + viewModelScope.launch { + selectedPlaylists.addAll(fetchAllPlaylists(importFromSpotifyScreenState.value.accessToken)) + }.invokeOnCompletion { + selectedAllPlaylists.value = true + onCompletion() + viewModelScope.launch { + withContext(Dispatchers.Main) { + Toast.makeText( + context, "Selected all playlists successfully", Toast.LENGTH_SHORT + ).show() + } + } + } + } + + private suspend fun fetchAllPlaylists( + authToken: String + ): List { + var url: String? = "https://api.spotify.com/v1/me/playlists" + val playlists = mutableListOf() + + while (url != null) { + httpClient.get(url) { + bearerAuth(authToken) + }.body().let { response -> + playlists.addAll(response.items.map { + Playlist( + name = it.playlistName, id = it.playlistId + ) + }) + logTheString(url.toString()) + url = response.nextUrl + } + } + return playlists + } + + private suspend fun getPlaylists( + authToken: String, context: Context + ): SpotifyPlaylistPaginatedResponse { + playListPaginationOffset += paginatedResultsLimit + return try { + httpClient.get("https://api.spotify.com/v1/me/playlists?offset=${playListPaginationOffset}&limit=$paginatedResultsLimit") { + bearerAuth(authToken) + }.body() + } catch (e: Exception) { + Toast.makeText(context, e.message.toString(), Toast.LENGTH_SHORT).show() + SpotifyPlaylistPaginatedResponse() + } + } + + private suspend fun getLikedSongsFromSpotify( + authToken: String, url: String, context: Context + ): SpotifyResultPaginatedResponse { + return try { + httpClient.get(url) { + bearerAuth(authToken) + }.body() + } catch (e: Exception) { + Toast.makeText(context, e.message.toString(), Toast.LENGTH_SHORT).show() + SpotifyResultPaginatedResponse( + totalCountOfLikedSongs = 0, nextPaginatedUrl = null, tracks = listOf() + ) + } + } + + private suspend fun getUserProfileFromSpotify( + authToken: String, context: Context + ): SpotifyUserProfile { + return try { + httpClient.get("https://api.spotify.com/v1/me") { + bearerAuth(authToken) + }.body() + } catch (e: Exception) { + Toast.makeText(context, e.message.toString(), Toast.LENGTH_SHORT).show() + SpotifyUserProfile(displayName = "") + } + } + + private suspend fun getSpotifyAccessTokenDataResponse( + authorizationCode: String, clientId: String, clientSecret: String, context: Context + ): Result { + return try { + Result.success(httpClient.post(urlString = "https://accounts.spotify.com/api/token") { + basicAuth(clientId, clientSecret) + setBody( + FormDataContent(Parameters.build { + append("grant_type", "authorization_code") + append("code", authorizationCode) + append("redirect_uri", "http://127.0.0.1:45454") + }) + ) + }) + } catch (e: Exception) { + Toast.makeText(context, e.message.toString(), Toast.LENGTH_SHORT).show() + Result.failure(e) + } + } + + private fun logTheString(string: String) { + Timber.tag("Loudly Log").d(string) + } + + + private val _likedSongsImportProgress = MutableStateFlow( + ImportProgressEvent.LikedSongsProgress( + completed = false, currentCount = 0, totalTracksCount = 0 + ) + ) + val importLogs = mutableStateListOf() + + private val _playlistsImportProgress = MutableStateFlow( + ImportProgressEvent.PlaylistsProgress( + completed = false, + playlistName = "", + progressedTrackCount = 0, + totalTracksCount = 0, + currentPlaylistIndex = 0, + + ) + ) + + private var progressedTracksInAPlaylistCount = 0 + + init { + viewModelScope.launch { + _playlistsImportProgress.collectLatest { it -> + "Importing playlist \"${it.playlistName}\" – ${it.progressedTrackCount}/${it.totalTracksCount} tracks completed".let { + importLogs.add(it) + logTheString(it) + } + } + } + viewModelScope.launch { + _likedSongsImportProgress.collectLatest { it -> + "Importing Liked Songs – ${it.currentCount} of ${it.totalTracksCount} completed".let { + importLogs.add(it) + logTheString(it) + } + } + } + } + + fun importSelectedItems(saveInDefaultLikedSongs: Boolean?, context: Context) { + importLogs.clear() + isImportingCompleted.value = false + isImportingInProgress.value = true + viewModelScope.launch(Dispatchers.IO) { + supervisorScope { + logTheString("Starting the import process") + val likedSongsJob = launch { + saveInDefaultLikedSongs?.let { + importSpotifyLikedSongs(it, context) + } + } + + val playlistsJob = launch { + importPlaylists( + selectedPlaylists, importFromSpotifyScreenState.value.accessToken + ) + } + likedSongsJob.join() + playlistsJob.join() + logTheString("Import Succeeded!") + isImportingCompleted.value = true + isImportingInProgress.value = false + } + } + } + + private suspend fun importPlaylists( + selectedPlaylists: List, authToken: String + ) = supervisorScope { + selectedPlaylists.forEachIndexed { playlistIndex, playlist -> + progressedTracksInAPlaylistCount = 0 + val generatedPlaylistId = PlaylistEntity.Companion.generatePlaylistId() + localDatabase.insert( + PlaylistEntity( + id = generatedPlaylistId, name = playlist.name + ) + ) + getTracksFromAPlaylist( + spotifyPlaylistId = playlist.id, authToken = authToken + ).let { trackItems -> + val trackJobs = mutableListOf() + trackItems.forEach { trackItem -> + trackJobs.add(launch { + val youtubeSearchResult = YouTube.search( + query = trackItem.trackName + " " + trackItem.artists.first().name, + filter = YouTube.SearchFilter.FILTER_SONG + ) + youtubeSearchResult.onSuccess { result -> + if (result.items.isEmpty()) { + return@onSuccess + } + val firstSong = result.items.first() as SongItem + firstSong.artists.forEachIndexed { index, artist -> + artist.id?.let { artistId -> + try { + if (localDatabase.artistIdExists(artistId).not()) { + YouTube.artist(artistId).onSuccess { artistPage -> + localDatabase.insert( + ArtistEntity( + id = artistId, + name = artistPage.artist.title + ) + ) + } + } + localDatabase.insert( + SongArtistMap( + songId = firstSong.id, + artistId = artistId, + position = index + ) + ) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + localDatabase.insert( + SongEntity( + id = firstSong.id, + thumbnailUrl = firstSong.thumbnail.getOriginalSizeThumbnail(), + title = firstSong.title, + localPath = null + ) + ) + localDatabase.insert( + PlaylistSongMap( + playlistId = generatedPlaylistId, songId = firstSong.id + ) + ) + _playlistsImportProgress.emit( + ImportProgressEvent.PlaylistsProgress( + completed = false, + progressedTrackCount = ++progressedTracksInAPlaylistCount, + playlistName = playlist.name, + totalTracksCount = trackItems.size, + currentPlaylistIndex = playlistIndex + ) + ) + } + }) + } + trackJobs.joinAll() + } + } + _playlistsImportProgress.emit( + ImportProgressEvent.PlaylistsProgress( + completed = true, + playlistName = "", + progressedTrackCount = 0, + totalTracksCount = 0, + currentPlaylistIndex = 0, + ) + ) + } + + private suspend fun getTracksFromAPlaylist( + spotifyPlaylistId: String, authToken: String + ): List { + var url: String? = "https://api.spotify.com/v1/playlists/$spotifyPlaylistId/tracks" + val tracks: MutableList = mutableListOf() + while (url != null) { + try { + httpClient.get(url) { + bearerAuth(authToken) + }.body().let { it -> + tracks.addAll(it.tracks.map { it.trackItem }) + url = it.nextPaginatedUrl + } + } catch (e: Exception) { + url = null + e.printStackTrace() + } + } + return tracks + } + + private var progressedTracksInTheLikedSongsCount = 0 + + private val progressMutex = Mutex() + private val dbMutex = Mutex() + + private suspend fun importSpotifyLikedSongs( + saveInDefaultLikedSongs: Boolean, context: Context + ): Unit = supervisorScope { + var url: String? = "https://api.spotify.com/v1/me/tracks?offset=0&limit=50" + var totalSongsCount = -1 + val progressedTracks = AtomicInteger(0) + + while (url != null) { + getLikedSongsFromSpotify( + authToken = importFromSpotifyScreenState.value.accessToken, + url = url, + context = context + ).let { response -> + totalSongsCount = response.totalCountOfLikedSongs + response.tracks.map { likedSong -> + async { + YouTube.search( + query = "${likedSong.trackItem.trackName} ${likedSong.trackItem.artists.first().name}", + filter = YouTube.SearchFilter.FILTER_SONG + ).onSuccess { result -> + result.items.firstOrNull()?.let { songItem -> + songItem as SongItem + dbMutex.withLock { + withContext(Dispatchers.IO) { + localDatabase.insert( + SongEntity( + id = songItem.id, + title = songItem.title, + liked = saveInDefaultLikedSongs, + thumbnailUrl = songItem.thumbnail.getOriginalSizeThumbnail(), + localPath = null + ) + ) + } + } + progressMutex.withLock { + _likedSongsImportProgress.emit( + ImportProgressEvent.LikedSongsProgress( + completed = false, + currentCount = progressedTracks.incrementAndGet(), + totalTracksCount = totalSongsCount + ) + ) + } + } + } + } + }.awaitAll() + url = response.nextPaginatedUrl + } + } + _likedSongsImportProgress.emit( + ImportProgressEvent.LikedSongsProgress( + completed = true, + currentCount = progressedTracksInTheLikedSongsCount, + totalTracksCount = totalSongsCount + ) + ) + } +} + +private fun String.getOriginalSizeThumbnail(): String { + return this.substringBefore("=w") +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/LibraryViewModels.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LibraryViewModels.kt new file mode 100644 index 0000000..e93ed4b --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LibraryViewModels.kt @@ -0,0 +1,456 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.offline.Download +import com.babelsoftware.loudly.constants.AlbumFilter +import com.babelsoftware.loudly.constants.AlbumFilterKey +import com.babelsoftware.loudly.constants.AlbumSortDescendingKey +import com.babelsoftware.loudly.constants.AlbumSortType +import com.babelsoftware.loudly.constants.AlbumSortTypeKey +import com.babelsoftware.loudly.constants.ArtistFilter +import com.babelsoftware.loudly.constants.ArtistFilterKey +import com.babelsoftware.loudly.constants.ArtistSongSortDescendingKey +import com.babelsoftware.loudly.constants.ArtistSongSortType +import com.babelsoftware.loudly.constants.ArtistSongSortTypeKey +import com.babelsoftware.loudly.constants.ArtistSortDescendingKey +import com.babelsoftware.loudly.constants.ArtistSortType +import com.babelsoftware.loudly.constants.ArtistSortTypeKey +import com.babelsoftware.loudly.constants.PlaylistSortDescendingKey +import com.babelsoftware.loudly.constants.PlaylistSortType +import com.babelsoftware.loudly.constants.PlaylistSortTypeKey +import com.babelsoftware.loudly.constants.SongFilter +import com.babelsoftware.loudly.constants.SongFilterKey +import com.babelsoftware.loudly.constants.SongSortDescendingKey +import com.babelsoftware.loudly.constants.SongSortType +import com.babelsoftware.loudly.constants.SongSortTypeKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.Song +import com.babelsoftware.loudly.di.DownloadCache +import com.babelsoftware.loudly.di.PlayerCache +import com.babelsoftware.loudly.extensions.reversed +import com.babelsoftware.loudly.extensions.toEnum +import com.babelsoftware.loudly.playback.DownloadUtil +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.ui.utils.DirectoryTree +import com.babelsoftware.loudly.ui.utils.refreshLocal +import com.babelsoftware.loudly.utils.SyncUtils +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime +import java.util.Stack +import javax.inject.Inject + +@HiltViewModel +class LibrarySongsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + downloadUtil: DownloadUtil, + private val syncUtils: SyncUtils, + @PlayerCache private val playerCache: SimpleCache, + @DownloadCache private val downloadCache: SimpleCache +) : ViewModel() { + + private val _cachedSongs = MutableStateFlow>(emptyList()) + val cachedSongs: StateFlow> = _cachedSongs + + init { + viewModelScope.launch { + while (true) { + val cachedIds = playerCache.keys.map { it }.toSet() + val downloadedIds = downloadCache.keys.map { it }.toSet() + val pureCacheIds = cachedIds.subtract(downloadedIds) + + val songs = if (pureCacheIds.isNotEmpty()) { + database.getSongsByIds(pureCacheIds.toList()) + } else { + emptyList() + } + + val completeSongs = songs.filter { + val contentLength = it.format?.contentLength + contentLength != null && playerCache.isCached(it.song.id, 0, contentLength) + } + + if (completeSongs.isNotEmpty()) { + database.query { + completeSongs.forEach { + if (it.song.dateDownload == null) { + update(it.song.copy(dateDownload = LocalDateTime.now())) + } + } + } + } + + _cachedSongs.value = completeSongs + .filter { it.song.dateDownload != null } + .sortedByDescending { it.song.dateDownload } + + delay(1000) + } + } + } + + /** + * The top of the stack is the folder that the page will render. + * Clicking on a folder pushes, while the back button pops. + */ + var folderPositionStack = Stack() + val databaseLink = database + + val inLocal = mutableStateOf(false) + + val allSongs = syncAllSongs(context, database, downloadUtil) + val localSongDirectoryTree = refreshLocal(context, database) + + fun syncLikedSongs() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } + } + + + /** + * Get local songs + * + * @return DirectoryTree + */ + fun getLocalSongs(context: Context, database: MusicDatabase): MutableStateFlow { + val directoryStructure = refreshLocal(context, database).value + localSongDirectoryTree.value = directoryStructure + return MutableStateFlow(directoryStructure) + } + + fun syncAllSongs(context: Context, database: MusicDatabase, downloadUtil: DownloadUtil): StateFlow> { + + return context.dataStore.data + .map { + Triple( + it[SongFilterKey].toEnum(SongFilter.LIKED), + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE), + (it[SongSortDescendingKey] ?: true) + ) + } + .distinctUntilChanged() + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + SongFilter.LIBRARY -> database.songs(sortType, descending) + SongFilter.LIKED -> database.likedSongs(sortType, descending) + SongFilter.DOWNLOADED -> downloadUtil.downloads.flatMapLatest { downloads -> + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + when (sortType) { + SongSortType.CREATE_DATE -> songs.sortedBy { downloads[it.id]?.updateTimeMs ?: 0L } + SongSortType.NAME -> songs.sortedBy { it.song.title } + SongSortType.ARTIST -> songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + + SongSortType.PLAY_TIME -> songs.sortedBy { it.song.totalPlayTime } + }.reversed(descending) + } + } + SongFilter.CACHED -> cachedSongs + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + } +} + + +@HiltViewModel +class LibraryArtistsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + private val syncUtils: SyncUtils, +) : ViewModel() { + val allArtists = context.dataStore.data + .map { + Triple( + it[ArtistFilterKey].toEnum(ArtistFilter.LIKED), + it[ArtistSortTypeKey].toEnum(ArtistSortType.CREATE_DATE), + it[ArtistSortDescendingKey] ?: true + ) + } + .distinctUntilChanged() + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + ArtistFilter.LIBRARY -> database.artists(sortType, descending) + ArtistFilter.LIKED -> database.artistsBookmarked(sortType, descending) + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, null) + fun sync() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncArtistsSubscriptions() } } + + init { + viewModelScope.launch(Dispatchers.IO) { + allArtists.collect { artists -> + artists + ?.map { it.artist } + ?.filter { + it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) + } + ?.forEach { artist -> + YouTube.artist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + } +} + +@HiltViewModel +class LibraryAlbumsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + private val syncUtils: SyncUtils, +) : ViewModel() { + @OptIn(ExperimentalCoroutinesApi::class) + val allAlbums = context.dataStore.data + .map { + Triple( + it[AlbumFilterKey].toEnum(AlbumFilter.LIKED), + it[AlbumSortTypeKey].toEnum(AlbumSortType.CREATE_DATE), + it[AlbumSortDescendingKey] ?: true + ) + } + .distinctUntilChanged() + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + AlbumFilter.LIBRARY -> database.albums(sortType, descending) + AlbumFilter.LIKED -> database.albumsLiked(sortType, descending) + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, null) + fun sync() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedAlbums() } } + + init { + viewModelScope.launch(Dispatchers.IO) { + allAlbums.collect { albums -> + albums + ?.filter { + it.album.songCount == 0 + } + ?.forEach { album -> + YouTube.album(album.id).onSuccess { albumPage -> + database.query { + update(album.album, albumPage) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + delete(album.album) + } + } + } + } + } + } + } +} + +@HiltViewModel +class LibraryPlaylistsViewModel @Inject constructor( + @ApplicationContext context: Context, + downloadUtil: DownloadUtil, + database: MusicDatabase, + @PlayerCache private val playerCache: SimpleCache, + @DownloadCache private val downloadCache: SimpleCache, + private val syncUtils: SyncUtils, +) : ViewModel() { + private val _cachedSongs = MutableStateFlow>(emptyList()) + val cachedSongs: StateFlow> = _cachedSongs + + init { + viewModelScope.launch { + while (true) { + val cachedIds = playerCache.keys.map { it }.toSet() + val downloadedIds = downloadCache.keys.map { it }.toSet() + val pureCacheIds = cachedIds.subtract(downloadedIds) + + val songs = if (pureCacheIds.isNotEmpty()) { + database.getSongsByIds(pureCacheIds.toList()) + } else { + emptyList() + } + + val completeSongs = songs.filter { + val contentLength = it.format?.contentLength + contentLength != null && playerCache.isCached(it.song.id, 0, contentLength) + } + + if (completeSongs.isNotEmpty()) { + database.query { + completeSongs.forEach { + if (it.song.dateDownload == null) { + update(it.song.copy(dateDownload = LocalDateTime.now())) + } + } + } + } + + _cachedSongs.value = completeSongs + .filter { it.song.dateDownload != null } + .sortedByDescending { it.song.dateDownload } + + delay(1000) + } + } + } + val likedSongs = database.likedSongs(SongSortType.CREATE_DATE, true) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val downloadSongs = + downloadUtil.downloads.flatMapLatest { downloads -> + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + } + val topSongs = database.mostPlayedSongsWithPlayCount(0, 100) + val localSongsCount = database.localSongsCount() + .stateIn(viewModelScope, SharingStarted.Lazily, 0) + fun sync() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncSavedPlaylists() } } + val allPlaylists = context.dataStore.data + .map { + it[PlaylistSortTypeKey].toEnum(PlaylistSortType.CREATE_DATE) to (it[PlaylistSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.playlists(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, null) +} + +@HiltViewModel +class ArtistSongsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val artistId = savedStateHandle.get("artistId")!! + val artist = database.artist(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + val songs = context.dataStore.data + .map { + it[ArtistSongSortTypeKey].toEnum(ArtistSongSortType.CREATE_DATE) to (it[ArtistSongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.artistSongs(artistId, sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} + +@HiltViewModel +class LibraryMixViewModel @Inject constructor( + database: MusicDatabase, + downloadUtil: DownloadUtil, + private val syncUtils: SyncUtils, + @PlayerCache private val playerCache: SimpleCache, + @DownloadCache private val downloadCache: SimpleCache, +) : ViewModel() { + private val _cachedSongs = MutableStateFlow>(emptyList()) + val cachedSongs: StateFlow> = _cachedSongs + + init { + viewModelScope.launch { + while (true) { + val cachedIds = playerCache.keys.map { it }.toSet() + val downloadedIds = downloadCache.keys.map { it }.toSet() + val pureCacheIds = cachedIds.subtract(downloadedIds) + + val songs = if (pureCacheIds.isNotEmpty()) { + database.getSongsByIds(pureCacheIds.toList()) + } else { + emptyList() + } + + val completeSongs = songs.filter { + val contentLength = it.format?.contentLength + contentLength != null && playerCache.isCached(it.song.id, 0, contentLength) + } + + if (completeSongs.isNotEmpty()) { + database.query { + completeSongs.forEach { + if (it.song.dateDownload == null) { + update(it.song.copy(dateDownload = LocalDateTime.now())) + } + } + } + } + + _cachedSongs.value = completeSongs + .filter { it.song.dateDownload != null } + .sortedByDescending { it.song.dateDownload } + + delay(1000) + } + } + } + val syncAllLibrary = { + viewModelScope.launch(Dispatchers.IO) { + syncUtils.syncLikedSongs() + syncUtils.syncArtistsSubscriptions() + syncUtils.syncLikedAlbums() + syncUtils.syncSavedPlaylists() + } + } + var artists = + database + .artistsBookmarked( + ArtistSortType.CREATE_DATE, + true, + ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + var albums = database.albumsLiked(AlbumSortType.CREATE_DATE, true) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + var playlists = database.playlists(PlaylistSortType.CREATE_DATE, true) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val likedSongs = database.likedSongs(SongSortType.CREATE_DATE, true) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val downloadSongs = + downloadUtil.downloads.flatMapLatest { downloads -> + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + } + val topSongs = database.mostPlayedSongsWithPlayCount(0, 100) + val localSongsCount = database.localSongsCount() + .stateIn(viewModelScope, SharingStarted.Lazily, 0) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalPlaylistViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalPlaylistViewModel.kt new file mode 100644 index 0000000..2095e27 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalPlaylistViewModel.kt @@ -0,0 +1,66 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.PlaylistSongSortDescendingKey +import com.babelsoftware.loudly.constants.PlaylistSongSortType +import com.babelsoftware.loudly.constants.PlaylistSongSortTypeKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.extensions.reversed +import com.babelsoftware.loudly.extensions.toEnum +import com.babelsoftware.loudly.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LocalPlaylistViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val playlistId = savedStateHandle.get("playlistId")!! + val playlist = database.playlist(playlistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val playlistSongs = combine( + database.playlistSongs(playlistId), + context.dataStore.data + .map { + it[PlaylistSongSortTypeKey].toEnum(PlaylistSongSortType.CUSTOM) to (it[PlaylistSongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + ) { songs, (sortType, sortDescending) -> + when (sortType) { + PlaylistSongSortType.CUSTOM -> songs + PlaylistSongSortType.CREATE_DATE -> songs.sortedBy { it.map.id } + PlaylistSongSortType.NAME -> songs.sortedBy { it.song.song.title.lowercase() } + PlaylistSongSortType.ARTIST -> songs.sortedBy { song -> + song.song.artists.joinToString { it.name }.lowercase() + } + + PlaylistSongSortType.PLAY_TIME -> songs.sortedBy { it.song.song.totalPlayTime } + }.reversed(sortDescending && sortType != PlaylistSongSortType.CUSTOM) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch { + val sortedSongs = playlistSongs.first().sortedWith(compareBy({ it.map.position }, { it.map.id })) + database.transaction { + sortedSongs.forEachIndexed { index, song -> + if (song.map.position != index) { + update(song.map.copy(position = index)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalSearchViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalSearchViewModel.kt new file mode 100644 index 0000000..c3a93c5 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LocalSearchViewModel.kt @@ -0,0 +1,73 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.Album +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.loudly.db.entities.LocalItem +import com.babelsoftware.loudly.db.entities.Playlist +import com.babelsoftware.loudly.db.entities.Song +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class LocalSearchViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { + val query = MutableStateFlow("") + val filter = MutableStateFlow(LocalFilter.ALL) + + val result = combine(query, filter) { query, filter -> + query to filter + }.flatMapLatest { (query, filter) -> + if (query.isEmpty()) { + flowOf(LocalSearchResult("", filter, emptyMap())) + } else { + when (filter) { + LocalFilter.ALL -> combine( + database.searchSongs(query, PREVIEW_SIZE), + database.searchAlbums(query, PREVIEW_SIZE), + database.searchArtists(query, PREVIEW_SIZE), + database.searchPlaylists(query, PREVIEW_SIZE), + ) { songs, albums, artists, playlists -> + val list = songs + albums + artists + playlists + list.distinctBy { it.id } + } + LocalFilter.SONG -> database.searchSongs(query) + LocalFilter.ALBUM -> database.searchAlbums(query) + LocalFilter.ARTIST -> database.searchArtists(query) + LocalFilter.PLAYLIST -> database.searchPlaylists(query) + }.map { list -> + LocalSearchResult( + query = query, + filter = filter, + map = list.groupBy { + when (it) { + is Song -> LocalFilter.SONG + is Album -> LocalFilter.ALBUM + is Artist -> LocalFilter.ARTIST + is Playlist -> LocalFilter.PLAYLIST + } + }) + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, LocalSearchResult("", filter.value, emptyMap())) + + companion object { + const val PREVIEW_SIZE = 3 + } +} + +enum class LocalFilter { + ALL, SONG, ALBUM, ARTIST, PLAYLIST +} + +data class LocalSearchResult( + val query: String, + val filter: LocalFilter, + val map: Map>, +) \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/LyricsMenuViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LyricsMenuViewModel.kt new file mode 100644 index 0000000..aaabd89 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/LyricsMenuViewModel.kt @@ -0,0 +1,56 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.LyricsEntity +import com.babelsoftware.loudly.lyrics.LyricsHelper +import com.babelsoftware.loudly.lyrics.LyricsResult +import com.babelsoftware.loudly.models.MediaMetadata +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@HiltViewModel +class LyricsMenuViewModel @Inject constructor( + private val lyricsHelper: LyricsHelper, + val database: MusicDatabase, +) : ViewModel() { + private var job: Job? = null + val results = MutableStateFlow(emptyList()) + val isLoading = MutableStateFlow(false) + + fun search(mediaId: String, title: String, artist: String, duration: Int) { + isLoading.value = true + results.value = emptyList() + job?.cancel() + job = viewModelScope.launch(Dispatchers.IO) { + lyricsHelper.getAllLyrics(mediaId, title, artist, duration) { result -> + results.update { + it + result + } + } + isLoading.value = false + } + } + + fun cancelSearch() { + job?.cancel() + job = null + } + + fun refetchLyrics(mediaMetadata: MediaMetadata, lyricsEntity: LyricsEntity?) { + database.query { + lyricsEntity?.let(::delete) + val lyrics = runBlocking { + lyricsHelper.getLyrics(mediaMetadata) + } + upsert(LyricsEntity(mediaMetadata.id, lyrics)) + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/MoodAndGenresViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/MoodAndGenresViewModel.kt new file mode 100644 index 0000000..ae58e82 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/MoodAndGenresViewModel.kt @@ -0,0 +1,26 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.pages.MoodAndGenres +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MoodAndGenresViewModel @Inject constructor() : ViewModel() { + val moodAndGenres = MutableStateFlow?>(null) + + init { + viewModelScope.launch { + YouTube.moodAndGenres().onSuccess { + moodAndGenres.value = it + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/NewReleaseViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/NewReleaseViewModel.kt new file mode 100644 index 0000000..40578f9 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/NewReleaseViewModel.kt @@ -0,0 +1,55 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.Artist +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.AlbumItem +import com.babelsoftware.innertube.models.filterExplicit +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NewReleaseViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, +) : ViewModel() { + private val _newReleaseAlbums = MutableStateFlow>(emptyList()) + val newReleaseAlbums = _newReleaseAlbums.asStateFlow() + + init { + viewModelScope.launch { + YouTube.newReleaseAlbums().onSuccess { albums -> + val artists: Set + val favouriteArtists: Set + database.artistsByCreateDateAsc().first().let { list -> + artists = list.map(Artist::id).toHashSet() + favouriteArtists = list + .filter { it.artist.bookmarkedAt != null } + .map { it.id } + .toHashSet() + } + _newReleaseAlbums.value = albums + .sortedBy { album -> + if (album.artists.orEmpty().any { it.id in favouriteArtists }) 0 + else if (album.artists.orEmpty().any { it.id in artists }) 1 + else 2 + } + .filterExplicit(context.dataStore.get(HideExplicitKey, false)) + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlinePlaylistViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlinePlaylistViewModel.kt new file mode 100644 index 0000000..a9087d3 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlinePlaylistViewModel.kt @@ -0,0 +1,43 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.innertube.models.SongItem +import com.babelsoftware.innertube.utils.completed +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnlinePlaylistViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + database: MusicDatabase +) : ViewModel() { + private val playlistId = savedStateHandle.get("playlistId")!! + + val playlist = MutableStateFlow(null) + val playlistSongs = MutableStateFlow>(emptyList()) + val dbPlaylist = database.playlistByBrowseId(playlistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + init { + viewModelScope.launch(Dispatchers.IO) { + YouTube.playlist(playlistId).completed() + .onSuccess { playlistPage -> + playlist.value = playlistPage.playlist + playlistSongs.value = playlistPage.songs + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchSuggestionViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchSuggestionViewModel.kt new file mode 100644 index 0000000..e901109 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchSuggestionViewModel.kt @@ -0,0 +1,70 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.loudly.db.entities.SearchHistory +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.YTItem +import com.babelsoftware.innertube.models.filterExplicit +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class OnlineSearchSuggestionViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, +) : ViewModel() { + val query = MutableStateFlow("") + private val _viewState = MutableStateFlow(SearchSuggestionViewState()) + val viewState = _viewState.asStateFlow() + + init { + viewModelScope.launch { + query.flatMapLatest { query -> + if (query.isEmpty()) { + database.searchHistory().map { history -> + SearchSuggestionViewState( + history = history + ) + } + } else { + val result = YouTube.searchSuggestions(query).getOrNull() + database.searchHistory(query) + .map { it.take(3) } + .map { history -> + SearchSuggestionViewState( + history = history, + suggestions = result?.queries?.filter { query -> + history.none { it.query == query } + }.orEmpty(), + items = result?.recommendedItems + ?.filterExplicit(context.dataStore.get(HideExplicitKey, false)) + .orEmpty() + ) + } + } + }.collect { + _viewState.value = it + } + } + } +} + +data class SearchSuggestionViewState( + val history: List = emptyList(), + val suggestions: List = emptyList(), + val items: List = emptyList(), +) diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchViewModel.kt new file mode 100644 index 0000000..fb0da94 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/OnlineSearchViewModel.kt @@ -0,0 +1,90 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.loudly.models.ItemsPage +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.filterExplicit +import com.babelsoftware.innertube.pages.SearchSummaryPage +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.collections.set + +@HiltViewModel +class OnlineSearchViewModel @Inject constructor( + @ApplicationContext context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val query = savedStateHandle.get("query")!! + val filter = MutableStateFlow(null) + var summaryPage by mutableStateOf(null) + val viewStateMap = mutableStateMapOf() + + init { + viewModelScope.launch { + filter.collect { filter -> + if (filter == null) { + if (summaryPage == null) { + YouTube.searchSummary(query) + .onSuccess { + summaryPage = it.filterExplicit(context.dataStore.get( + HideExplicitKey, false)) + } + .onFailure { + reportException(it) + } + } + } else { + if (viewStateMap[filter.value] == null) { + YouTube.search(query, filter) + .onSuccess { result -> + viewStateMap[filter.value] = ItemsPage( + result.items + .distinctBy { it.id } + .filterExplicit( + context.dataStore.get( + HideExplicitKey, + false + ) + ), + result.continuation + ) + } + .onFailure { + reportException(it) + } + } + } + } + } + } + + fun loadMore() { + val filter = filter.value?.value + viewModelScope.launch { + if (filter == null) return@launch + val viewState = viewStateMap[filter] ?: return@launch + val continuation = viewState.continuation + if (continuation != null) { + val searchResult = YouTube.searchContinuation(continuation).getOrNull() ?: return@launch + viewStateMap[filter] = ItemsPage( + (viewState.items + searchResult.items).distinctBy { it.id }, + searchResult.continuation + ) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/StatsViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/StatsViewModel.kt new file mode 100644 index 0000000..4867320 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/StatsViewModel.kt @@ -0,0 +1,92 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.StatPeriod +import com.babelsoftware.loudly.db.MusicDatabase +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.loudly.reportException +import com.babelsoftware.loudly.models.SongWithPlayCount // Yeni modeli import et +import kotlinx.coroutines.flow.map // map operatörünü import et +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class StatsViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + val statPeriod = MutableStateFlow(StatPeriod.`1_WEEK`) + + // DAO'dan tek bir seferde daha uzun bir liste çekelim (örneğin 20 öğe) + private val allMostPlayedSongs = statPeriod.flatMapLatest { period -> + // DAO'daki yeni fonksiyonu çağırıyoruz ve limiti artırıyoruz + database.mostPlayedSongsWithPlayCount(period.toTimeMillis(), limit = 20) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // Özet görünüm için kısa liste (ilk 5) + val summaryMostPlayedSongs = allMostPlayedSongs.map { it.take(5) } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // Genişletilmiş görünüm için tam liste + val detailedMostPlayedSongs = allMostPlayedSongs + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + // Sanatçılar ve albümler için şimdilik aynı mantığı koruyabiliriz. + val mostPlayedArtists = statPeriod.flatMapLatest { period -> + database.mostPlayedArtists(period.toTimeMillis()).map { artists -> + artists.filter { it.artist.isYouTubeArtist } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val mostPlayedAlbums = statPeriod.flatMapLatest { period -> + database.mostPlayedAlbums(period.toTimeMillis()) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch { + mostPlayedArtists.collect { artists -> + artists + .map { it.artist } + .filter { + it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) + } + .forEach { artist -> + YouTube.artist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + viewModelScope.launch { + mostPlayedAlbums.collect { albums -> + albums.filter { + it.album.songCount == 0 + }.forEach { album -> + YouTube.album(album.id).onSuccess { albumPage -> + database.query { + update(album.album, albumPage) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + delete(album.album) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/TopPlaylistViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/TopPlaylistViewModel.kt new file mode 100644 index 0000000..3160fd8 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/TopPlaylistViewModel.kt @@ -0,0 +1,36 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.MyTopFilter +import com.babelsoftware.loudly.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class TopPlaylistViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val top = savedStateHandle.get("top")!! + + val topPeriod = MutableStateFlow(MyTopFilter.ALL_TIME) + + @OptIn(ExperimentalCoroutinesApi::class) + val topSongs = + topPeriod + .flatMapLatest { period -> + database.mostPlayedSongsWithPlayCount(period.toTimeMillis(), top.toInt()) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} \ No newline at end of file diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubeBrowseViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubeBrowseViewModel.kt new file mode 100644 index 0000000..1f0beb0 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubeBrowseViewModel.kt @@ -0,0 +1,38 @@ +package com.babelsoftware.loudly.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.loudly.constants.HideExplicitKey +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.pages.BrowseResult +import com.babelsoftware.loudly.utils.dataStore +import com.babelsoftware.loudly.utils.get +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class YouTubeBrowseViewModel @Inject constructor( + @ApplicationContext context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val browseId = savedStateHandle.get("browseId")!! + private val params = savedStateHandle.get("params") + + val result = MutableStateFlow(null) + + init { + viewModelScope.launch { + YouTube.browse(browseId, params).onSuccess { + result.value = it.filterExplicit(context.dataStore.get(HideExplicitKey, false)) + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubePlaylistsViewModel.kt b/app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubePlaylistsViewModel.kt new file mode 100644 index 0000000..3582a39 --- /dev/null +++ b/app/src/main/java/com/babelsoftware/loudly/viewmodels/YouTubePlaylistsViewModel.kt @@ -0,0 +1,26 @@ +package com.babelsoftware.loudly.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.babelsoftware.innertube.YouTube +import com.babelsoftware.innertube.models.PlaylistItem +import com.babelsoftware.loudly.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class YouTubePlaylistsViewModel @Inject constructor() : ViewModel() { + val playlists = MutableStateFlow?>(null) + + init { + viewModelScope.launch { + YouTube.likedPlaylists().onSuccess { + playlists.value = it + }.onFailure { + reportException(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/animator/draw_path.xml b/app/src/main/res/animator/draw_path.xml deleted file mode 100644 index 6b8b3bc..0000000 --- a/app/src/main/res/animator/draw_path.xml +++ /dev/null @@ -1,8 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/animator/splash_animation.xml b/app/src/main/res/animator/splash_animation.xml deleted file mode 100644 index 8d108be..0000000 --- a/app/src/main/res/animator/splash_animation.xml +++ /dev/null @@ -1,8 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/drawable-nodpi/example_appwidget_preview.png b/app/src/main/res/drawable-nodpi/example_appwidget_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..894b069a4907d258f60b1b2406b90f5a0fe1c35b GIT binary patch literal 3522 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 yUns6|kY9zLe7Vwj< + + + + diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml new file mode 100644 index 0000000..38fc415 --- /dev/null +++ b/app/src/main/res/drawable/add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/add_link.xml b/app/src/main/res/drawable/add_link.xml new file mode 100644 index 0000000..9c6f2df --- /dev/null +++ b/app/src/main/res/drawable/add_link.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/album.xml b/app/src/main/res/drawable/album.xml new file mode 100644 index 0000000..2e011dc --- /dev/null +++ b/app/src/main/res/drawable/album.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/album_art_gradient.xml b/app/src/main/res/drawable/album_art_gradient.xml new file mode 100644 index 0000000..4fef474 --- /dev/null +++ b/app/src/main/res/drawable/album_art_gradient.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_widget_background.xml b/app/src/main/res/drawable/app_widget_background.xml new file mode 100644 index 0000000..99fe4ac --- /dev/null +++ b/app/src/main/res/drawable/app_widget_background.xml @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_widget_inner_view_background.xml b/app/src/main/res/drawable/app_widget_inner_view_background.xml new file mode 100644 index 0000000..f5437fe --- /dev/null +++ b/app/src/main/res/drawable/app_widget_inner_view_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/arrow_back.xml b/app/src/main/res/drawable/arrow_back.xml new file mode 100644 index 0000000..12ec388 --- /dev/null +++ b/app/src/main/res/drawable/arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_downward.xml b/app/src/main/res/drawable/arrow_downward.xml new file mode 100644 index 0000000..caa5be8 --- /dev/null +++ b/app/src/main/res/drawable/arrow_downward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_forward.xml b/app/src/main/res/drawable/arrow_forward.xml new file mode 100644 index 0000000..786a65f --- /dev/null +++ b/app/src/main/res/drawable/arrow_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/arrow_top_left.xml b/app/src/main/res/drawable/arrow_top_left.xml new file mode 100644 index 0000000..d57c533 --- /dev/null +++ b/app/src/main/res/drawable/arrow_top_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_upward.xml b/app/src/main/res/drawable/arrow_upward.xml new file mode 100644 index 0000000..3dfa31b --- /dev/null +++ b/app/src/main/res/drawable/arrow_upward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/artist.xml b/app/src/main/res/drawable/artist.xml new file mode 100644 index 0000000..3610689 --- /dev/null +++ b/app/src/main/res/drawable/artist.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/avd_splash_draw.xml b/app/src/main/res/drawable/avd_splash_draw.xml deleted file mode 100644 index 06ea372..0000000 --- a/app/src/main/res/drawable/avd_splash_draw.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/backup.xml b/app/src/main/res/drawable/backup.xml new file mode 100644 index 0000000..11dd85b --- /dev/null +++ b/app/src/main/res/drawable/backup.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/badge.xml b/app/src/main/res/drawable/badge.xml new file mode 100644 index 0000000..e87e93a --- /dev/null +++ b/app/src/main/res/drawable/badge.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bedtime.xml b/app/src/main/res/drawable/bedtime.xml new file mode 100644 index 0000000..7f6f538 --- /dev/null +++ b/app/src/main/res/drawable/bedtime.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bluetooth_devices.xml b/app/src/main/res/drawable/bluetooth_devices.xml new file mode 100644 index 0000000..e0d6322 --- /dev/null +++ b/app/src/main/res/drawable/bluetooth_devices.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bookmark.xml b/app/src/main/res/drawable/bookmark.xml new file mode 100644 index 0000000..197ecf3 --- /dev/null +++ b/app/src/main/res/drawable/bookmark.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bookmark_filled.xml b/app/src/main/res/drawable/bookmark_filled.xml new file mode 100644 index 0000000..5c67052 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/boosty.xml b/app/src/main/res/drawable/boosty.xml new file mode 100644 index 0000000..6cac2e6 --- /dev/null +++ b/app/src/main/res/drawable/boosty.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bug_report.xml b/app/src/main/res/drawable/bug_report.xml new file mode 100644 index 0000000..f7e809d --- /dev/null +++ b/app/src/main/res/drawable/bug_report.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cached.xml b/app/src/main/res/drawable/cached.xml new file mode 100644 index 0000000..a120088 --- /dev/null +++ b/app/src/main/res/drawable/cached.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/casa.webp b/app/src/main/res/drawable/casa.webp new file mode 100644 index 0000000000000000000000000000000000000000..a744a06825cf4d918969b8c03a649d119daba5d3 GIT binary patch literal 532 zcmV+v0_*)!Nk&Et0ssJ4MM6+kP&il$0000G0000#002J#06|PpNVow200EFgZJQxk z|Jt^3t;oL7o}1gYePi3v4Q<=DZQC~c3#z88>i;jZh?oF!8QEdM)mQH>ENvM>B7^!p zM!;u1LIfnOhwsg?>=IAG@I(@`sC_U#63Z%V11xTjU=%bPR#%4bnFh0mxL$Ydng&ot-_fx~^Z{wcxh=A+~*ME+tCLY=G3qFz=h>ufb>SQE?R*OjWxwGwkkrJh z2zV)8q2X-qL@0@G;PZ975_y-)C{i!O_eF&!aH*;^gJEN(I+IsH%2|`sO3+&#XG#5H z@W*tG9b zzD)@wZMli3U+{ZzYGPR;B34j1AT9s^0FVO!odGI906+jfkw~CSC8Q%FA_xGm5(#Ml W01!7*Pz88}xc~tE_|Els0002$V)JqU literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/casino.xml b/app/src/main/res/drawable/casino.xml new file mode 100644 index 0000000..3a0a363 --- /dev/null +++ b/app/src/main/res/drawable/casino.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/clear_all.xml b/app/src/main/res/drawable/clear_all.xml new file mode 100644 index 0000000..1878734 --- /dev/null +++ b/app/src/main/res/drawable/clear_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/close.xml b/app/src/main/res/drawable/close.xml new file mode 100644 index 0000000..4151c7d --- /dev/null +++ b/app/src/main/res/drawable/close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/codigo.xml b/app/src/main/res/drawable/codigo.xml new file mode 100644 index 0000000..b5b5d91 --- /dev/null +++ b/app/src/main/res/drawable/codigo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/content_copy.xml b/app/src/main/res/drawable/content_copy.xml new file mode 100644 index 0000000..ae0658a --- /dev/null +++ b/app/src/main/res/drawable/content_copy.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/contrast.xml b/app/src/main/res/drawable/contrast.xml new file mode 100644 index 0000000..b028de9 --- /dev/null +++ b/app/src/main/res/drawable/contrast.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/dark_mode.xml b/app/src/main/res/drawable/dark_mode.xml new file mode 100644 index 0000000..50e6cb2 --- /dev/null +++ b/app/src/main/res/drawable/dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 0000000..5bd6f7e --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete_history.xml b/app/src/main/res/drawable/delete_history.xml new file mode 100644 index 0000000..13e05f3 --- /dev/null +++ b/app/src/main/res/drawable/delete_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/directions_run.xml b/app/src/main/res/drawable/directions_run.xml new file mode 100644 index 0000000..0867061 --- /dev/null +++ b/app/src/main/res/drawable/directions_run.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/discord.xml b/app/src/main/res/drawable/discord.xml new file mode 100644 index 0000000..792279e --- /dev/null +++ b/app/src/main/res/drawable/discord.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/discover_tune.xml b/app/src/main/res/drawable/discover_tune.xml new file mode 100644 index 0000000..c8e7af1 --- /dev/null +++ b/app/src/main/res/drawable/discover_tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/donate.xml b/app/src/main/res/drawable/donate.xml new file mode 100644 index 0000000..c12f9e6 --- /dev/null +++ b/app/src/main/res/drawable/donate.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml new file mode 100644 index 0000000..890ea08 --- /dev/null +++ b/app/src/main/res/drawable/download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/download_done.xml b/app/src/main/res/drawable/download_done.xml new file mode 100644 index 0000000..4552a49 --- /dev/null +++ b/app/src/main/res/drawable/download_done.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/drag_handle.xml b/app/src/main/res/drawable/drag_handle.xml new file mode 100644 index 0000000..7aebb86 --- /dev/null +++ b/app/src/main/res/drawable/drag_handle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/edit.xml b/app/src/main/res/drawable/edit.xml new file mode 100644 index 0000000..f834f69 --- /dev/null +++ b/app/src/main/res/drawable/edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/equalizer.xml b/app/src/main/res/drawable/equalizer.xml new file mode 100644 index 0000000..f8706d9 --- /dev/null +++ b/app/src/main/res/drawable/equalizer.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/error.xml b/app/src/main/res/drawable/error.xml new file mode 100644 index 0000000..785630c --- /dev/null +++ b/app/src/main/res/drawable/error.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/expand_less.xml b/app/src/main/res/drawable/expand_less.xml new file mode 100644 index 0000000..8eeb98d --- /dev/null +++ b/app/src/main/res/drawable/expand_less.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/expand_more.xml b/app/src/main/res/drawable/expand_more.xml new file mode 100644 index 0000000..7dbc7e3 --- /dev/null +++ b/app/src/main/res/drawable/expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/explicit.xml b/app/src/main/res/drawable/explicit.xml new file mode 100644 index 0000000..ac012c9 --- /dev/null +++ b/app/src/main/res/drawable/explicit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/explore_filled.xml b/app/src/main/res/drawable/explore_filled.xml new file mode 100644 index 0000000..6e21d71 --- /dev/null +++ b/app/src/main/res/drawable/explore_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/explore_outlined.xml b/app/src/main/res/drawable/explore_outlined.xml new file mode 100644 index 0000000..187fef4 --- /dev/null +++ b/app/src/main/res/drawable/explore_outlined.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/extension.xml b/app/src/main/res/drawable/extension.xml new file mode 100644 index 0000000..b8aa967 --- /dev/null +++ b/app/src/main/res/drawable/extension.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/fast_forward.xml b/app/src/main/res/drawable/fast_forward.xml new file mode 100644 index 0000000..ebfce35 --- /dev/null +++ b/app/src/main/res/drawable/fast_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/favorite.xml b/app/src/main/res/drawable/favorite.xml new file mode 100644 index 0000000..ac89942 --- /dev/null +++ b/app/src/main/res/drawable/favorite.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/favorite_border.xml b/app/src/main/res/drawable/favorite_border.xml new file mode 100644 index 0000000..b1550b1 --- /dev/null +++ b/app/src/main/res/drawable/favorite_border.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_align_center.xml b/app/src/main/res/drawable/format_align_center.xml new file mode 100644 index 0000000..0167283 --- /dev/null +++ b/app/src/main/res/drawable/format_align_center.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/format_align_left.xml b/app/src/main/res/drawable/format_align_left.xml new file mode 100644 index 0000000..352723b --- /dev/null +++ b/app/src/main/res/drawable/format_align_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/github.xml b/app/src/main/res/drawable/github.xml new file mode 100644 index 0000000..54dc933 --- /dev/null +++ b/app/src/main/res/drawable/github.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/gradient.xml b/app/src/main/res/drawable/gradient.xml new file mode 100644 index 0000000..2e24ae6 --- /dev/null +++ b/app/src/main/res/drawable/gradient.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/graphic_eq.xml b/app/src/main/res/drawable/graphic_eq.xml new file mode 100644 index 0000000..833d40d --- /dev/null +++ b/app/src/main/res/drawable/graphic_eq.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/grid_view.xml b/app/src/main/res/drawable/grid_view.xml new file mode 100644 index 0000000..1f5809a --- /dev/null +++ b/app/src/main/res/drawable/grid_view.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/headphones.xml b/app/src/main/res/drawable/headphones.xml new file mode 100644 index 0000000..b3ad731 --- /dev/null +++ b/app/src/main/res/drawable/headphones.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/help.xml b/app/src/main/res/drawable/help.xml new file mode 100644 index 0000000..8848983 --- /dev/null +++ b/app/src/main/res/drawable/help.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/high_quality.xml b/app/src/main/res/drawable/high_quality.xml new file mode 100644 index 0000000..db7c741 --- /dev/null +++ b/app/src/main/res/drawable/high_quality.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/history.xml b/app/src/main/res/drawable/history.xml new file mode 100644 index 0000000..ec5ebd4 --- /dev/null +++ b/app/src/main/res/drawable/history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/home.xml b/app/src/main/res/drawable/home.xml new file mode 100644 index 0000000..b552119 --- /dev/null +++ b/app/src/main/res/drawable/home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/home_filled.xml b/app/src/main/res/drawable/home_filled.xml new file mode 100644 index 0000000..415f31a --- /dev/null +++ b/app/src/main/res/drawable/home_filled.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/home_outlined.xml b/app/src/main/res/drawable/home_outlined.xml new file mode 100644 index 0000000..57cf2d2 --- /dev/null +++ b/app/src/main/res/drawable/home_outlined.xml @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_autorenew.xml b/app/src/main/res/drawable/ic_autorenew.xml new file mode 100644 index 0000000..488454d --- /dev/null +++ b/app/src/main/res/drawable/ic_autorenew.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_banner_foreground.xml b/app/src/main/res/drawable/ic_banner_foreground.xml new file mode 100644 index 0000000..bee5b32 --- /dev/null +++ b/app/src/main/res/drawable/ic_banner_foreground.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_battery.xml b/app/src/main/res/drawable/ic_battery.xml new file mode 100644 index 0000000..a488ccd --- /dev/null +++ b/app/src/main/res/drawable/ic_battery.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_battery_charging.xml b/app/src/main/res/drawable/ic_battery_charging.xml new file mode 100644 index 0000000..100afa2 --- /dev/null +++ b/app/src/main/res/drawable/ic_battery_charging.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index aeb7942..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_low_battery.xml b/app/src/main/res/drawable/ic_low_battery.xml new file mode 100644 index 0000000..91633c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_low_battery.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_moon.xml b/app/src/main/res/drawable/ic_moon.xml new file mode 100644 index 0000000..5cbc772 --- /dev/null +++ b/app/src/main/res/drawable/ic_moon.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_smart_hub.xml b/app/src/main/res/drawable/ic_smart_hub.xml new file mode 100644 index 0000000..e250787 --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_hub.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/incognito_fill.xml b/app/src/main/res/drawable/incognito_fill.xml deleted file mode 100644 index 6976f6d..0000000 --- a/app/src/main/res/drawable/incognito_fill.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/info.xml b/app/src/main/res/drawable/info.xml new file mode 100644 index 0000000..9140295 --- /dev/null +++ b/app/src/main/res/drawable/info.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/input.xml b/app/src/main/res/drawable/input.xml new file mode 100644 index 0000000..1ddfd30 --- /dev/null +++ b/app/src/main/res/drawable/input.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/language.xml b/app/src/main/res/drawable/language.xml new file mode 100644 index 0000000..d062780 --- /dev/null +++ b/app/src/main/res/drawable/language.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/liberapay.xml b/app/src/main/res/drawable/liberapay.xml new file mode 100644 index 0000000..26e2f1f --- /dev/null +++ b/app/src/main/res/drawable/liberapay.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/library_add.xml b/app/src/main/res/drawable/library_add.xml new file mode 100644 index 0000000..775962e --- /dev/null +++ b/app/src/main/res/drawable/library_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_add_check.xml b/app/src/main/res/drawable/library_add_check.xml new file mode 100644 index 0000000..f42aa70 --- /dev/null +++ b/app/src/main/res/drawable/library_add_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_music.xml b/app/src/main/res/drawable/library_music.xml new file mode 100644 index 0000000..e2c9e67 --- /dev/null +++ b/app/src/main/res/drawable/library_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_music_filled.xml b/app/src/main/res/drawable/library_music_filled.xml new file mode 100644 index 0000000..1046363 --- /dev/null +++ b/app/src/main/res/drawable/library_music_filled.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/library_music_outlined.xml b/app/src/main/res/drawable/library_music_outlined.xml new file mode 100644 index 0000000..f08b0f9 --- /dev/null +++ b/app/src/main/res/drawable/library_music_outlined.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/list.xml b/app/src/main/res/drawable/list.xml new file mode 100644 index 0000000..84abba8 --- /dev/null +++ b/app/src/main/res/drawable/list.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/location_on.xml b/app/src/main/res/drawable/location_on.xml new file mode 100644 index 0000000..db338ee --- /dev/null +++ b/app/src/main/res/drawable/location_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml new file mode 100644 index 0000000..3a3e269 --- /dev/null +++ b/app/src/main/res/drawable/lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock_open.xml b/app/src/main/res/drawable/lock_open.xml new file mode 100644 index 0000000..c14c11a --- /dev/null +++ b/app/src/main/res/drawable/lock_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/logout.xml b/app/src/main/res/drawable/logout.xml new file mode 100644 index 0000000..65dc060 --- /dev/null +++ b/app/src/main/res/drawable/logout.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/loudly_monochrome.xml b/app/src/main/res/drawable/loudly_monochrome.xml new file mode 100644 index 0000000..ca0cb84 --- /dev/null +++ b/app/src/main/res/drawable/loudly_monochrome.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/loudly_picutre_1.jpg b/app/src/main/res/drawable/loudly_picutre_1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b8f0945964a6c584f8c7968d479f96c1ae94356f GIT binary patch literal 58395 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�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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/loudly_picutre_2.jpg b/app/src/main/res/drawable/loudly_picutre_2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bac25f6a1b71188f01280e30d5b2d9ae7ec97f8f GIT binary patch literal 66000 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%0D%?YcLCVEU%(`XgKQjN=Kc>d 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 F{|9w;)w}=z literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/loudly_picutre_3.jpg b/app/src/main/res/drawable/loudly_picutre_3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6ab90c8a4a275e05210ae9ea4b95bfcae78d3ee1 GIT binary patch literal 34986 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˜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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/loudly_picutre_4.jpg b/app/src/main/res/drawable/loudly_picutre_4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..757fe39f311587c483ddbf887d45d697a0173664 GIT binary patch literal 27369 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°eddPNY4 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 M3L9@=PPDWC*{by^R{#J2 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/loudly_picutre_5.jpg b/app/src/main/res/drawable/loudly_picutre_5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..91680c3b23dcaf26dbd275152b75070f8b5819e0 GIT binary patch literal 44714 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%0Ah^hTYQnd@q>oFpo9Y0cxrp#N% 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%0AxeLLUM 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 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/lyrics.xml b/app/src/main/res/drawable/lyrics.xml new file mode 100644 index 0000000..93e74bc --- /dev/null +++ b/app/src/main/res/drawable/lyrics.xml @@ -0,0 +1,37 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/manage_search.xml b/app/src/main/res/drawable/manage_search.xml new file mode 100644 index 0000000..ba575ee --- /dev/null +++ b/app/src/main/res/drawable/manage_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/mood.xml b/app/src/main/res/drawable/mood.xml new file mode 100644 index 0000000..adb1afa --- /dev/null +++ b/app/src/main/res/drawable/mood.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/more_horiz.xml b/app/src/main/res/drawable/more_horiz.xml new file mode 100644 index 0000000..6e09388 --- /dev/null +++ b/app/src/main/res/drawable/more_horiz.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/more_vert.xml b/app/src/main/res/drawable/more_vert.xml new file mode 100644 index 0000000..ddc2ea7 --- /dev/null +++ b/app/src/main/res/drawable/more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/music_note.xml b/app/src/main/res/drawable/music_note.xml new file mode 100644 index 0000000..7a0582f --- /dev/null +++ b/app/src/main/res/drawable/music_note.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/nav_bar.xml b/app/src/main/res/drawable/nav_bar.xml new file mode 100644 index 0000000..7d4e8df --- /dev/null +++ b/app/src/main/res/drawable/nav_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/navigate_next.xml b/app/src/main/res/drawable/navigate_next.xml new file mode 100644 index 0000000..9880347 --- /dev/null +++ b/app/src/main/res/drawable/navigate_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/notification_on.xml b/app/src/main/res/drawable/notification_on.xml new file mode 100644 index 0000000..dd6a5b3 --- /dev/null +++ b/app/src/main/res/drawable/notification_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/notifications_off.xml b/app/src/main/res/drawable/notifications_off.xml new file mode 100644 index 0000000..f2cea9b --- /dev/null +++ b/app/src/main/res/drawable/notifications_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/offline.xml b/app/src/main/res/drawable/offline.xml new file mode 100644 index 0000000..802c44e --- /dev/null +++ b/app/src/main/res/drawable/offline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/palette.xml b/app/src/main/res/drawable/palette.xml new file mode 100644 index 0000000..55565b2 --- /dev/null +++ b/app/src/main/res/drawable/palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/pause.xml b/app/src/main/res/drawable/pause.xml new file mode 100644 index 0000000..4b6a898 --- /dev/null +++ b/app/src/main/res/drawable/pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/person.xml b/app/src/main/res/drawable/person.xml index ddc8322..50004d9 100644 --- a/app/src/main/res/drawable/person.xml +++ b/app/src/main/res/drawable/person.xml @@ -1,5 +1,9 @@ - - - - + + diff --git a/app/src/main/res/drawable/play.xml b/app/src/main/res/drawable/play.xml new file mode 100644 index 0000000..2293a19 --- /dev/null +++ b/app/src/main/res/drawable/play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_add.xml b/app/src/main/res/drawable/playlist_add.xml new file mode 100644 index 0000000..bb449bf --- /dev/null +++ b/app/src/main/res/drawable/playlist_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_play.xml b/app/src/main/res/drawable/playlist_play.xml new file mode 100644 index 0000000..ac53694 --- /dev/null +++ b/app/src/main/res/drawable/playlist_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_remove.xml b/app/src/main/res/drawable/playlist_remove.xml new file mode 100644 index 0000000..2f0671b --- /dev/null +++ b/app/src/main/res/drawable/playlist_remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/queue_music.xml b/app/src/main/res/drawable/queue_music.xml new file mode 100644 index 0000000..1ce6f1a --- /dev/null +++ b/app/src/main/res/drawable/queue_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio.xml b/app/src/main/res/drawable/radio.xml new file mode 100644 index 0000000..31fdaa3 --- /dev/null +++ b/app/src/main/res/drawable/radio.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio_button_checked.xml b/app/src/main/res/drawable/radio_button_checked.xml new file mode 100644 index 0000000..49e0093 --- /dev/null +++ b/app/src/main/res/drawable/radio_button_checked.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio_button_unchecked.xml b/app/src/main/res/drawable/radio_button_unchecked.xml new file mode 100644 index 0000000..555c26d --- /dev/null +++ b/app/src/main/res/drawable/radio_button_unchecked.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/remove.xml b/app/src/main/res/drawable/remove.xml new file mode 100644 index 0000000..b13fa15 --- /dev/null +++ b/app/src/main/res/drawable/remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat.xml b/app/src/main/res/drawable/repeat.xml new file mode 100644 index 0000000..b9b25fa --- /dev/null +++ b/app/src/main/res/drawable/repeat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat_on.xml b/app/src/main/res/drawable/repeat_on.xml new file mode 100644 index 0000000..bb7d8db --- /dev/null +++ b/app/src/main/res/drawable/repeat_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat_one.xml b/app/src/main/res/drawable/repeat_one.xml new file mode 100644 index 0000000..032f882 --- /dev/null +++ b/app/src/main/res/drawable/repeat_one.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat_one_on.xml b/app/src/main/res/drawable/repeat_one_on.xml new file mode 100644 index 0000000..b03050a --- /dev/null +++ b/app/src/main/res/drawable/repeat_one_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/replay.xml b/app/src/main/res/drawable/replay.xml new file mode 100644 index 0000000..5972520 --- /dev/null +++ b/app/src/main/res/drawable/replay.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/restore.xml b/app/src/main/res/drawable/restore.xml new file mode 100644 index 0000000..7e1e9e7 --- /dev/null +++ b/app/src/main/res/drawable/restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/rounded_corner.xml b/app/src/main/res/drawable/rounded_corner.xml new file mode 100644 index 0000000..160e682 --- /dev/null +++ b/app/src/main/res/drawable/rounded_corner.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/save.xml b/app/src/main/res/drawable/save.xml new file mode 100644 index 0000000..7e59b64 --- /dev/null +++ b/app/src/main/res/drawable/save.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/schedule.xml b/app/src/main/res/drawable/schedule.xml new file mode 100644 index 0000000..750fdd0 --- /dev/null +++ b/app/src/main/res/drawable/schedule.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/screenshot.xml b/app/src/main/res/drawable/screenshot.xml new file mode 100644 index 0000000..05aca12 --- /dev/null +++ b/app/src/main/res/drawable/screenshot.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml new file mode 100644 index 0000000..c65ad76 --- /dev/null +++ b/app/src/main/res/drawable/search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search_off.xml b/app/src/main/res/drawable/search_off.xml new file mode 100644 index 0000000..21affc3 --- /dev/null +++ b/app/src/main/res/drawable/search_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/security.xml b/app/src/main/res/drawable/security.xml new file mode 100644 index 0000000..823edc6 --- /dev/null +++ b/app/src/main/res/drawable/security.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 0000000..57c7adb --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/share.xml b/app/src/main/res/drawable/share.xml new file mode 100644 index 0000000..1070fcd --- /dev/null +++ b/app/src/main/res/drawable/share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shortcut_albums.xml b/app/src/main/res/drawable/shortcut_albums.xml new file mode 100644 index 0000000..575eaa2 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_albums.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_artists.xml b/app/src/main/res/drawable/shortcut_artists.xml new file mode 100644 index 0000000..3e96869 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_artists.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_playlists.xml b/app/src/main/res/drawable/shortcut_playlists.xml new file mode 100644 index 0000000..792cdb5 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_playlists.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_search.xml b/app/src/main/res/drawable/shortcut_search.xml new file mode 100644 index 0000000..b094778 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_search.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_songs.xml b/app/src/main/res/drawable/shortcut_songs.xml new file mode 100644 index 0000000..90d6e37 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_songs.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shuffle.xml b/app/src/main/res/drawable/shuffle.xml new file mode 100644 index 0000000..1c1c923 --- /dev/null +++ b/app/src/main/res/drawable/shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shuffle_on.xml b/app/src/main/res/drawable/shuffle_on.xml new file mode 100644 index 0000000..e18e294 --- /dev/null +++ b/app/src/main/res/drawable/shuffle_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/signal_cellular_alt.xml b/app/src/main/res/drawable/signal_cellular_alt.xml new file mode 100644 index 0000000..2009944 --- /dev/null +++ b/app/src/main/res/drawable/signal_cellular_alt.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/signal_cellular_nodata.xml b/app/src/main/res/drawable/signal_cellular_nodata.xml new file mode 100644 index 0000000..1dfbbca --- /dev/null +++ b/app/src/main/res/drawable/signal_cellular_nodata.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/skip_next.xml b/app/src/main/res/drawable/skip_next.xml new file mode 100644 index 0000000..af92b54 --- /dev/null +++ b/app/src/main/res/drawable/skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/skip_previous.xml b/app/src/main/res/drawable/skip_previous.xml new file mode 100644 index 0000000..298c8fa --- /dev/null +++ b/app/src/main/res/drawable/skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/sliders.xml b/app/src/main/res/drawable/sliders.xml new file mode 100644 index 0000000..4597d59 --- /dev/null +++ b/app/src/main/res/drawable/sliders.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/slow_motion_video.xml b/app/src/main/res/drawable/slow_motion_video.xml new file mode 100644 index 0000000..69ed770 --- /dev/null +++ b/app/src/main/res/drawable/slow_motion_video.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/small_icon.xml b/app/src/main/res/drawable/small_icon.xml new file mode 100644 index 0000000..ca0cb84 --- /dev/null +++ b/app/src/main/res/drawable/small_icon.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/speed.xml b/app/src/main/res/drawable/speed.xml new file mode 100644 index 0000000..37bb5bf --- /dev/null +++ b/app/src/main/res/drawable/speed.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/splash_icon.xml b/app/src/main/res/drawable/splash_icon.xml deleted file mode 100644 index 1bbcbaa..0000000 --- a/app/src/main/res/drawable/splash_icon.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/spotify.xml b/app/src/main/res/drawable/spotify.xml new file mode 100644 index 0000000..0ba66f8 --- /dev/null +++ b/app/src/main/res/drawable/spotify.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/standart_quality.xml b/app/src/main/res/drawable/standart_quality.xml new file mode 100644 index 0000000..e81270b --- /dev/null +++ b/app/src/main/res/drawable/standart_quality.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/storage.xml b/app/src/main/res/drawable/storage.xml new file mode 100644 index 0000000..85f5666 --- /dev/null +++ b/app/src/main/res/drawable/storage.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/swipe.xml b/app/src/main/res/drawable/swipe.xml new file mode 100644 index 0000000..079610a --- /dev/null +++ b/app/src/main/res/drawable/swipe.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/sync.xml b/app/src/main/res/drawable/sync.xml new file mode 100644 index 0000000..0e6feca --- /dev/null +++ b/app/src/main/res/drawable/sync.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tab.xml b/app/src/main/res/drawable/tab.xml new file mode 100644 index 0000000..b6ee503 --- /dev/null +++ b/app/src/main/res/drawable/tab.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tag.xml b/app/src/main/res/drawable/tag.xml new file mode 100644 index 0000000..f71f900 --- /dev/null +++ b/app/src/main/res/drawable/tag.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/telegram.xml b/app/src/main/res/drawable/telegram.xml new file mode 100644 index 0000000..dc0cd20 --- /dev/null +++ b/app/src/main/res/drawable/telegram.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tinkoff.xml b/app/src/main/res/drawable/tinkoff.xml new file mode 100644 index 0000000..8f7a341 --- /dev/null +++ b/app/src/main/res/drawable/tinkoff.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/drawable/tips.xml b/app/src/main/res/drawable/tips.xml new file mode 100644 index 0000000..946f2ca --- /dev/null +++ b/app/src/main/res/drawable/tips.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/token.xml b/app/src/main/res/drawable/token.xml new file mode 100644 index 0000000..8013362 --- /dev/null +++ b/app/src/main/res/drawable/token.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/translate.xml b/app/src/main/res/drawable/translate.xml new file mode 100644 index 0000000..c1d325b --- /dev/null +++ b/app/src/main/res/drawable/translate.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/trending_up.xml b/app/src/main/res/drawable/trending_up.xml new file mode 100644 index 0000000..d79825e --- /dev/null +++ b/app/src/main/res/drawable/trending_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tune.xml b/app/src/main/res/drawable/tune.xml new file mode 100644 index 0000000..9ab9dfc --- /dev/null +++ b/app/src/main/res/drawable/tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/update.xml b/app/src/main/res/drawable/update.xml new file mode 100644 index 0000000..65fd69a --- /dev/null +++ b/app/src/main/res/drawable/update.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/upgrade.xml b/app/src/main/res/drawable/upgrade.xml new file mode 100644 index 0000000..3fadf5e --- /dev/null +++ b/app/src/main/res/drawable/upgrade.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/whatsapp.xml b/app/src/main/res/drawable/whatsapp.xml new file mode 100644 index 0000000..e796d8f --- /dev/null +++ b/app/src/main/res/drawable/whatsapp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/widget_album_art_background.xml b/app/src/main/res/drawable/widget_album_art_background.xml new file mode 100644 index 0000000..6d6bcd1 --- /dev/null +++ b/app/src/main/res/drawable/widget_album_art_background.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..8625e5b --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_control_chip_background.xml b/app/src/main/res/drawable/widget_control_chip_background.xml new file mode 100644 index 0000000..8625e5b --- /dev/null +++ b/app/src/main/res/drawable/widget_control_chip_background.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/drawable/widget_icon_background.xml b/app/src/main/res/drawable/widget_icon_background.xml new file mode 100644 index 0000000..5c3c1e1 --- /dev/null +++ b/app/src/main/res/drawable/widget_icon_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_preview_classic.xml b/app/src/main/res/drawable/widget_preview_classic.xml new file mode 100644 index 0000000..7083d3b --- /dev/null +++ b/app/src/main/res/drawable/widget_preview_classic.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/widget_progress_bar.xml b/app/src/main/res/drawable/widget_progress_bar.xml new file mode 100644 index 0000000..f530ced --- /dev/null +++ b/app/src/main/res/drawable/widget_progress_bar.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widgetpreview2.xml b/app/src/main/res/drawable/widgetpreview2.xml deleted file mode 100644 index f846fc1..0000000 --- a/app/src/main/res/drawable/widgetpreview2.xml +++ /dev/null @@ -1,4963 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/wifi.xml b/app/src/main/res/drawable/wifi.xml new file mode 100644 index 0000000..4956dd4 --- /dev/null +++ b/app/src/main/res/drawable/wifi.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/wifi_off.xml b/app/src/main/res/drawable/wifi_off.xml new file mode 100644 index 0000000..97f5724 --- /dev/null +++ b/app/src/main/res/drawable/wifi_off.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/wifi_proxy.xml b/app/src/main/res/drawable/wifi_proxy.xml new file mode 100644 index 0000000..f0cad18 --- /dev/null +++ b/app/src/main/res/drawable/wifi_proxy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/widget_music.xml b/app/src/main/res/layout/widget_music.xml new file mode 100644 index 0000000..7278cb0 --- /dev/null +++ b/app/src/main/res/layout/widget_music.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml new file mode 100644 index 0000000..a0a0dec --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index ef49c99..cf60fb2 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ + + - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 7353dbd..cf60fb2 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ + + - \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index 44b7d14acabb3b3ed77d6eda2ea967572e2ac8a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3102 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 K!>qR51PcH&Cj}?~ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index e99c7beb2b3aa43b4934dda084c79051b0ad28ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1830 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 G`6vMG$`>L4 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 0f28215b9d7a585307edc1c2f8ed83dca7fdddce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6250 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 m(e|?YSEH$I$8u>)dM8pK}=<_1- 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 diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..63b46f9 --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en \ No newline at end of file diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml new file mode 100644 index 0000000..a07b80a --- /dev/null +++ b/app/src/main/res/values-DE/strings.xml @@ -0,0 +1,636 @@ + + + + Willkommen bei Loudly + Lass uns dein Musikerlebnis personalisieren. + YouTube Music immer griffbereit + YT Music-Kontosynchronisierung + Wiedergabe ohne Werbung + Offline-Musikerlebnis + Unterstützt Android Auto + Unterstützt Discord-Status + Vollständig Open Source + Wähle deine Player-Benutzeroberfläche + Wähle die Player-Ansicht in der App aus. Du kannst diese Einstellung später unter „Einstellungen > Erscheinungsbild“ ändern. + Einrichtung abgeschlossen! + Du bist bereit, Loudly zu entdecken. + Fertig + Zurück + Weiter + Zurück + + Start + Entdecken + Songs + Künstler + Alben + Playlists + + Musik, die dir gefällt + Gefällt mir + Offline-Musik + Meine Top-Musik + Gecachte Musik + Lokale Musik + + Inhaltsfilter + + Insgesamt + Letzte 24 Stunden + Letzte Woche + Letzter Monat + Letztes Jahr + + + %d ausgewählt + %d ausgewählt + + + Guten Morgen + Guten Tag + Guten Abend + Gute Nacht + Letzte Aktivitäten + Verlauf + Dein Verlauf wird hier angezeigt + Deine Statistiken werden hier angezeigt + Statistiken + Weniger anzeigen + Mehr anzeigen + Stimmungen und Genres + Konto + Schnellauswahl + Höre Songs, um deine Schnellauswahl zu erstellen + Vergessene Favoriten + Weiterhören + Deine YouTube-Playlists + Ähnlich wie + Neuveröffentlichungen + Keine Internetverbindung + Es scheint, als hättest du keine Internetverbindung. Bitte überprüfe dein Netzwerk. + Für dich empfohlen + Heute für dich empfohlen + + Heute + Gestern + Diese Woche + Letzte Woche + Lokal + Online + Lokaler Verlauf + Online-Verlauf + + Meistgespielte Songs + Meistgespielte Künstler + Meistgespielte Alben + + Suche + In Loudly suchen + Mediathek durchsuchen… + Suchverlauf + Vorschläge + Suchergebnisse + Mediathek + Gefällt mir + Heruntergeladen + Alle + Songs + Videos + Alben + Künstler + Playlists + Community-Playlists + Empfohlene Playlists + Gemerkt + Keine Ergebnisse gefunden + + Deine Mediathek wird hier angezeigt + Songs aus deiner Mediathek werden hier angezeigt + Künstler aus deiner Mediathek werden hier angezeigt + Alben aus deiner Mediathek werden hier angezeigt + Deine Playlists werden hier angezeigt + + Aus deiner Mediathek + + Andere Versionen + Favorit + Top-Songs + + Songs, die dir gefallen + Heruntergeladene Songs + Die Playlist ist leer + Möchtest du wirklich alle Songs der Playlist „%s“ aus dem Speicher für heruntergeladene Songs entfernen? + Möchtest du die Playlist „%s“ wirklich löschen? + + Deaktiviert + + Erneut versuchen + Radio + Zufallswiedergabe + Zurücksetzen + + Details + Bearbeiten + Radio starten + Abspielen + Als Nächstes abspielen + Zur Wiedergabeliste hinzufügen + Zur Mediathek hinzufügen + Alle zur Mediathek hinzufügen + Aus der Mediathek entfernen + Alle aus der Mediathek entfernen + Herunterladen + Wird heruntergeladen + Download entfernen + Playlist importieren + Zu Playlist hinzufügen + Künstler anzeigen + Album anzeigen + Neu laden + Teilen + Löschen + Aus Verlauf entfernen + Aus Playlist entfernen + Aus Wiedergabeliste entfernen + Aus Cache entfernen + Als M3U-Datei exportieren + Online suchen + Synchronisieren + Erweitert + Tempo und Tonhöhe + + Hinzugefügt am + Aktualisiert am + Name + Künstler + Jahr + Anzahl der Songs + Länge + Wiedergabezeit + Benutzerdefiniert + + Medien-ID + MIME-Typ + Codecs + Bitrate + Abtastrate + Lautheit + Lautstärke + Dateigröße + Unbekannt + In die Zwischenablage kopiert + + Songtext bearbeiten + Songtext suchen + Möchtest du den gespeicherten Songtext für „%s“ wirklich löschen? + + Song bearbeiten + Songtitel + Songkünstler + Der Songtitel darf nicht leer sein. + Der Songkünstler darf nicht leer sein. + Speichern + + Playlist synchronisieren + Playlist auswählen + Playlist bearbeiten + Playlist erstellen + Name der Playlist + Der Name der Playlist darf nicht leer sein. + + Künstler bearbeiten + Name des Künstlers + Der Name des Künstlers darf nicht leer sein. + + Duplikate + Duplikate überspringen + Trotzdem hinzufügen + Der Song ist bereits in deiner Playlist + %d Songs sind bereits in deiner Playlist + Hinweis: Dies ermöglicht die Synchronisierung mit YouTube Music + + + %d Song + %d Songs + + + %d Künstler + %d Künstler + + + %d Album + %d Alben + + + %d Playlist + %d Playlists + + + %d Woche + %d Wochen + + + %d Monat + %d Monate + + + %d Jahr + %d Jahre + + + Playlist importiert + „%s“ aus der Playlist entfernt + Playlist synchronisiert + Rückgängig + + Aktuelle Wiedergabe + Songtext nicht gefunden + Sleep-Timer + Am Ende des Songs + + 1 Minute + %d Minuten + + Kein Stream verfügbar + Keine Netzwerkverbindung + Zeitüberschreitung + Unbekannter Fehler + Menü + Liste teilen + + 5 Sekunden zurück + 5 Sekunden vor + + Gefällt mir + Allen gefällt mir + „Gefällt mir“ entfernen + Alle „Gefällt mir“ entfernen + Zufallswiedergabe an + Zufallswiedergabe aus + Wiederholung aus + Aktuellen Song wiederholen + Wiedergabeliste wiederholen + + Alle Songs + Gesuchte Songs + + Musikplayer + + Benachrichtigungen + Verhindern, dass die App vom System beendet wird + Verwende eine Vordergrundbenachrichtigung, um zu verhindern, dass Loudly zufällig vom System geschlossen wird. + Benachrichtigungsberechtigung ist erforderlich + + Einstellungen + Erscheinungsbild + Design + Proxy + Dynamisches Design aktivieren + Dunkles Design + An + Aus + Systemeinstellung folgen + Reines Schwarz + App-Design + App-Benutzeroberfläche 1.0 + App-Benutzeroberfläche 1.5 + Auf YouTube Music anhören + Loudly besuchen + Künstler + Loudly anhören + Wischen auf Thumbnail zum Wechseln des Songs aktivieren + Navigations-Tabs anpassen + Player + Textausrichtung im Player + Position des Songtextes + Seitlich + Links + Zentriert + Player-Stil + Player Alte Benutzeroberfläche + Player-Benutzeroberfläche 1.0 + Player-Benutzeroberfläche 2.0 + Design folgen + Farbverlauf + Weichzeichner + Weichzeichner (Bewegung) + Player-Hintergrundstil + Rechts + Schieberegler-Stil + Standard + Wellig + Compose + Eckenradius des Thumbnails konfigurieren + Inhaltsfilter anzeigen + Sonstiges + Haupt + Anpassung der Auto-Playlists + Auto-Playlist „Gefällt mir“ anzeigen + Auto-Playlist „Downloads“ anzeigen + Auto-Playlist „Top“ anzeigen + Auto-Playlist „Gecacht“ anzeigen + Auto-Playlist „Lokal“ anzeigen + Nach rechts wischen für nächsten Song, nach links zum Hinzufügen zur Wiedergabeliste + Standard-Tab beim Öffnen + Standard-Filter in der Mediathek + Größe der Gitterzellen + Beschriftungen der unteren Navigationsleiste anzeigen + Klein + Groß + Änderungsprotokoll + Keine kürzlichen Änderungen gefunden + Letzte Änderungen: + Schließen + Neue Version verfügbar + Hallo nochmal + Spenden + Offizieller Kanal + Webseite + Offizielle Webseite ❯ + Quellcode + Zugriff auf den Quellcode der App + Probleme + Fehlerberichte und mehr + Hilfe + Wenn du beitragen möchtest, klicke hier ❯ + Entwickler | @RRechz + + Inhalt + Anmelden + Melde dich bei deinen YT Music-Playlists und mehr an + Tippen, um Token anzuzeigen + Erneut tippen zum Kopieren oder Bearbeiten + Dies ist eine ERWEITERTE Anmeldemethode. Als Alternative zum Webportal kannst du deinen Anmelde-Token direkt hier eingeben oder aktualisieren. Dies kann beispielsweise die Anmeldung auf mehreren Geräten beschleunigen. Bitte beachte, dass ungültige Token-Formate, die die App nicht verarbeiten kann, nicht akzeptiert werden. + Aus Spotify importieren + Spotify + Discord + Songs, die dir gefallen, automatisch herunterladen + Automatisch mit deinem YouTube Music-Konto synchronisieren + Synchronisiere deine Playlists und mehr mit deinem Konto + Nur WLAN + Nicht angemeldet + Standard-Inhaltssprache + Standard-Inhaltsland + Systemstandard + Proxy aktivieren + Proxy-Typ + Proxy-URL + Neustart erforderlich + App-Sprache + App-Sprachen konfigurieren + App-Spracheinstellungen konnten nicht gefunden werden, bitte manuell konfigurieren + Unterstützte Links standardmäßig öffnen + Unterstützte Links konfigurieren + Einstellungen für unterstützte Links konnten nicht gefunden werden, bitte manuell konfigurieren + + Player und Audio + Audioqualität + Maximal + Automatisch + Hoch + Niedrig + Minimale Wiedergabedauer + Die Mindestdauer, die ein Song abgespielt werden muss, bevor er als „abgespielt“ gilt + Wiedergabeliste + Dauerhafte Wiedergabeliste + Stelle deine letzte Wiedergabeliste beim Start der App wieder her + Automatisch mehr Songs laden + Füge automatisch mehr Songs hinzu, wenn das Ende der Wiedergabeliste erreicht ist, falls möglich + Stille überspringen + Audio-Normalisierung + Wiedergabe fortsetzen, wenn ein Bluetooth-Gerät verbunden wird + Wiedergabe bei minimaler Lautstärke stoppen + Abgespielte Songs zum YouTube-Music-Verlauf hinzufügen + Bei Fehler automatisch zum nächsten Song springen + Sorge für ein kontinuierliches Wiedergabeerlebnis + Musik beim Leeren der Aufgaben stoppen + Equalizer + Songtexte + Songtexte + Einstellungen für lokalen Player + Speicher + Cache + Bilder-Cache + Song-Cache + Maximale Cache-Größe + Unbegrenzt + Offload aktivieren + Verwende den Offload-Audiopfad für die Wiedergabe. Die Deaktivierung kann den Stromverbrauch erhöhen, kann aber bei Problemen mit der Wiedergabe oder Nachbearbeitung nützlich sein. + Alle Downloads löschen + Maximale Größe des Bilder-Caches + Bilder-Cache leeren + Maximale Größe des Song-Caches + Song-Cache leeren + verwendet + Bist du sicher, dass du den gesamten Cache leeren möchtest? + Bist du sicher, dass du alle Downloads löschen möchtest? + Bist du sicher, dass du alle gecachten Songs löschen möchtest? + Bist du sicher, dass du alle gecachten Bilder löschen möchtest? + Bist du sicher, dass du alle gecachten Übersetzungsmodelle löschen möchtest? + + Datenschutz + Wiedergabeverlauf + Wiedergabeverlauf pausieren + Wiedergabeverlauf löschen + Bist du sicher, dass du den gesamten Wiedergabeverlauf löschen möchtest? + Suchverlauf pausieren + Suchverlauf löschen + Bist du sicher, dass du den gesamten Suchverlauf löschen möchtest? + Suchverlauf + Anmeldung zum Durchsuchen von Inhalten verwenden + Dies kann beeinflussen, welche Inhalte du siehst, und zeigt zum Beispiel nur Premium-Alben an, wenn du mit einem Premium-Konto angemeldet bist. + Screenshot deaktivieren + Wenn diese Option aktiviert ist, sind Screenshots und die App-Ansicht in den „Zuletzt geöffnet“-Apps deaktiviert. + LrcLib-Songtextanbieter aktivieren + KuGou-Songtextanbieter aktivieren + Standard-Songtextanbieter + Mehrzeilige Songtexte lesen + Behandle alle Zeilen zwischen Synchronisationspunkten als einen Songtext + Leerzeichen um Songtexte entfernen + Schriftgröße + Explizite Inhalte ausblenden + Benachrichtigungen aktivieren + + Sicherung und Wiederherstellung + Sicherung + Wiederherstellung + Playlist importieren (m3u) + Import der Playlist fehlgeschlagen + Importierte Playlist + Sicherung erfolgreich erstellt + Sicherung konnte nicht erstellt werden + Wiederherstellung der Sicherung fehlgeschlagen + + Discord-Integration + Loudly verwendet die KizzyRPC-Bibliothek, um den Status deines Discord-Kontos zu setzen. Dies beinhaltet die Nutzung der Discord-Gateway-Verbindung, was als Verstoß gegen die Discord-Nutzungsbedingungen angesehen werden kann. Es sind jedoch keine Fälle bekannt, in denen Benutzerkonten aus diesem Grund gesperrt wurden. Verwendung auf eigene Gefahr.\n\nLoudly extrahiert nur deinen Token, alles andere wird lokal gespeichert. + Verwerfen + Optionen + Vorschau + Anmeldung fehlgeschlagen + Abmelden + Rich Presence aktivieren + + Über + App-Version + Suche nach Updates... + Version %1$s zum Download verfügbar. + Wird heruntergeladen... + Aktualisieren + Erneut versuchen + Jetzt herunterladen + App-Typ + + Neue Version verfügbar + Übersetzungsmodelle + Übersetzungsmodelle löschen + Verlaufsdauer + + 1 Sekunde + %d Sekunden + + Musik 3x2 + Musik 2x1 + Wiedergabe/Pause + Version · Entwickler · Lizenz + Benutzeroberfläche + Inhalt & Konto + Player + System + Mediathek + Integrationen + Systemintegration + Insgesamt genutzter Speicherplatz + Aktuell verwendet + Cache leeren + Sicherung · Wiederherstellung · Import + Verlauf · Suche · Screenshot + Downloads · Cache · Bilder + Sound · Wiedergabeliste · Songtexte + Sprache · Standort · Proxy + Anmelden · Spotify · Discord + Design · Schieberegler · Player + + Profilkartenbild + Passe das Bild auf der Profilkarte an + Profilbild auswählen + Vorgefertigte Bilder + Aus Galerie auswählen + Foto bearbeiten + Abbrechen + + An Entwickler senden + Schließen + Fehlerdetails anzeigen + Download fehlgeschlagen + Fehler anzeigen + Manuelles Update + Details findest du auf der Einstellungsseite + Software ist auf dem neuesten Stand + + App-Benutzeroberfläche + Wähle eines von 3 verschiedenen Designs + Auswahl der Benutzeroberflächen-Version + Altes Design der Anwendung. (App, Player, MiniPlayer) + Kompaktes Design mit einem moderneren Ansatz. (App, Player, MiniPlayer) + Beliebtestes Design. (App, Player, MiniPlayer) + Erstelle dein eigenes App-Design. (App, Player, MiniPlayer) + Benutzeroberfläche anpassen + + Andere Einstellungen + Klicke für verschiedene andere Einstellungen + + Passt die Qualität automatisch zwischen 45 und 512 Kbit/s an + Bietet die höchste Qualität von bis zu 512 Kbit/s + Stellt die Qualität für durchschnittliche Internetgeschwindigkeiten zwischen 128 und 256 Kbit/s ein. + Begrenzt die Qualität auf 45 bis 52 Kbit/s, um Daten zu sparen (Datensparmodus) + + Import fehlgeschlagen + + Textausrichtung + Stelle die Position des Textes auf der Karte ein + Farbverlaufsintensität + Eckenabrundung + Profilbild + Profilkarte + Profilbild entfernen + Texte anzeigen + Zeige den Begrüßungs- und Benutzernamentext auf der Profilkarte an + Tippen zum Ändern + + Übersetzen (BETA) + Kopieren + + Netzwerkfehler + Überprüfe deine Internetverbindung und versuche es erneut. + Nicht unterstützter Inhalt + Dieses Songformat wird auf deinem Gerät nicht unterstützt. + Live-Übertragungsfehler + Es gibt ein Problem mit dem Live-Stream. Bitte starte neu. + Wiedergabe fehlgeschlagen + Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut. + Versuche es erneut… + Die Verwendung eines VPN kann die Verbindungsqualität beeinträchtigen. Wenn du Netzwerkprobleme hast, trenne deine VPN-Verbindung oder verwende einen Proxy. Du kannst dies unter „Einstellungen >> Inhalt >> Proxy“ konfigurieren. + + Lokale Songs erfolgreich synchronisiert + Lokaler Scanner + + Automatisch scannen + Songs beim Öffnen der App automatisch scannen + + Zusätzliche Scanner-Einstellungen + Strenge Dateinamen + Wenn aktiviert, werden Erweiterungen NICHT ignoriert. Z. B. ist „Song.ogg“ ein anderer Song als „Song.flac“ + Scanner-Empfindlichkeit konfigurieren + Titel abgleichen + Titel und Künstler abgleichen + Titel, Künstler, Alben abgleichen + + Ordner + Unterordner zusammenführen + Deaktivieren, um die Dateistruktur wie auf der Festplatte beizubehalten + + Tipps & Funktionen + Tipps · Funktionen + Erstelle deine Story + Teile den Song, den du hörst, in sozialen Medien mit einem stilvollen Design, das mit den dominanten Farben des Albumcovers erstellt wurde. + Anpassbare Einstellungen + Mache die App zu deiner eigenen, indem du den Hintergrund der Titelkarte im Einstellungsbildschirm durch ein Bild aus deiner Galerie ersetzt. + Neue Songliste + Sieh dir deine aktuelle Wiedergabeliste an und wechsle einfach zwischen den Songs, indem du vom Player-Bildschirm nach oben wischst (neues Design exklusiv für Player UI 2.0) + Überarbeitetes Einstellungsdesign + Finde schnell, was du suchst, mit einem neuen, kartenbasierten Einstellungsdesign, das moderner und bequemer ist. + Klassisches Musik-Widget + Greife schnell von deinem Startbildschirm auf alle Steuerelemente und Songinformationen zu. + Minimalistisches Musik-Widget + „Quik“-Musik-Widget, inspiriert vom Design der Schnelleinstellungstaste von Android 16 + In Story teilen + Songlink kopieren + Link in die Zwischenablage kopiert! + Gestreamt auf Loudly + + Alle auswählen + Alle Playlists werden abgerufen… + Ausgewählte Elemente importieren + Etwas ist schiefgelaufen. Wir sind genauso verwirrt wie du. + Nun, das ist peinlich. Wir haben auch keine Ahnung, was passiert ist. + Anleitung zum Erhalt der erforderlichen Anmeldeinformationen + 3. Stelle sicher, dass du auf „Hinzufügen“ klickst, nachdem du den lokalen Pfad oben eingegeben hast. + 5. Akzeptiere die Nutzungsbedingungen und klicke auf „Speichern“. + Du wirst auf eine neue Seite weitergeleitet. Klicke auf Einstellungen, kopiere dann die Client-ID und das Client-Geheimnis und füge sie in die Textfelder unten in dieser App ein. + 2. Gib die erforderlichen Details ein und verwende http://127.0.0.1:45454 als Umleitungs-URIs. + 4. Schalte die Web-API um. Das Kontrollkästchen sollte jetzt ein Häkchen haben. + Klicke jetzt auf die Schaltfläche „Autorisieren und Fortfahren“ unten und melde dich mit dem Konto an, von dem du importieren möchtest, und autorisiere und fahre fort, was dich zu einer Website weiterleitet, die mit http://127.0.0.1:45454/?code= beginnen sollte.\n\nFüge diese gesamte URL in das untere Textfeld ein, das „Autorisierungscode“ sagt, und klicke auf die Schaltfläche „Authentifizieren“. + 1. Besuche das Spotify for Developers Dashboard und klicke auf „App erstellen“. + Mit Spotify-API-Anmeldeinformationen anmelden + Autorisieren und Fortfahren + Autorisierungscode + Authentifizieren + Client-Geheimnis + Client-ID + Import läuft… + Schließe die App nicht und gehe nicht zurück. Dieser Vorgang wird nicht im Hintergrund ausgeführt, also bleibe, bis er fertig ist!\nKEINE PANIK, WENN ES FESTGEFAHREN AUSSIEHT; manchmal kann der Abruf einige Zeit dauern. + Wähle ein Ziel für „Songs, die dir gefallen“ + Wohin sollen die Songs, die dir gefallen, importiert werden? + Eine neue Playlist namens „Songs, die dir gefallen“ + In die Standard-Playlist „Songs, die dir gefallen“ + \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 89431ef..4631f4c 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,278 +1,676 @@ - AirNote - الملاحظات - ملاحظة جديدة - لا توجد ملاحظات تم إنشاؤها. - لم يتم العثور على ملاحظات. - وقت الإنشاء - عرض شبكي - عرض عمودي - كلمات - أحرف - الاسم - الوصف - تحديد الكل - حذف - معلومات + + مرحبًا بك في Loudly + لنبدأ في تخصيص تجربتك الموسيقية. + موسيقى يوتيوب في متناول يدك + مزامنة حساب موسيقى يوتيوب + تشغيل بدون إعلانات + تجربة موسيقى بدون اتصال بالإنترنت + دعم Android Auto + دعم حالة Discord + مفتوح المصدر بالكامل + اختر واجهة المشغل الخاصة بك + اختر عرض المشغل داخل التطبيق. يمكنك تغيير هذا الإعداد لاحقًا في "الإعدادات > المظهر". + اكتمل الإعداد! + أنت جاهز لاستكشاف Loudly. + إنهاء + رجوع + التالي + السابق + + الرئيسية + استكشاف + الأغاني + الفنانون + الألبومات + قوائم التشغيل + + الموسيقى المعجب بها + إعجاب + موسيقى بدون نت + أفضل موسيقاي + الموسيقى المخبأة + الموسيقى المحلية + + مرشح المحتوى + + كل الأوقات + آخر 24 ساعة + الأسبوع الماضي + الشهر الماضي + السنة الماضية + + + تم تحديد %d + تم تحديد %d + تم تحديد %d + تم تحديد %d + تم تحديد %d + تم تحديد %d + + + صباح الخير + مساء الخير + مساء الخير + ليلة سعيدة + النشاط الأخير + السجل + سيظهر سجلك هنا + ستظهر إحصائياتك هنا + الإحصائيات + عرض أقل + عرض المزيد + الحالات المزاجية والأنواع + الحساب + اختيارات سريعة + استمع إلى الأغاني لإنشاء اختياراتك السريعة + المفضلات المنسية + استمر في الاستماع + قوائم تشغيل يوتيوب الخاصة بك + مشابه لـ + ألبومات الإصدارات الجديدة + لا يوجد اتصال بالإنترنت + يبدو أنه لا يوجد لديك اتصال بالإنترنت. يرجى التحقق من شبكتك. + مختارة لك + موصى به لك اليوم + + اليوم + الأمس + هذا الأسبوع + الأسبوع الماضي + محلي + عن بعد + السجل المحلي + السجل عن بعد + + الأغاني الأكثر تشغيلاً + الفنانون الأكثر تشغيلاً + الألبومات الأكثر تشغيلاً + بحث - الإعدادات - العرض - سمة النظام + البحث في Loudly + ابحث في المكتبة… + سجل البحث + اقتراحات + نتائج البحث + المكتبة + المعجب بها + التي تم تنزيلها + الكل + الأغاني + الفيديوهات + الألبومات + الفنانون + قوائم التشغيل + قوائم تشغيل المجتمع + قوائم التشغيل المميزة + المحفوظة + لم يتم العثور على نتائج + + ستظهر المكتبة هنا + ستظهر أغاني المكتبة هنا + سيظهر فنانو المكتبة هنا + ستظهر ألبومات المكتبة هنا + ستظهر قوائم التشغيل الخاصة بك هنا + + من مكتبتك + + إصدارات أخرى + المفضلة + أفضل الأغاني + + الأغاني المعجب بها + الأغاني التي تم تنزيلها + قائمة التشغيل فارغة + هل تريد حقًا إزالة جميع أغاني قائمة التشغيل \"%s\" من مساحة تخزين الأغاني التي تم تنزيلها؟ + هل تريد حقًا حذف قائمة التشغيل \"%s\"؟ + + تعطيل + + إعادة المحاولة + راديو + خلط + إعادة تعيين + + تفاصيل + تعديل + بدء الراديو + تشغيل + تشغيل التالي + إضافة إلى قائمة الانتظار + إضافة إلى المكتبة + إضافة الكل إلى المكتبة + إزالة من المكتبة + إزالة الكل من المكتبة + تنزيل + جارٍ التنزيل + إزالة التنزيل + استيراد قائمة تشغيل + إضافة إلى قائمة التشغيل + عرض الفنان + عرض الألبوم + إعادة الجلب + مشاركة + حذف + إزالة من السجل + إزالة من قائمة التشغيل + إزالة من قائمة الانتظار + إزالة من ذاكرة التخزين المؤقت + تصدير كملف M3U + البحث عبر الإنترنت + مزامنة + متقدم + الإيقاع والطبقة + + تاريخ الإضافة + تاريخ آخر تحديث + الاسم + الفنان + السنة + عدد الأغاني + المدة + وقت التشغيل + ترتيب مخصص + + معرف الوسائط + نوع MIME + الترميزات + معدل البت + معدل العينة + الجهارة + مستوى الصوت + حجم الملف + غير معروف + تم النسخ إلى الحافظة + + تعديل الكلمات + البحث عن الكلمات + هل تريد حقًا حذف الكلمات المخزنة لـ \"%s\"؟ + + تعديل الأغنية + عنوان الأغنية + فنانو الأغنية + لا يمكن أن يكون عنوان الأغنية فارغًا. + لا يمكن أن يكون فنان الأغنية فارغًا. + حفظ + + مزامنة قائمة التشغيل + اختر قائمة التشغيل + تعديل قائمة التشغيل + إنشاء قائمة تشغيل + اسم قائمة التشغيل + لا يمكن أن يكون اسم قائمة التشغيل فارغًا. + + تعديل الفنان + اسم الفنان + لا يمكن أن يكون اسم الفنان فارغًا. + + التكرارات + تخطي التكرارات + إضافة على أي حال + الأغنية موجودة بالفعل في قائمة التشغيل الخاصة بك + يوجد %d أغنية بالفعل في قائمة التشغيل الخاصة بك + ملاحظة: هذا يسمح بالمزامنة مع موسيقى يوتيوب + + + %d أغنية + أغنية واحدة (%d) + أغنيتان (%d) + %d أغانٍ + %d أغنية + %d أغنية + + + %d فنان + فنان واحد (%d) + فنانان (%d) + %d فنانين + %d فنانًا + %d فنان + + + %d ألبوم + ألبوم واحد (%d) + ألبومان (%d) + %d ألبومات + %d ألبومًا + %d ألبوم + + + %d قائمة تشغيل + قائمة تشغيل واحدة (%d) + قائمتا تشغيل (%d) + %d قوائم تشغيل + %d قائمة تشغيل + %d قائمة تشغيل + + + %d أسبوع + أسبوع واحد (%d) + أسبوعان (%d) + %d أسابيع + %d أسبوعًا + %d أسبوع + + + %d شهر + شهر واحد (%d) + شهران (%d) + %d أشهر + %d شهرًا + %d شهر + + + %d سنة + سنة واحدة (%d) + سنتان (%d) + %d سنوات + %d سنة + %d سنة + + + تم استيراد قائمة التشغيل + تمت إزالة \"%s\" من قائمة التشغيل + تمت مزامنة قائمة التشغيل + تراجع + + قيد التشغيل الآن + لم يتم العثور على الكلمات + مؤقت النوم + نهاية الأغنية + + %d دقيقة + دقيقة واحدة + دقيقتان (%d) + %d دقائق + %d دقيقة + %d دقيقة + + لا يوجد بث متاح + لا يوجد اتصال بالشبكة + انتهت المهلة + خطأ غير معروف + القائمة + مشاركة القائمة + + 5 ثوانٍ للخلف + 5 ثوانٍ للأمام + + إعجاب + إعجاب بالكل + إزالة الإعجاب + إزالة كل الإعجابات + تشغيل الخلط + إيقاف الخلط + إيقاف وضع التكرار + تكرار الأغنية الحالية + تكرار قائمة الانتظار + + كل الأغاني + الأغاني التي تم البحث عنها + + مشغل الموسيقى + + الإشعارات + منع النظام من إغلاق التطبيق + استخدم إشعارًا في المقدمة لمنع Loudly من الإغلاق العشوائي بواسطة النظام. + إذن الإشعارات مطلوب + + الإعدادات + المظهر + السمة + البروكسي + تفعيل السمة الديناميكية السمة الداكنة - ألوان ديناميكية - ألوان Amoled - قاعدة البيانات - نسخ احتياطي - غير مدعوم بعد - حول - الإصدار - آخر إصدار - أضف نجمة للمشروع - تبرع لي :) - استعادة - الملاحظات - الدعم - - اللغة - Markdown - الأدوات - السجل - الأدوات المصغرة - السمات • الحجم • الخط - السحابة - الخط • اللغة - NextCloud • محلي • تشفير - الإصدار • المطورون • الترخيص - الأدوات المصغرة • Markdown - السجل • التراجع - تم الإنشاء • حذف الكل - الدعم • الصور - أظهر حبك - الخط - نصف القطر - حدد نصف القطر - الوضع المبسط - ألوان Amoled القصوى + تشغيل + إيقاف + اتباع النظام + أسود خالص + تصميم التطبيق + واجهة التطبيق 1.0 + واجهة التطبيق 1.5 + الاستماع على موسيقى يوتيوب + زيارة Loudly + الفنان + الاستماع إلى Loudly + تفعيل السحب على الصورة المصغرة لتغيير الأغنية + تخصيص علامات التبويب + المشغل + محاذاة نص المشغل + موضع نص الكلمات + جانبي + يسار + وسط + نمط المشغل + واجهة المشغل القديمة + واجهة المشغل 1.0 + واجهة المشغل 2.0 + اتباع السمة + تدرج لوني + ضبابي + ضبابي (حركي) + نمط خلفية المشغل + يمين + نمط شريط التمرير + افتراضي + متعرج + مركب + تكوين نصف قطر زاوية الصورة المصغرة + إظهار مرشح المحتوى + متفرقات + الرئيسي + تخصيص قوائم التشغيل التلقائية + إظهار قائمة التشغيل التلقائية للمعجب بها + إظهار قائمة التشغيل التلقائية للتنزيلات + إظهار قائمة التشغيل التلقائية للأفضل + إظهار قائمة التشغيل التلقائية للمخبأة + إظهار قائمة التشغيل التلقائية للمحلية + اسحب الأغنية لليمين للتشغيل التالي أو لليسار للإضافة إلى قائمة الانتظار + علامة التبويب الافتراضية عند الفتح + شرائح المكتبة الافتراضية + حجم خلية الشبكة + إظهار تسميات شريط التنقل السفلي + صغير + كبير + سجل التغييرات + لم يتم العثور على تغييرات حديثة + التغييرات الأخيرة: + إغلاق + يتوفر إصدار جديد + مرحبًا مجددًا + تبرع + القناة الرسمية + الموقع الإلكتروني + الموقع الرسمي ❯ + كود المصدر + الوصول إلى كود المصدر للتطبيق + المشكلات + الإبلاغ عن الأخطاء والمزيد + مساعدة + إذا كنت ترغب في المساهمة، انقر هنا ❯ + المطور | @RRechz + + المحتوى + تسجيل الدخول + سجل الدخول إلى قوائم تشغيل موسيقى يوتيوب الخاصة بك والمزيد + انقر لإظهار الرمز + انقر مرة أخرى للنسخ أو التعديل + هذه طريقة تسجيل دخول متقدمة. كبديل للبوابة الإلكترونية، يمكنك إدخال أو تحديث رمز تسجيل الدخول الخاص بك مباشرة هنا. على سبيل المثال، يمكن أن يسرع هذا من تسجيل الدخول على أجهزة متعددة. يرجى ملاحظة أنه لن يتم قبول أي تنسيقات رموز غير صالحة يفشل التطبيق في تحليلها. + استيراد من سبوتيفاي + سبوتيفاي + ديسكورد + تنزيل الأغاني المعجب بها تلقائيًا + المزامنة التلقائية مع حساب موسيقى يوتيوب الخاص بك + مزامنة قوائم التشغيل الخاصة بك والمزيد مع حسابك + عبر الواي فاي فقط + لم يتم تسجيل الدخول + لغة المحتوى الافتراضية + دولة المحتوى الافتراضية + افتراضي النظام + تفعيل البروكسي + نوع البروكسي + عنوان URL للبروكسي + أعد التشغيل لتطبيق التأثير + لغة التطبيق + تكوين لغات التطبيق + تعذر العثور على إعدادات لغة التطبيق، يرجى تكوينها يدويًا + فتح الروابط المدعومة افتراضيًا + تكوين الروابط المدعومة + تعذر العثور على إعدادات الروابط المدعومة، يرجى تكوينها يدويًا + + المشغل والصوت + جودة الصوت + أقصى + تلقائي + عالية + منخفضة + الحد الأدنى لمدة التشغيل + الحد الأدنى من مدة تشغيل الأغنية التي يجب أن تُشغل قبل اعتبارها "تم تشغيلها" + قائمة الانتظار + قائمة انتظار مستمرة + استعادة آخر قائمة انتظار عند بدء التطبيق + تحميل المزيد من الأغاني تلقائيًا + إضافة المزيد من الأغاني تلقائيًا عند الوصول إلى نهاية قائمة الانتظار، إذا أمكن + تخطي الصمت + تطبيع الصوت + متابعة التشغيل عند توصيل جهاز بلوتوث + إيقاف تشغيل الأغنية عند الحد الأدنى لمستوى الصوت + إضافة الأغاني المشغلة إلى سجل موسيقى يوتيوب + التخطي التلقائي إلى الأغنية التالية عند حدوث خطأ + ضمان تجربة تشغيل مستمرة + إيقاف الموسيقى عند مسح المهام + المعادل الصوتي + الكلمات + الكلمات + إعدادات المشغل المحلي + التخزين + ذاكرة التخزين المؤقت + ذاكرة التخزين المؤقت للصور + ذاكرة التخزين المؤقت للأغاني + الحد الأقصى لحجم ذاكرة التخزين المؤقت + غير محدود + تفعيل التفريغ + استخدم مسار تفريغ الصوت لتشغيل الصوت. قد يؤدي تعطيل هذا إلى زيادة استهلاك الطاقة ولكنه قد يكون مفيدًا إذا واجهت مشكلات في تشغيل الصوت أو المعالجة اللاحقة. + مسح جميع التنزيلات + الحد الأقصى لحجم ذاكرة تخزين الصور المؤقتة + مسح ذاكرة تخزين الصور المؤقتة + الحد الأقصى لحجم ذاكرة تخزين الأغاني المؤقتة + مسح ذاكرة تخزين الأغاني المؤقتة + مستخدم + هل أنت متأكد من أنك تريد مسح كل ذاكرة التخزين المؤقت؟ + هل أنت متأكد من أنك تريد مسح جميع التنزيلات؟ + هل أنت متأكد من أنك تريد مسح جميع الأغاني المخبأة؟ + هل أنت متأكد من أنك تريد مسح جميع الصور المخبأة؟ + هل أنت متأكد من أنك تريد مسح جميع نماذج الترجمة المخبأة؟ + الخصوصية - حماية الشاشة - دعم الصور - غير مستقر - العملات المشفرة - يكيف سمة التطبيق بناءً على إعدادات النظام - التبديل بين أنظمة الألوان الداكنة والفاتحة - يضبط الألوان لتتناسب مع ورق الحائط الخاص بك - استخدام اللون الأسود للخلفية - تحسين فائق للبطارية - تخصيص نصف قطر الحدود - حدد نمط العرض - إزالة الأزرار العلوية من شاشة التعديل - تخزين جميع الملاحظات في ملف - استعادة الملاحظات من ملف النسخ الاحتياطي - تمكين Markdown في الملاحظات - إخفاء الشاشة في التطبيقات الحديثة وتعطيل لقطات الشاشة - نوع البناء - كود المصدر - قائمة الداعمين - البريد الإلكتروني - ديسكورد - تقرير الأخطاء والمزيد - مثبت - أخرى - نسخ - تشفير قاعدة البيانات - تشفير قاعدة البيانات أثناء النسخ الاحتياطي - أدخل كلمة المرور - كلمة المرور غير صالحة أو قاعدة البيانات ليست مشفرة - إدخال غير صالح - فرز تصاعدي - فرز تنازلي - فرز الملاحظات حسب وقت إنشائها - حدد ملاحظة - حجم الصورة غير مدعوم (15 ميغابايت كحد أقصى) - الخزنة - تمكين خزنة الملاحظات المشفرة - متابعة - تغيير لغة التطبيق - لغة النظام - السلوك - إعطاء الأولوية لوضع التحرير - الدخول إلى وضع التحرير في الملاحظات الحالية - كلمة مرور غير صالحة - تبرع - الترجمة - المساهمة - يحذف الصور من تخزين التطبيق عند إزالتها من المعرض - مزامنة المعرض - إظهار العنوان فقط - إظهار العنوان فقط في المعاينة - 1. قبول الشروط - بالضغط على "موافق" واستخدام Easy Notes، فإنك توافق على شروط الخدمة هذه. إذا كنت لا توافق، فلا تستخدم التطبيق. - 2. الترخيص - يتم توزيع AirNote (Easy Notes) بموجب **رخصة جنو العمومية (GPL) الإصدار 3**. هذا يعني:\n\n- لك حرية استخدام التطبيق وتعديله ومشاركته.\n- إذا قمت بتوزيع التطبيق أو أي إصدارات معدلة، فيجب عليك أيضًا توزيع الكود المصدري والاحتفاظ برخصة GPL. - 3. استخدام التطبيق - أنت توافق على استخدام AirNote (Easy Notes) فقط للأغراض المشروعة ووفقًا لهذه الشروط. يجب عليك **عدم**:\n\n- _استخدام التطبيق لأي غرض غير قانوني_\n- _انتهاك حقوق الآخرين_\n- _تعطيل وظائف التطبيق أو أمانه_ - 4. الملكية الفكرية - جميع حقوق الملكية الفكرية في AirNote (Easy Notes) مملوكة لنا أو لمرخصينا. استخدامك للتطبيق لا يمنحك ملكية أي حقوق ملكية فكرية. - 5. إخلاء المسؤولية عن الضمانات - يتم توفير Easy Notes "كما هو"، دون أي ضمانات. نحن لا نضمن أن التطبيق سيكون خاليًا من الأخطاء أو آمنًا أو غير متقطع. - 6. تحديد المسؤولية - نحن لسنا مسؤولين عن أي أضرار ناتجة عن استخدامك لـ AirNote (Easy Notes)، بما في ذلك الأضرار غير المباشرة أو العرضية أو التبعية. - 7. الإنهاء - يجوز لنا إنهاء أو تعليق وصولك إلى AirNote (Easy Notes) إذا انتهكت هذه الشروط. عند الإنهاء، سينتهي حقك في استخدام التطبيق على الفور. - 8. التغييرات على الشروط - قد نقوم بتحديث شروط الخدمة هذه من وقت لآخر. استمرارك في استخدام AirNote (Easy Notes) بعد أي تغييرات يشكل قبولًا للشروط المعدلة. - 9. اتصل بنا - لأية أسئلة حول شروط الخدمة هذه أو AirNote (Easy Notes)، يرجى الاتصال بنا على - 10. سياسة الخصوصية - لا يجمع AirNote (Easy Notes) أي بيانات شخصية. لمزيد من المعلومات، يرجى الرجوع إلى سياسة الخصوصية الخاصة بنا المتاحة على - تاريخ السريان - أوافق - شروط الخدمة - تعذر الاستيراد - فشل استيراد بعض الملفات - تم الاستيراد بنجاح - استيراد - استيراد ملفات نصية أخرى كملاحظات - استخدام خط أحادي المسافة أثناء تحرير الملاحظة - خط أحادي المسافة - هل أنت متأكد أنك تريد حذف الملاحظات المحددة - نعم + سجل الاستماع + إيقاف سجل الاستماع مؤقتًا + مسح سجل الاستماع + هل أنت متأكد من أنك تريد مسح كل سجل الاستماع؟ + إيقاف سجل البحث مؤقتًا + مسح سجل البحث + هل أنت متأكد من أنك تريد مسح كل سجل البحث؟ + سجل البحث + استخدام تسجيل الدخول لتصفح المحتوى + يمكن أن يؤثر هذا على المحتوى الذي تراه ويعرض على سبيل المثال ألبومات حصرية للحسابات المميزة إذا قمت بتسجيل الدخول بحساب مميز. + تعطيل لقطة الشاشة + عند تشغيل هذا الخيار، يتم تعطيل لقطات الشاشة وعرض التطبيق في التطبيقات الأخيرة. + تفعيل مزود كلمات LrcLib + تفعيل مزود كلمات KuGou + مزود الكلمات الافتراضي + قراءة الكلمات متعددة الأسطر + تعامل مع جميع الأسطر بين نقاط المزامنة كنص واحد للكلمات + إزالة المسافات حول الكلمات + حجم الخط + إخفاء المحتوى الصريح + تفعيل الإشعارات + + النسخ الاحتياطي والاستعادة + النسخ الاحتياطي + استعادة + استيراد قائمة تشغيل (m3u) + فشل استيراد قائمة التشغيل + قائمة التشغيل المستوردة + تم إنشاء النسخ الاحتياطي بنجاح + تعذر إنشاء النسخ الاحتياطي + فشل استعادة النسخ الاحتياطي + + تكامل ديسكورد + يستخدم Loudly مكتبة KizzyRPC لتعيين حالة حساب ديسكورد الخاص بك. يتضمن هذا استخدام اتصال بوابة ديسكورد، والذي قد يعتبر انتهاكًا لشروط خدمة ديسكورد. ومع ذلك، لا توجد حالات معروفة لتعليق حسابات المستخدمين لهذا السبب. استخدم على مسؤوليتك الخاصة.\n\nسيستخرج Loudly فقط الرمز الخاص بك، ويتم تخزين كل شيء آخر محليًا. + تجاهل + خيارات + معاينة + فشل تسجيل الدخول + تسجيل الخروج + تفعيل التواجد المنسق + + حول + إصدار التطبيق + جارٍ التحقق من وجود تحديثات... + الإصدار %1$s متاح للتنزيل. + جارٍ التنزيل... + ترقية + حاول مرة أخرى + تنزيل الآن + نوع التطبيق + + يتوفر إصدار جديد + نماذج الترجمة + مسح نماذج الترجمة + مدة السجل + + %d ثانية + ثانية واحدة + ثانيتان (%d) + %d ثوانٍ + %d ثانية + %d ثانية + + موسيقى 3x2 + موسيقى 2x1 + تشغيل/إيقاف مؤقت + الإصدار · المطورون · الترخيص + واجهة المستخدم + المحتوى والحساب + المشغل + النظام + المكتبة + التكاملات + تكامل النظام + إجمالي المساحة المستخدمة + المستخدمة حاليًا + مسح ذاكرة التخزين المؤقت + النسخ الاحتياطي · الاستعادة · الاستيراد + السجل · البحث · لقطة الشاشة + التنزيلات · ذاكرة التخزين المؤقت · الصور + الصوت · قائمة الانتظار · الكلمات + اللغة · الموقع · البروكسي + تسجيل الدخول · سبوتيفاي · ديسكورد + السمة · شريط التمرير · المشغل + + صورة بطاقة الملف الشخصي + تخصيص الصورة على بطاقة الملف الشخصي + اختر صورة الملف الشخصي + صور جاهزة + اختيار من المعرض + تعديل الصورة إلغاء - قفل التطبيق - قفل التطبيق باستخدام كلمة مرور - إعداد رمز المرور - أدخل رمز المرور - رقم التعريف الشخصي (6 أرقام) - رمز المرور - بصمة الإصبع - المصادقة مطلوبة - مقفل - UID - طلب بصمة الإصبع عند البدء - طلب رمز المرور عند البدء - نمط - طلب النمط عند البدء - إعداد النمط - أدخل النمط - قفل فوري - قفل فوري عند استئناف التطبيق - تعطيل السحب - "تعطيل السحب للمعاينة في وضع التحرير" - توسيع عرض شريط البحث - جعل عرض شريط البحث يطابق عرض الملاحظات - حجم الخط - تغيير حجم النص في الملاحظات - حجم الخط: %1$d - معاينة - هكذا ستبدو ملاحظاتك بحجم الخط المحدد. - مؤسس Babel Software ومطور أندرويد - إضافة مجلد جديد - اسم المجلد - لون المجلد - إضافة - نقل إلى مجلد - لا توجد مجلدات متاحة - حذف المجلد - هل أنت متأكد أنك تريد حذف المجلد ’%1$s‘؟ سيتم نقل الملاحظات الموجودة فيه إلى قسم ’كل الملاحظات‘. - تعديل المجلد - حفظ - علامة المجلد - يعرض علامة المجلد الذي تنتمي إليه الملاحظات. - إعدادات AirNote AI - تكوين الذكاء الاصطناعي • إعدادات API - استخدام AirNote API - استخدم ميزات الذكاء الاصطناعي مع واجهة برمجة التطبيقات المدمجة في التطبيق (موصى به) - مفتاح API الخاص بك (تجريبي) - تحقق - النموذج المستخدم - اختيار النموذج (تجريبي) - اقتراح الذكاء الاصطناعي + + إرسال إلى المطور إغلاق - كل الملاحظات - اسأل، دردش، ابحث مع الذكاء الاصطناعي... - كيفية الحصول على مفتاح API الخاص بك؟ - من Google AI Studio، يمكنك إنشاء مفتاح API شخصي مع حد استخدام مجاني سخي. هذا المفتاح ملك لك فقط ولديك سيطرة كاملة عليه. - 1. انتقل إلى موقع Google AI Studio بالنقر فوق الزر أدناه. - 2. سجل الدخول باستخدام حساب Google الخاص بك. - 3. في الشاشة التي تفتح، انقر فوق الزر "إنشاء مفتاح API". - 4. قد يُطلب منك إنشاء مشروع جديد، اتبع الخطوات لإنشائه. - 5. انسخ سلسلة الأحرف الطويلة التي تم إنشاؤها. - 6. الصق المفتاح المنسوخ في مربع النص أدناه واضغط على زر "تحقق". - اذهب إلى Google AI Studio - الرجاء إدخال مفتاح API أولاً. - مفتاح API صالح وتم تسجيله بنجاح! ✅ - مفتاح API غير صالح. يرجى التحقق والمحاولة مرة أخرى. - اطرح سؤالاً - اكتب ملاحظة حول موضوع ما - توليد أفكار - أعطني 5 أفكار مثيرة للاهتمام حول موضوع عشوائي - إصدار جديد - تحديث - يتوفر تحديث جديد! - تحديث التطبيق - تعذر استرداد سجل التغييرات. - فشل في تلقي سجل التغييرات: %1$s - جارٍ التحقق من وجود تحديثات... - التحديث متاح - إصدار جديد متاح! - الإصدار %1$s متاح للتنزيل. - التحديث الآن - تنزيل - جارٍ التنزيل... %1$d%% - تثبيت - تثبيت - فشل التحديث - خطأ - حاول مرة أخرى - حاول مرة أخرى - تطبيقك محدّث - محدّث - يتطلب التحديث إذنًا للتثبيت من مصادر غير معروفة. - لم يتم العثور على رابط التنزيل. - الابتكارات - حفظ الملاحظات - إعادة البناء - - تحسين الكتابة - تلخيص - اجعله أقصر - اجعله أطول - تغيير النبرة - - رسمي - متوازن - ودي - لم يتم العثور على مفتاح API للمستخدم. يرجى التحقق في الإعدادات. - فشل طلب API: %1$s - أهلاً بك! كيف يمكنني مساعدتك؟ - مقترحات - سرد الإيجابيات والسلبيات - إنشاء قائمة مهام - اشرح ببساطة أكثر - موصى به للملفات المستوردة - اقترح عنوانًا - - عظيم، عن ماذا سننشئ مسودة؟ يرجى كتابة الموضوع. - - صحح الأخطاء النحوية والتعبيرية في النص أدناه لجعله أكثر سلاسة. أجب فقط بالنص المصحح:\n\n---\n\n%1$s - لخص النص أدناه في جملة واحدة أو جملتين، مع الحفاظ على الفكرة الرئيسية. أجب فقط بنص الملخص:\n\n---\n\n%1$s - اختصر النص التالي بشكل كبير مع الحفاظ على معناه. أجب فقط بالنص المختصر:\n\n---\n\n%1$s - أضف تفاصيل إبداعية إلى النص أدناه لجعله أطول. أجب فقط بالنص الموسع:\n\n---\n\n%1$s - خوادم الذكاء الاصطناعي مشغولة حاليًا. يرجى المحاولة مرة أخرى في غضون بضع دقائق. - - %1$s\n\n---\n\n%2$s - أعد كتابة النص أدناه بلغة احترافية ورسمية. حافظ على المعنى، وغير النبرة فقط. أجب فقط بالنص النهائي: - أعد كتابة النص أدناه بلغة متوازنة، ليست رسمية جدًا ولا ودية جدًا. حافظ على المعنى. أجب فقط بالنتيجة: - أعد كتابة النص أدناه بطريقة أكثر صدقًا ودفئًا وحوارية وودية. حافظ على المعنى. أجب فقط بالنتيجة: - - يعمل مستخدم تطبيق تدوين الملاحظات على ملاحظة بعنوان "%1$s". اقترح بعض الأفكار الإبداعية والملهمة أو نقاط البداية التي تناسب هذا العنوان. اجعل إجابتك قصيرة وفي شكل قائمة. - تابع النص أدناه، مع الحفاظ على سلامة المعنى وأسلوب المؤلف. أجب فقط بالنص الجديد، ولا تكرر النص الموجود:\n\n---\n\n\nالعنوان: %1$s\n\nالمحتوى:\n%2$s - حلل النص أدناه واكتب فقرة حول كيفية التعامل مع هذا الموضوع من منظور مختلف تمامًا. لا تلخص النص الموجود، بل قدم منظورًا بديلاً:\n\n---\n\n\nالعنوان: %1$s\n\nالمحتوى:\n%2$s - حلل النص أدناه وسرد النقاط الإيجابية والسلبية تحت عنواني "### الإيجابيات" و "### السلبيات". أجب فقط بهذين العنوانين والبنود:\n\n\n---\n\n\n\n%1$s - حلل النص أدناه. إذا كان النص يذكر مهامًا يجب القيام بها خلال اليوم، فحولها إلى قائمة مهام عن طريق وضع "[] " في بداية كل سطر. إذا لم يذكر النص مهامًا، أجب فقط بـ "NO_TASKS" دون كتابة أي شيء آخر. اكتشف لغة النص وأنشئ القائمة بتلك اللغة:\n\n---\n\n%1$s - ترجم النص المعقد أدناه إلى لغة بسيطة وسلسة يمكن للجميع فهمها. حافظ على الفكرة الرئيسية والتفاصيل المهمة للنص. أجب فقط بالنص المبسط:\n\n---\n\n%1$s - اقترح 5 عناوين إبداعية وجذابة تناسب محتوى النص أدناه على أفضل وجه. قدم إجابتك كقائمة مرقمة:\n\n---\n\n%1$s - - يريد المستخدم إنشاء مسودة ملاحظة حول موضوع "%1$s". يجب أن تقدم إجابتك بالتنسيق التالي: العنوان: [أدخل العنوان هنا]\n\nالمحتوى: [أدخل محتوى الملاحظة هنا] + إظهار تفاصيل الخطأ + فشل التنزيل + إظهار الخطأ + تحديث يدوي + تحقق من صفحة الإعدادات للحصول على التفاصيل + البرنامج محدث + + واجهة التطبيق + اختر واحدًا من 3 تصميمات مختلفة + اختيار إصدار الواجهة + تصميم الإصدار القديم للتطبيق. (التطبيق، المشغل، المشغل المصغر) + تصميم مدمج تم إنشاؤه بنهج أكثر حداثة. (التطبيق، المشغل، المشغل المصغر) + التصميم الأكثر شعبية. (التطبيق، المشغل، المشغل المصغر) + أنشئ تصميم واجهة التطبيق الخاص بك. (التطبيق، المشغل، المشغل المصغر) + تخصيص الواجهة + + إعدادات أخرى + انقر للحصول على إعدادات متنوعة أخرى + + يضبط الجودة تلقائيًا بين 45 و 512 كيلوبت في الثانية + يقدم أعلى جودة تصل إلى 512 كيلوبت في الثانية + يضبط الجودة لسرعات الإنترنت المتوسطة بين 128 و 256 كيلوبت في الثانية. + يحد من الجودة بين 45 و 52 كيلوبت في الثانية لتوفير البيانات (توفير البيانات) + + تعذر الاستيراد + + محاذاة النص + تعيين موضع النص على البطاقة + شدة التدرج + استدارة الزاوية + صورة الملف الشخصي + بطاقة الملف الشخصي + إزالة صورة الملف الشخصي + إظهار النصوص + عرض نص الترحيب واسم المستخدم على بطاقة الملف الشخصي + انقر للتغيير + + ترجمة (تجريبي) + نسخ + + خطأ في الشبكة + تحقق من اتصالك بالإنترنت وحاول مرة أخرى. + محتوى غير مدعوم + تنسيق هذه الأغنية غير مدعوم على جهازك. + خطأ في البث المباشر + توجد مشكلة في البث المباشر. يرجى إعادة التشغيل. + فشل التشغيل + حدث خطأ غير معروف. يرجى المحاولة مرة أخرى. + جارٍ المحاولة مرة أخرى… + قد يؤثر استخدام VPN على جودة اتصالك. إذا كنت تواجه مشكلات في الشبكة، فافصل اتصال VPN الخاص بك أو استخدم بروكسي. يمكنك تكوين هذا في "الإعدادات >> المحتوى >> البروكسي". + + تمت مزامنة الأغاني المحلية بنجاح + الماسح المحلي + + المسح التلقائي + المسح التلقائي للأغاني عند فتح التطبيق + + إعدادات الماسح الإضافية + أسماء ملفات صارمة + عند التمكين، لن يتم تجاهل الامتدادات. على سبيل المثال، ستكون "Song.ogg" أغنية مختلفة عن "Song.flac" + تكوين حساسية الماسح + مطابقة العنوان + مطابقة العنوان والفنانين + مطابقة العنوان والفنانين والألبومات + + المجلدات + تسطيح المجلدات الفرعية + عطّل للحفاظ على بنية الملفات كما هي على القرص + + نصائح وميزات + نصائح · ميزات + أنشئ قصتك + شارك الأغنية التي تستمع إليها على وسائل التواصل الاجتماعي بتصميم أنيق تم إنشاؤه بالألوان السائدة لغلاف الألبوم. + إعدادات قابلة للتخصيص + اجعل التطبيق خاصًا بك عن طريق استبدال خلفية بطاقة العنوان على شاشة الإعدادات بصورة من معرض الصور الخاص بك. + قائمة أغاني جديدة + اعرض قائمة التشغيل الحالية وتنقل بسهولة بين الأغاني عن طريق السحب لأعلى من شاشة المشغل (تصميم جديد حصري لواجهة المشغل 2.0) + تصميم إعدادات مُعاد تصميمه + ابحث عما تبحث عنه بسهولة من خلال تصميم إعدادات جديد قائم على البطاقات يكون أكثر حداثة وملاءمة. + أداة الموسيقى الكلاسيكية + يمكنك الوصول بسرعة إلى جميع عناصر التحكم ومعلومات الأغنية من شاشتك الرئيسية. + أداة الموسيقى المبسطة + أداة موسيقى "Quik" مستوحاة من تصميم زر الإعدادات السريعة في أندرويد 16 + مشاركة في القصة + نسخ رابط الأغنية + تم نسخ الرابط إلى الحافظة! + تم بثها على Loudly + + تحديد الكل + جارٍ جلب جميع قوائم التشغيل… + استيراد العناصر المحددة + حدث خطأ ما. نحن في حيرة من أمرنا مثلك تمامًا. + حسنًا، هذا محرج. ليس لدينا أي فكرة عما حدث أيضًا. + تعليمات للحصول على بيانات الاعتماد المطلوبة + 3. تأكد من النقر فوق "إضافة" بمجرد إدخال المسار المحلي أعلاه. + 5. اقبل شروط الخدمة وانقر فوق "حفظ". + سيتم إعادة توجيهك إلى صفحة جديدة. انقر فوق الإعدادات، ثم انسخ معرف العميل وسر العميل، والصقهما في حقول النص أدناه في هذا التطبيق. + 2. أدخل التفاصيل اللازمة واستخدم http://127.0.0.1:45454 كعناوين URI لإعادة التوجيه. + 4. قم بتبديل Web API. يجب أن يحتوي مربع الاختيار الآن على علامة اختيار. + الآن، انقر فوق زر التفويض والمتابعة أدناه وقم بتسجيل الدخول باستخدام حسابك الذي تريد الاستيراد منه، وقم بالتفويض والمتابعة، مما سيعيد توجيهك إلى موقع يجب أن يبدأ بـ http://127.0.0.1:45454/?code=\n\nالصق عنوان URL الكامل هذا في حقل النص أدناه الذي يقول رمز التفويض وانقر فوق زر المصادقة + 1. قم بزيارة spotify للمطورين لوحة معلومات مطوري سبوتيفاي وانقر على "إنشاء تطبيق". + تسجيل الدخول باستخدام بيانات اعتماد Spotify API + تفويض ومتابعة + رمز التفويض + مصادقة + سر العميل + معرف العميل + جارٍ الاستيراد… + لا تغلق التطبيق أو تعود للخلف. لا تعمل هذه العملية في الخلفية، لذا ابق هنا حتى تنتهي!\nلا داعي للذعر إذا بدا عالقًا؛ فقد يستغرق الاسترداد بعض الوقت أحيانًا. + اختر وجهة "الأغاني المعجب بها" + أين يجب استيراد الأغاني المعجب بها؟ + قائمة تشغيل جديدة باسم "الأغاني المعجب بها" + في "الأغاني المعجب بها" الافتراضية \ No newline at end of file diff --git a/app/src/main/res/values-b+hi+Latn/strings.xml b/app/src/main/res/values-b+hi+Latn/strings.xml deleted file mode 100644 index 3cccaac..0000000 --- a/app/src/main/res/values-b+hi+Latn/strings.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - AirNote - Notes - Naya Note - Koi notes nahin banaye gaye. - Koi notes nahin mile. - Shabd - Varn - Naam - Sabhi chune - Mitaye - Jaankaari - Dhunde - Settings - Pradarshan - Pranali Theme - Kaala Theme - Dynamic Rang - Amoled Rang - Database - Backup - Abhi tak samarthit nahi hai - App ke barein mein - Sanskaran - Naya Release - Mujhe Daan Karo :) - Notes - Nirmaan ka samay - Stambh roop - Varnan - Restore - Sahayata - Bhasha - Auzaar - Itihas - Widgets - Cloud - Banaya • Sab Ko Hataayein - Apna pyaar dikhayein - Gopniyata - Screen Suraksha - Images Samarthan - Asthir - Kriptomudra - Atirikt Battery Anukoolan - diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000..ca3a990 --- /dev/null +++ b/app/src/main/res/values-bg/strings.xml @@ -0,0 +1,636 @@ + + + + Добре дошли в Loudly + Нека да започнем с персонализирането на вашето музикално изживяване. + YouTube Music на една ръка разстояние + Синхронизиране на акаунт в YT Music + Възпроизвеждане без реклами + Офлайн музикално изживяване + Поддръжка на Android Auto + Поддръжка на статус в Discord + Напълно отворен код + Изберете потребителски интерфейс на плейъра + Изберете изгледа на плейъра в приложението. Можете да промените тази настройка по-късно в „Настройки > Външен вид“. + Настройката е завършена! + Готови сте да изследвате Loudly. + Край + Назад + Напред + Предишен + + Начало + Открийте + Песни + Изпълнители + Албуми + Плейлисти + + Харесана музика + Харесване + Офлайн музика + Моята топ музика + Кеширана музика + Локална музика + + Филтър на съдържанието + + За всички времена + Последните 24 часа + Последната седмица + Последния месец + Последната година + + + %d избран + %d избрани + + + Добро утро + Добър ден + Добър вечер + Лека нощ + Последна активност + История + Вашата история ще се появи тук + Вашите статистики ще се появят тук + Статистики + Покажи по-малко + Покажи повече + Настроения и жанрове + Акаунт + Бързи избори + Слушайте песни, за да генерирате вашите бързи избори + Забравени любими + Продължете да слушате + Вашите плейлисти в YouTube + Подобни на + Нови албуми + Няма интернет връзка + Изглежда, че нямате интернет връзка. Моля, проверете вашата мрежа. + Препоръчано за вас + Препоръчано за вас днес + + Днес + Вчера + Тази седмица + Миналата седмица + Локална + Отдалечена + Локална история + Онлайн история + + Най-слушани песни + Най-слушани изпълнители + Най-слушани албуми + + Търсене + Търсене в Loudly + Търсене в библиотеката… + История на търсенето + Предложения + Резултати от търсенето + Библиотека + Харесани + Изтеглени + Всички + Песни + Видеоклипове + Албуми + Изпълнители + Плейлисти + Плейлисти на общността + Препоръчани плейлисти + Маркирани + Няма намерени резултати + + Библиотеката ще се появи тук + Песните от библиотеката ще се появят тук + Изпълнителите от библиотеката ще се появят тук + Албумите от библиотеката ще се появят тук + Вашите плейлисти ще се появят тук + + От вашата библиотека + + Други версии + Любими + Топ песни + + Харесани песни + Изтеглени песни + Плейлистът е празен + Наистина ли искате да премахнете всички песни от плейлиста „%s“ от хранилището за изтеглени песни? + Наистина ли искате да изтриете плейлиста „%s“? + + Изключено + + Опитай пак + Радио + Разбъркване + Нулиране + + Детайли + Редактиране + Стартирай радио + Пусни + Пусни следваща + Добави към опашката + Добави в библиотеката + Добави всички в библиотеката + Премахни от библиотеката + Премахни всички от библиотеката + Изтегляне + Изтегляне... + Премахни изтегленото + Импортиране на плейлист + Добави към плейлист + Виж изпълнителя + Виж албума + Обнови + Споделяне + Изтриване + Премахни от историята + Премахни от плейлиста + Премахни от опашката + Премахни от кеша + Експортиране като M3U файл + Търсене онлайн + Синхронизиране + Разширени + Темпо и височина + + Дата на добавяне + Дата на актуализация + Име + Изпълнител + Година + Брой песни + Дължина + Време на възпроизвеждане + Персонализиран ред + + Медиен идентификатор + MIME тип + Кодеци + Битрейт + Честота на дискретизация + Сила на звука + Сила + Размер на файла + Неизвестно + Копирано в клипборда + + Редактиране на текста + Търсене на текст + Наистина ли искате да изтриете запазения текст за „%s“? + + Редактиране на песен + Заглавие на песента + Изпълнители на песента + Заглавието на песента не може да бъде празно. + Изпълнителят на песента не може да бъде празен. + Запазване + + Синхронизиране на плейлист + Изберете плейлист + Редактиране на плейлист + Създаване на плейлист + Име на плейлиста + Името на плейлиста не може да бъде празно. + + Редактиране на изпълнител + Име на изпълнителя + Името на изпълнителя не може да бъде празно. + + Дубликати + Пропусни дубликатите + Добави въпреки това + Песента вече е във вашия плейлист + %d песни вече са във вашия плейлист + Забележка: Това позволява синхронизация с YouTube Music + + + %d песен + %d песни + + + %d изпълнител + %d изпълнители + + + %d албум + %d албума + + + %d плейлист + %d плейлиста + + + %d седмица + %d седмици + + + %d месец + %d месеца + + + %d година + %d години + + + Плейлистът е импортиран + Премахнахте „%s“ от плейлиста + Плейлистът е синхронизиран + Отмяна + + Сега се възпроизвежда + Текстът не е намерен + Таймер за заспиване + Край на песента + + 1 минута + %d минути + + Няма наличен стрийм + Няма мрежова връзка + Времето изтече + Неизвестна грешка + Меню + Споделяне на списъка + + 5 секунди назад + 5 секунди напред + + Харесване + Харесай всички + Премахни харесването + Премахни всички харесвания + Разбъркването е включено + Разбъркването е изключено + Режимът на повторение е изключен + Повтаряне на текущата песен + Повтаряне на опашката + + Всички песни + Търсени песни + + Музикален плейър + + Известия + Предотвратяване на затварянето на приложението от системата + Използвайте известие на преден план, за да предотвратите случайното затваряне на Loudly от системата. + Изисква се разрешение за известия + + Настройки + Външен вид + Тема + Прокси + Активиране на динамична тема + Тъмна тема + Включена + Изключена + Следване на системата + Чисто черно + Дизайн на приложението + UI на приложението 1.0 + UI на приложението 1.5 + Слушайте в YouTube Music + Посетете Loudly + Изпълнител + Слушайте Loudly + Активирайте плъзгане по обложката за смяна на песен + Персонализиране на навигационните раздели + Плейър + Подравняване на текста в плейъра + Позиция на текста на песента + Странично + Ляво + Център + Стил на плейъра + Стар UI на плейъра + UI на плейъра 1.0 + UI на плейъра 2.0 + Следване на темата + Градиент + Размазване + Размазване (движение) + Стил на фона на плейъра + Дясно + Стил на плъзгача + По подразбиране + Назъбен + Композиция + Конфигуриране на радиуса на ъглите на обложката + Показване на филтъра за съдържание + Разни + Основно + Персонализиране на автоматични плейлисти + Показване на автоматичен плейлист с харесани + Показване на автоматичен плейлист с изтеглени + Показване на автоматичен плейлист с топ + Показване на автоматичен плейлист с кеширани + Показване на автоматичен плейлист с локални + Плъзнете песен надясно за следваща или наляво за добавяне в опашката + Раздел по подразбиране при отваряне + Чипове по подразбиране в библиотеката + Размер на клетките в мрежата + Показване на етикетите в долната навигационна лента + Малък + Голям + Дневник на промените + Няма намерени скорошни промени + Скорошни промени: + Затвори + Налична е нова версия + Здравейте отново + Дарение + Официален канал + Уебсайт + Официален уебсайт ❯ + Изходен код + Достъп до изходния код на приложението + Проблеми + Доклади за грешки и други + Помощ + Ако искате да допринесете, кликнете тук ❯ + Разработчик | @RRechz + + Съдържание + Вход + Влезте във вашите плейлисти в YT Music и други + Докоснете, за да покажете токена + Докоснете отново, за да копирате или редактирате + Това е РАЗШИРЕН метод за влизане. Като алтернатива на уеб портала, можете директно да въведете или актуализирате вашия токен за вход тук. Например, това може да ускори влизането на няколко устройства. Моля, имайте предвид, че всякакви невалидни формати на токени, които приложението не успее да анализира, няма да бъдат приети. + Импортиране от Spotify + Spotify + Discord + Автоматично изтегляне на харесаните от вас песни + Автоматично синхронизиране с вашия акаунт в YouTube Music + Синхронизирайте вашите плейлисти и други с вашия акаунт + Само през Wi-Fi + Не сте влезли + Език на съдържанието по подразбиране + Държава на съдържанието по подразбиране + По подразбиране от системата + Активиране на прокси + Тип на проксито + URL на проксито + Рестартирайте, за да влезе в сила + Език на приложението + Конфигуриране на езиците на приложението + Не можахме да намерим настройките за език на приложението, моля, конфигурирайте ги ръчно + Отваряне на поддържани връзки по подразбиране + Конфигуриране на поддържани връзки + Не можахме да намерим настройките за поддържани връзки, моля, конфигурирайте ги ръчно + + Плейър и аудио + Качество на звука + Максимално + Автоматично + Високо + Ниско + Минимална продължителност на възпроизвеждане + Минималното време, през което една песен трябва да бъде възпроизведена, преди да се счита за „изслушана“ + Опашка + Постоянна опашка + Възстановете последната си опашка при стартиране на приложението + Автоматично зареждане на още песни + Автоматично добавяне на още песни при достигане на края на опашката, ако е възможно + Пропускане на тишината + Нормализиране на звука + Продължаване на възпроизвеждането при свързване на Bluetooth устройство + Спиране на възпроизвеждането при минимална сила на звука + Добавяне на възпроизведени песни към историята на YouTube Music + Автоматично преминаване към следващата песен при грешка + Осигурете непрекъснатото си възпроизвеждане + Спиране на музиката при изчистване на задачите + Еквалайзер + Текст на песен + Текстове + Настройки на локалния плейър + Хранилище + Кеш + Кеш на изображения + Кеш на песни + Максимален размер на кеша + Неограничен + Активиране на offload + Използвайте offload аудио пътя за възпроизвеждане на аудио. Деактивирането може да увеличи консумацията на енергия, но може да бъде полезно, ако имате проблеми с възпроизвеждането или обработката на звука. + Изчистване на всички изтегляния + Максимален размер на кеша за изображения + Изчистване на кеша за изображения + Максимален размер на кеша за песни + Изчистване на кеша за песни + използвано + Сигурни ли сте, че искате да изчистите целия кеш? + Сигурни ли сте, че искате да изчистите всички изтегляния? + Сигурни ли сте, че искате да изчистите всички кеширани песни? + Сигурни ли сте, че искате да изчистите всички кеширани изображения? + Сигурни ли сте, че искате да изчистите всички кеширани модели за превод? + + Поверителност + История на слушане + Пауза на историята на слушане + Изчистване на историята на слушане + Сигурни ли сте, че искате да изчистите цялата история на слушане? + Пауза на историята на търсене + Изчистване на историята на търсене + Сигурни ли сте, че искате да изчистите цялата история на търсене? + История на търсенето + Използване на вход за разглеждане на съдържание + Това може да повлияе на съдържанието, което виждате, и например да показва албуми само за Premium, ако сте влезли с Premium акаунт. + Деактивиране на екранни снимки + Когато тази опция е включена, екранните снимки и изгледът на приложението в „Последни“ са деактивирани. + Активиране на доставчика на текстове LrcLib + Активиране на доставчика на текстове KuGou + Доставчик на текстове по подразбиране + Четене на многоредови текстове + Третирайте всички редове между точките за синхронизация като един текст + Премахване на интервали около текста + Размер на шрифта + Скриване на нецензурно съдържание + Активиране на известията + + Архивиране и възстановяване + Архивиране + Възстановяване + Импортиране на плейлист (m3u) + Неуспешно импортиране на плейлист + Импортиран плейлист + Архивът е създаден успешно + Не можа да се създаде архив + Неуспешно възстановяване на архив + + Интеграция с Discord + Loudly използва библиотеката KizzyRPC, за да зададе статуса на вашия Discord акаунт. Това включва използването на връзка с Discord Gateway, което може да се счита за нарушение на Условията за ползване на Discord. Въпреки това, няма известни случаи на блокирани потребителски акаунти по тази причина. Използвайте на свой собствен риск.\n\nLoudly ще извлече само вашия токен, а всичко останало се съхранява локално. + Отхвърляне + Опции + Преглед + Неуспешен вход + Изход + Активиране на Rich Presence + + Относно + Версия на приложението + Проверка за актуализации... + версия %1$s е налична за изтегляне. + Изтегляне... + Надграждане + Опитайте отново + Изтеглете сега + Тип на приложението + + Налична е нова версия + Модели за превод + Изчистване на моделите за превод + Продължителност на историята + + 1 секунда + %d секунди + + Музика 3x2 + Музика 2x1 + Пусни/Пауза + Версия · Разработчици · Лиценз + UI + Съдържание и акаунт + Плейър + Система + Библиотека + Интеграции + Системна интеграция + Общо използвано пространство + В момента се използва + Изчистване на кеша + Архивиране · Възстановяване · Импортиране + История · Търсене · Екранна снимка + Изтегляния · Кеш · Изображения + Звук · Опашка · Текстове + Език · Местоположение · Прокси + Вход · Spotify · Discord + Тема · Плъзгач · Плейър + + Снимка на профилната карта + Персонализирайте снимката на профилната карта + Изберете профилно изображение + Готови изображения + Избор от галерията + Редактиране на снимка + Отказ + + Изпращане до разработчика + Затвори + Покажи подробности за грешката + Изтеглянето е неуспешно + Покажи грешка + Ръчна актуализация + Проверете страницата с настройки за подробности + Софтуерът е актуален + + UI на приложението + Изберете един от 3 различни дизайна + Избор на версия на UI + Дизайн на старата версия на приложението. (Приложение, Плейър, МиниПлейър) + Компактен дизайн, създаден с по-модерен подход. (Приложение, Плейър, МиниПлейър) + Най-популярният дизайн. (Приложение, Плейър, МиниПлейър) + Създайте свой собствен дизайн на UI на приложението. (Приложение, Плейър, МиниПлейър) + Персонализиране на UI + + Други настройки + Кликнете за други различни настройки + + Автоматично регулира качеството между 45 и 512 Kbps + Предлага най-високото качество до 512 Kbps + Задава качеството за средни интернет скорости между 128 и 256 Kbps. + Ограничава качеството до между 45 и 52 Kbps за пестене на данни (Икономия на данни) + + Не може да се импортира + + Подравняване на текста + Задайте позицията на текста на картата + Интензитет на градиента + Заобляне на ъглите + Профилна снимка + Профилна карта + Премахване на профилната снимка + Показване на текстове + Показване на поздравителния текст и потребителското име на профилната карта + Докоснете за промяна + + Превод (БЕТА) + Копиране + + Грешка в мрежата + Проверете интернет връзката си и опитайте отново. + Неподдържано съдържание + Този формат на песента не се поддържа от вашето устройство. + Грешка в излъчването на живо + Има проблем с излъчването на живо. Моля, рестартирайте. + Възпроизвеждането е неуспешно + Възникна неизвестна грешка. Моля, опитайте отново. + Опитваме отново... + Използването на VPN може да повлияе на качеството на връзката ви. Ако изпитвате проблеми с мрежата, изключете VPN връзката си или използвайте прокси. Можете да конфигурирате това в „Настройки >> Съдържание >> Прокси.“ + + Локалните песни са синхронизирани успешно + Локален скенер + + Сканиране автоматично + Автоматично сканиране за песни при отваряне на приложението + + Допълнителни настройки на скенера + Строги имена на файлове + Когато е активирано, разширенията НЯМА да бъдат игнорирани. Например, „Song.ogg“ ще бъде различна песен от „Song.flac“. + Конфигуриране на чувствителността на скенера + Съвпадение по заглавие + Съвпадение по заглавие и изпълнители + Съвпадение по заглавие, изпълнители, албуми + + Папки + Изравняване на подпапки + Деактивирайте, за да запазите структурата на файловете както е на диска + + Съвети и функции + Съвети · Функции + Създайте своята история + Споделете песента, която слушате, в социалните мрежи със стилен дизайн, създаден с доминиращите цветове на обложката на албума. + Персонализируеми настройки + Направете приложението свое, като замените фона на заглавната карта в екрана с настройки с изображение от вашата галерия. + Нов списък с песни + Прегледайте текущия си плейлист и лесно превключвайте между песните, като плъзнете нагоре от екрана на плейъра (нов дизайн, ексклузивен за Player UI 2.0). + Преработен дизайн на настройките + Намерете лесно това, което търсите, с нов дизайн на настройките, базиран на карти, който е по-модерен и удобен. + Класически музикален уиджет + Бърз достъп до всички контроли и информация за песента от началния екран. + Минималистичен музикален уиджет + Музикален уиджет „Quik“, вдъхновен от дизайна на бутона за бързи настройки на Android 16. + Споделяне в история + Копиране на връзката към песента + Връзката е копирана в клипборда! + Стриймвано в Loudly + + Избери всички + Извличане на всички плейлисти… + Импортиране на избраните елементи + Нещо се обърка. Ние сме също толкова объркани, колкото и вие. + Е, това е неудобно. Нямаме представа какво се е случило. + Инструкции за получаване на необходимите данни за достъп + 3. Уверете се, че сте кликнали върху „Добавяне“, след като сте въвели локалния път по-горе. + 5. Приемете условията за ползване и кликнете върху „Запазване“. + Ще бъдете пренасочени към нова страница. Кликнете върху „Настройки“, след това копирайте ID на клиента и Тайната на клиента и ги поставете в текстовите полета по-долу в това приложение. + 2. Въведете необходимите данни и използвайте http://127.0.0.1:45454 като URI за пренасочване. + 4. Превключете Web API. Полето за отметка вече трябва да има отметка. + Сега кликнете върху бутона „Упълномощаване и продължаване“ по-долу и влезте с акаунта, от който искате да импортирате, и упълномощете и продължете, което ще ви пренасочи към сайт, който трябва да започва с http://127.0.0.1:45454/?code=\n\nПоставете целия този URL адрес в текстовото поле по-долу, което казва „Код за упълномощаване“, и кликнете върху бутона „Удостоверяване“. + 1. Посетете таблото за разработчици на Spotify и кликнете върху „Създаване на приложение“. + Вход с данни за достъп до Spotify API + Упълномощаване и продължаване + Код за упълномощаване + Удостоверяване + Тайна на клиента + ID на клиента + Импортирането е в ход… + Не затваряйте приложението и не се връщайте назад. Тази операция не се изпълнява във фонов режим, така че останете, докато приключи!\nНЕ СЕ ПАНИКЬОСВАЙТЕ, АКО ИЗГЛЕЖДА, ЧЕ Е ЗАСЕднало; понякога извличането може да отнеме известно време. + Изберете дестинация за „Харесани песни“ + Къде трябва да се импортират харесаните песни? + Нов плейлист с име „Харесани песни“ + В плейлиста по подразбиране „Харесани песни“ + \ No newline at end of file diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml new file mode 100644 index 0000000..bcf68c8 --- /dev/null +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -0,0 +1,636 @@ + + + + Loudly-তে স্বাগত + আসুন আপনার সঙ্গীত অভিজ্ঞতাকে ব্যক্তিগতকরণ করা শুরু করি। + YouTube Music আপনার হাতের মুঠোয় + YT Music অ্যাকাউন্ট সিঙ্ক + বিজ্ঞাপন-মুক্ত প্লেব্যাক + অফলাইন সঙ্গীত অভিজ্ঞতা + Android Auto সমর্থন করে + Discord স্ট্যাটাস সমর্থন করে + সম্পূর্ণ ওপেন সোর্স + আপনার প্লেয়ার UI নির্বাচন করুন + অ্যাপ-মধ্যস্থ প্লেয়ার ভিউ নির্বাচন করুন। আপনি এই সেটিংটি পরে “সেটিংস > অ্যাপিয়ারেন্স” থেকে পরিবর্তন করতে পারেন। + সেটআপ সম্পন্ন! + আপনি Loudly অন্বেষণ করার জন্য প্রস্তুত। + শেষ করুন + ফিরে যান + পরবর্তী + পূর্ববর্তী + + হোম + এক্সপ্লোর + গান + শিল্পী + অ্যালবাম + প্লেলিস্ট + + পছন্দের সঙ্গীত + লাইক + অফলাইন সঙ্গীত + আমার সেরা সঙ্গীত + ক্যাশ করা সঙ্গীত + স্থানীয় সঙ্গীত + + কন্টেন্ট ফিল্টার + + সর্বকালের + গত ২৪ ঘন্টা + গত সপ্তাহ + গত মাস + গত বছর + + + %dটি নির্বাচিত + %dটি নির্বাচিত + + + সুপ্রভাত + শুভ অপরাহ্ন + শুভ সন্ধ্যা + শুভ রাত্রি + সাম্প্রতিক কার্যকলাপ + ইতিহাস + আপনার ইতিহাস এখানে দেখা যাবে + আপনার পরিসংখ্যান এখানে দেখা যাবে + পরিসংখ্যান + কম দেখান + আরও দেখান + মুড এবং জেনার + অ্যাকাউন্ট + দ্রুত বাছাই + আপনার দ্রুত বাছাই তৈরি করতে গান শুনুন + ভুলে যাওয়া প্রিয় + শুনতে থাকুন + আপনার YouTube প্লেলিস্ট + এর মতো + নতুন প্রকাশিত অ্যালবাম + কোনো ইন্টারনেট সংযোগ নেই + মনে হচ্ছে আপনার কোনো ইন্টারনেট সংযোগ নেই। অনুগ্রহ করে আপনার নেটওয়ার্ক পরীক্ষা করুন। + আপনার জন্য বৈশিষ্ট্যযুক্ত + আজ আপনার জন্য প্রস্তাবিত + + আজ + গতকাল + এই সপ্তাহ + গত সপ্তাহ + স্থানীয় + রিমোট + স্থানীয় ইতিহাস + রিমোট ইতিহাস + + সবচেয়ে বেশি বাজানো গান + সবচেয়ে বেশি বাজানো শিল্পী + সবচেয়ে বেশি বাজানো অ্যালবাম + + অনুসন্ধান + Loudly-তে অনুসন্ধান করুন + লাইব্রেরি অনুসন্ধান করুন... + অনুসন্ধানের ইতিহাস + পরামর্শ + অনুসন্ধানের ফলাফল + লাইব্রেরি + পছন্দের + ডাউনলোড করা + সব + গান + ভিডিও + অ্যালবাম + শিল্পী + প্লেলিস্ট + কমিউনিটি প্লেলিস্ট + বৈশিষ্ট্যযুক্ত প্লেলিস্ট + বুকমার্ক করা + কোনো ফলাফল পাওয়া যায়নি + + লাইব্রেরি এখানে দেখা যাবে + লাইব্রেরির গান এখানে দেখা যাবে + লাইব্রেরির শিল্পীরা এখানে দেখা যাবে + লাইব্রেরির অ্যালবাম এখানে দেখা যাবে + আপনার প্লেলিস্ট এখানে দেখা যাবে + + আপনার লাইব্রেরি থেকে + + অন্যান্য সংস্করণ + প্রিয় + সেরা গান + + পছন্দের গান + ডাউনলোড করা গান + প্লেলিস্টটি খালি + আপনি কি সত্যিই ডাউনলোড করা গানের স্টোরেজ থেকে \"%s\" প্লেলিস্টের সমস্ত গান সরাতে চান? + আপনি কি সত্যিই \"%s\" প্লেলিস্টটি মুছে ফেলতে চান? + + বন্ধ + + পুনরায় চেষ্টা করুন + রেডিও + শাফেল + রিসেট + + বিবরণ + সম্পাদনা + রেডিও শুরু করুন + চালান + পরবর্তী চালান + সারিতে যোগ করুন + লাইব্রেরিতে যোগ করুন + সব লাইব্রেরিতে যোগ করুন + লাইব্রেরি থেকে সরান + সব লাইব্রেরি থেকে সরান + ডাউনলোড + ডাউনলোড হচ্ছে + ডাউনলোড সরান + প্লেলিস্ট আমদানি করুন + প্লেলিস্টে যোগ করুন + শিল্পী দেখুন + অ্যালবাম দেখুন + পুনরায় আনুন + শেয়ার করুন + মুছে ফেলুন + ইতিহাস থেকে সরান + প্লেলিস্ট থেকে সরান + সারি থেকে সরান + ক্যাশ থেকে সরান + M3U ফাইল হিসাবে রপ্তানি করুন + অনলাইনে অনুসন্ধান করুন + সিঙ্ক + উন্নত + টেম্পো এবং পিচ + + যোগ করার তারিখ + শেষ আপডেটের তারিখ + নাম + শিল্পী + বছর + গানের সংখ্যা + দৈর্ঘ্য + বাজানোর সময় + কাস্টম ক্রম + + মিডিয়া আইডি + MIME প্রকার + কোডেক + বিটরেট + নমুনা হার + শব্দের তীব্রতা + ভলিউম + ফাইলের আকার + অজানা + ক্লিপবোর্ডে কপি করা হয়েছে + + গানের কথা সম্পাদনা করুন + গানের কথা অনুসন্ধান করুন + আপনি কি সত্যিই \"%s\"-এর জন্য সঞ্চিত গানের কথা মুছে ফেলতে চান? + + গান সম্পাদনা করুন + গানের শিরোনাম + গানের শিল্পী + গানের শিরোনাম খালি রাখা যাবে না। + গানের শিল্পী খালি রাখা যাবে না। + সংরক্ষণ করুন + + প্লেলিস্ট সিঙ্ক করুন + প্লেলিস্ট বাছুন + প্লেলিস্ট সম্পাদনা করুন + প্লেলিস্ট তৈরি করুন + প্লেলিস্টের নাম + প্লেলিস্টের নাম খালি রাখা যাবে না। + + শিল্পী সম্পাদনা করুন + শিল্পীর নাম + শিল্পীর নাম খালি রাখা যাবে না। + + ডুপ্লিকেট + ডুপ্লিকেট এড়িয়ে যান + তবুও যোগ করুন + গানটি আপনার প্লেলিস্টে ইতিমধ্যে আছে + আপনার প্লেলিস্টে ইতিমধ্যে %dটি গান আছে + দ্রষ্টব্য: এটি YouTube Music-এর সাথে সিঙ্ক করার অনুমতি দেয় + + + %dটি গান + %dটি গান + + + %d জন শিল্পী + %d জন শিল্পী + + + %dটি অ্যালবাম + %dটি অ্যালবাম + + + %dটি প্লেলিস্ট + %dটি প্লেলিস্ট + + + %d সপ্তাহ + %d সপ্তাহ + + + %d মাস + %d মাস + + + %d বছর + %d বছর + + + প্লেলিস্ট আমদানি করা হয়েছে + প্লেলিস্ট থেকে \"%s\" সরানো হয়েছে + প্লেলিস্ট সিঙ্ক করা হয়েছে + পূর্বাবস্থায় ফেরান + + এখন চলছে + গানের কথা পাওয়া যায়নি + স্লিপ টাইমার + গানের শেষে + + ১ মিনিট + %d মিনিট + + কোনো স্ট্রিম উপলব্ধ নেই + কোনো নেটওয়ার্ক সংযোগ নেই + সময়সীমা শেষ + অজানা ত্রুটি + মেনু + তালিকা শেয়ার করুন + + ৫ সেকেন্ড পিছনে + ৫ সেকেন্ড আগে + + লাইক + সব লাইক করুন + লাইক সরান + সব লাইক সরান + শাফেল চালু + শাফেল বন্ধ + রিপিট মোড বন্ধ + বর্তমান গানটি পুনরাবৃত্তি করুন + সারি পুনরাবৃত্তি করুন + + সব গান + অনুসন্ধান করা গান + + মিউজিক প্লেয়ার + + বিজ্ঞপ্তি + সিস্টেম দ্বারা অ্যাপ বন্ধ হওয়া প্রতিরোধ করুন + সিস্টেম দ্বারা Loudly-কে এলোমেলোভাবে বন্ধ হওয়া থেকে আটকাতে একটি ফোরগ্রাউন্ড বিজ্ঞপ্তি ব্যবহার করুন। + বিজ্ঞপ্তির অনুমতি প্রয়োজন + + সেটিংস + অ্যাপিয়ারেন্স + থিম + প্রক্সি + ডাইনামিক থিম সক্ষম করুন + ডার্ক থিম + চালু + বন্ধ + সিস্টেম অনুসরণ করুন + খাঁটি কালো + অ্যাপ ডিজাইন + অ্যাপ UI 1.0 + অ্যাপ UI 1.5 + YouTube Music-এ শুনুন + Loudly-তে যান + শিল্পী + Loudly-তে শুনুন + গান পরিবর্তন করতে থাম্বনেইলে সোয়াইপ সক্ষম করুন + নেভিগেশন ট্যাব কাস্টমাইজ করুন + প্লেয়ার + প্লেয়ার টেক্সট অ্যালাইনমেন্ট + গানের কথার টেক্সট পজিশন + পার্শ্বযুক্ত + বাম + কেন্দ্র + প্লেয়ার স্টাইল + প্লেয়ার পুরনো UI + প্লেয়ার UI 1.0 + প্লেয়ার UI 2.0 + থিম অনুসরণ করুন + গ্রেডিয়েন্ট + ব্লার + ব্লার (মোশন) + প্লেয়ার ব্যাকগ্রাউন্ড স্টাইল + ডান + স্লাইডার স্টাইল + ডিফল্ট + আঁকাবাঁকা + কম্পোজ + থাম্বনেইল কোণার ব্যাসার্ধ কনফিগার করুন + কন্টেন্ট ফিল্টার দেখান + বিবিধ + প্রধান + স্বয়ংক্রিয় প্লেলিস্ট কাস্টমাইজেশন + লাইক করা স্বয়ংক্রিয় প্লেলিস্ট দেখান + ডাউনলোড করা স্বয়ংক্রিয় প্লেলিস্ট দেখান + শীর্ষ স্বয়ংক্রিয় প্লেলিস্ট দেখান + ক্যাশ করা স্বয়ংক্রিয় প্লেলিস্ট দেখান + স্থানীয় স্বয়ংক্রিয় প্লেলিস্ট দেখান + পরবর্তী গানের জন্য ডানে বা সারিতে যোগ করার জন্য বামে সোয়াইপ করুন + ডিফল্ট খোলা ট্যাব + ডিফল্ট লাইব্রেরি চিপস + গ্রিড সেলের আকার + নীচের নেভিগেশন বারের লেবেল দেখান + ছোট + বড় + পরিবর্তনের লগ + কোনো সাম্প্রতিক পরিবর্তন পাওয়া যায়নি + সাম্প্রতিক পরিবর্তন: + বন্ধ করুন + নতুন সংস্করণ উপলব্ধ + আবারও হ্যালো + দান করুন + অফিসিয়াল চ্যানেল + ওয়েবসাইট + অফিসিয়াল ওয়েবসাইট ❯ + সোর্স কোড + অ্যাপের সোর্স কোড অ্যাক্সেস করুন + সমস্যা + বাগ রিপোর্ট এবং আরও অনেক কিছু + সাহায্য + আপনি যদি অবদান রাখতে চান, এখানে ক্লিক করুন ❯ + ডেভেলপার | @RRechz + + কন্টেন্ট + লগইন + আপনার YT Music প্লেলিস্ট এবং আরও অনেক কিছুতে সাইন ইন করুন + টোকেন দেখাতে ট্যাপ করুন + কপি বা সম্পাদনা করতে আবার ট্যাপ করুন + এটি একটি উন্নত লগইন পদ্ধতি। ওয়েব পোর্টালের বিকল্প হিসাবে, আপনি সরাসরি এখানে আপনার লগইন টোকেন প্রবেশ বা আপডেট করতে পারেন। উদাহরণস্বরূপ, এটি একাধিক ডিভাইসে লগইন দ্রুত করতে পারে। অনুগ্রহ করে মনে রাখবেন যে অ্যাপটি পার্স করতে ব্যর্থ হওয়া কোনো অবৈধ টোকেন ফর্ম্যাট গ্রহণ করা হবে না। + Spotify থেকে আমদানি করুন + Spotify + Discord + আপনার পছন্দের গানগুলি স্বয়ংক্রিয়ভাবে ডাউনলোড করুন + আপনার YouTube Music অ্যাকাউন্টের সাথে স্বয়ংক্রিয়ভাবে সিঙ্ক করুন + আপনার প্লেলিস্ট এবং আরও অনেক কিছু আপনার অ্যাকাউন্টের সাথে সিঙ্ক করুন + শুধুমাত্র ওয়াইফাই + লগইন করা নেই + ডিফল্ট কন্টেন্ট ভাষা + ডিফল্ট কন্টেন্ট দেশ + সিস্টেম ডিফল্ট + প্রক্সি সক্ষম করুন + প্রক্সির প্রকার + প্রক্সি URL + প্রভাব ফেলতে পুনরায় চালু করুন + অ্যাপের ভাষা + অ্যাপের ভাষা কনফিগার করুন + অ্যাপের ভাষা সেটিংস খুঁজে পাওয়া যায়নি, অনুগ্রহ করে ম্যানুয়ালি কনফিগার করুন + সমর্থিত লিঙ্ক ডিফল্টরূপে খুলুন + সমর্থিত লিঙ্ক কনফিগার করুন + সমর্থিত লিঙ্ক সেটিংস খুঁজে পাওয়া যায়নি, অনুগ্রহ করে ম্যানুয়ালি কনফিগার করুন + + প্লেয়ার এবং অডিও + অডিওর গুণমান + সর্বোচ্চ + স্বয়ংক্রিয় + উচ্চ + নিম্ন + ন্যূনতম প্লেব্যাক সময়কাল + একটি গান "বাজানো হয়েছে" বলে বিবেচিত হওয়ার আগে তার ন্যূনতম সময়কাল যা বাজাতে হবে + সারি + স্থায়ী সারি + অ্যাপ শুরু হলে আপনার শেষ সারি পুনরুদ্ধার করুন + স্বয়ংক্রিয়ভাবে আরও গান লোড করুন + সারি শেষ হলে স্বয়ংক্রিয়ভাবে আরও গান যোগ করুন, যদি সম্ভব হয় + নীরবতা এড়িয়ে যান + অডিও নরম্যালাইজেশন + ব্লুটুথ ডিভাইস সংযুক্ত হলে প্লেব্যাক চালিয়ে যান + গানের শব্দ ন্যূনতম ভলিউমে পৌঁছলে বাজানো বন্ধ করুন + বাজানো গানগুলি YouTube-Music ইতিহাসে যোগ করা হচ্ছে + ত্রুটি ঘটলে স্বয়ংক্রিয়ভাবে পরবর্তী গানে যান + আপনার নিরবচ্ছিন্ন প্লেব্যাক অভিজ্ঞতা নিশ্চিত করুন + টাস্ক পরিষ্কার করার সময় সঙ্গীত বন্ধ করুন + ইকুয়ালাইজার + গানের কথা + গানের কথা + স্থানীয় প্লেয়ার সেটিংস + স্টোরেজ + ক্যাশ + চিত্র ক্যাশ + গান ক্যাশ + সর্বাধিক ক্যাশ আকার + সীমাহীন + অফলোড সক্ষম করুন + অডিও প্লেব্যাকের জন্য অফলোড অডিও পাথ ব্যবহার করুন। এটি অক্ষম করলে বিদ্যুৎ খরচ বাড়তে পারে তবে অডিও প্লেব্যাক বা পোস্ট প্রসেসিং-এ সমস্যা হলে এটি কার্যকর হতে পারে। + সমস্ত ডাউনলোড মুছুন + সর্বাধিক চিত্র ক্যাশ আকার + চিত্র ক্যাশ মুছুন + সর্বাধিক গান ক্যাশ আকার + গান ক্যাশ মুছুন + ব্যবহৃত + আপনি কি নিশ্চিত যে আপনি সমস্ত ক্যাশ মুছতে চান? + আপনি কি নিশ্চিত যে আপনি সমস্ত ডাউনলোড মুছতে চান? + আপনি কি নিশ্চিত যে আপনি সমস্ত ক্যাশ করা গান মুছতে চান? + আপনি কি নিশ্চিত যে আপনি সমস্ত ক্যাশ করা ছবি মুছতে চান? + আপনি কি নিশ্চিত যে আপনি সমস্ত ক্যাশ করা অনুবাদ মডেল মুছতে চান? + + গোপনীয়তা + শোনার ইতিহাস + শোনার ইতিহাস সাময়িকভাবে বন্ধ করুন + শোনার ইতিহাস মুছুন + আপনি কি নিশ্চিত যে আপনি সমস্ত শোনার ইতিহাস মুছতে চান? + অনুসন্ধানের ইতিহাস সাময়িকভাবে বন্ধ করুন + অনুসন্ধানের ইতিহাস মুছুন + আপনি কি নিশ্চিত যে আপনি সমস্ত অনুসন্ধানের ইতিহাস মুছতে চান? + অনুসন্ধানের ইতিহাস + কন্টেন্ট ব্রাউজ করার জন্য লগইন ব্যবহার করুন + এটি আপনার দেখা কন্টেন্টকে প্রভাবিত করতে পারে এবং উদাহরণস্বরূপ, আপনি যদি একটি প্রিমিয়াম অ্যাকাউন্ট দিয়ে লগইন করেন তবে শুধুমাত্র প্রিমিয়াম অ্যালবাম দেখায়। + স্ক্রিনশট অক্ষম করুন + এই বিকল্পটি চালু থাকলে, স্ক্রিনশট এবং সাম্প্রতিক অ্যাপগুলিতে অ্যাপের ভিউ অক্ষম করা হয়। + LrcLib লিরিক্স প্রদানকারী সক্ষম করুন + KuGou লিরিক্স প্রদানকারী সক্ষম করুন + ডিফল্ট লিরিক্স প্রদানকারী + একাধিক লাইনের লিরিক্স পড়ুন + সিঙ্ক পয়েন্টগুলির মধ্যে সমস্ত লাইনকে একটি লিরিক্স টেক্সট হিসাবে বিবেচনা করুন + লিরিক্সের চারপাশের স্পেস সরান + ফন্টের আকার + অশ্লীল কন্টেন্ট লুকান + বিজ্ঞপ্তি সক্ষম করুন + + ব্যাকআপ এবং পুনরুদ্ধার + ব্যাকআপ + পুনরুদ্ধার + একটি প্লেলিস্ট আমদানি করুন (m3u) + প্লেলিস্ট আমদানি করতে ব্যর্থ + আমদানি করা প্লেলিস্ট + ব্যাকআপ সফলভাবে তৈরি করা হয়েছে + ব্যাকআপ তৈরি করা যায়নি + ব্যাকআপ পুনরুদ্ধার করতে ব্যর্থ + + Discord ইন্টিগ্রেশন + Loudly আপনার Discord অ্যাকাউন্টের স্ট্যাটাস সেট করতে KizzyRPC লাইব্রেরি ব্যবহার করে। এর মধ্যে Discord গেটওয়ে সংযোগ ব্যবহার করা হয়, যা Discord-এর পরিষেবার শর্তাবলীর লঙ্ঘন হিসাবে বিবেচিত হতে পারে। তবে, এই কারণে ব্যবহারকারীর অ্যাকাউন্ট স্থগিত হওয়ার কোনো পরিচিত ঘটনা নেই। নিজের ঝুঁকিতে ব্যবহার করুন।\n\nLoudly শুধুমাত্র আপনার টোকেন বের করবে, এবং বাকি সবকিছু স্থানীয়ভাবে সংরক্ষণ করা হবে। + খারিজ করুন + বিকল্প + প্রিভিউ + লগইন ব্যর্থ + লগআউট + রিচ প্রেজেন্স সক্ষম করুন + + সম্পর্কে + অ্যাপ সংস্করণ + আপডেটের জন্য পরীক্ষা করা হচ্ছে... + সংস্করণ %1$s ডাউনলোডের জন্য উপলব্ধ। + ডাউনলোড হচ্ছে... + আপগ্রেড + আবার চেষ্টা করুন + এখনই ডাউনলোড করুন + অ্যাপের প্রকার + + নতুন সংস্করণ উপলব্ধ + অনুবাদ মডেল + অনুবাদ মডেল মুছুন + ইতিহাসের সময়কাল + + ১ সেকেন্ড + %d সেকেন্ড + + সঙ্গীত 3x2 + সঙ্গীত 2x1 + চালান/বিরতি + সংস্করণ · ডেভেলপার · লাইসেন্স + UI + কন্টেন্ট ও অ্যাকাউন্ট + প্লেয়ার + সিস্টেম + লাইব্রেরি + ইন্টিগ্রেশন + সিস্টেম ইন্টিগ্রেশন + মোট ব্যবহৃত স্থান + বর্তমানে ব্যবহৃত + ক্যাশ মুছুন + ব্যাকআপ · পুনরুদ্ধার · আমদানি + ইতিহাস · অনুসন্ধান · স্ক্রিনশট + ডাউনলোড · ক্যাশ · ছবি + শব্দ · সারি · গানের কথা + ভাষা · অবস্থান · প্রক্সি + লগইন · Spotify · Discord + থিম · স্লাইডার · প্লেয়ার + + প্রোফাইল কার্ডের ছবি + প্রোফাইল কার্ডের ছবি কাস্টমাইজ করুন + প্রোফাইল ছবি বাছুন + প্রস্তুত ছবি + গ্যালারি থেকে বাছুন + ছবি সম্পাদনা করুন + বাতিল করুন + + ডেভেলপারকে পাঠান + বন্ধ করুন + ত্রুটির বিবরণ দেখান + ডাউনলোড ব্যর্থ + ত্রুটি দেখান + ম্যানুয়াল আপডেট + বিবরণের জন্য সেটিংস পৃষ্ঠা দেখুন + সফ্টওয়্যার আপ টু ডেট + + অ্যাপ UI + ৩টি ভিন্ন ডিজাইনের মধ্যে একটি নির্বাচন করুন + UI সংস্করণ নির্বাচন + অ্যাপ্লিকেশনের পুরানো সংস্করণ ডিজাইন। (অ্যাপ, প্লেয়ার, মিনিপ্লেয়ার) + একটি আরও আধুনিক পদ্ধতির সাথে তৈরি করা কমপ্যাক্ট ডিজাইন। (অ্যাপ, প্লেয়ার, মিনিপ্লেয়ার) + সবচেয়ে জনপ্রিয় ডিজাইন। (অ্যাপ, প্লেয়ার, মিনিপ্লেয়ার) + আপনার নিজস্ব অ্যাপ UI ডিজাইন তৈরি করুন। (অ্যাপ, প্লেয়ার, মিনিপ্লেয়ার) + UI কাস্টমাইজ করুন + + অন্যান্য সেটিংস + অন্যান্য বিভিন্ন সেটিংসের জন্য ক্লিক করুন + + স্বয়ংক্রিয়ভাবে 45 থেকে 512 Kbps এর মধ্যে গুণমান সামঞ্জস্য করে + 512 Kbps পর্যন্ত সর্বোচ্চ গুণমান প্রদান করে + গড় ইন্টারনেট গতির জন্য 128 থেকে 256 Kbps এর মধ্যে গুণমান সেট করে। + ডেটা বাঁচাতে গুণমান 45 থেকে 52 Kbps এর মধ্যে সীমাবদ্ধ করে (ডেটা সেভার) + + আমদানি করা যায়নি + + টেক্সট অ্যালাইনমেন্ট + কার্ডে টেক্সটের অবস্থান সেট করুন + গ্রেডিয়েন্ট তীব্রতা + কোণার গোলাকারতা + প্রোফাইল ছবি + প্রোফাইল কার্ড + প্রোফাইল ছবি সরান + টেক্সট দেখান + প্রোফাইল কার্ডে শুভেচ্ছা এবং ব্যবহারকারীর নামের টেক্সট প্রদর্শন করুন + পরিবর্তন করতে ট্যাপ করুন + + অনুবাদ (বিটা) + কপি + + নেটওয়ার্ক ত্রুটি + আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন। + অসমর্থিত কন্টেন্ট + এই গানের ফরম্যাট আপনার ডিভাইসে সমর্থিত নয়। + লাইভ সম্প্রচার ত্রুটি + লাইভ স্ট্রিমে একটি সমস্যা আছে। অনুগ্রহ করে পুনরায় চালু করুন। + প্লেব্যাক ব্যর্থ + একটি অজানা ত্রুটি ঘটেছে। অনুগ্রহ করে আবার চেষ্টা করুন। + আবার চেষ্টা করা হচ্ছে... + ভিপিএন ব্যবহার আপনার সংযোগের গুণমানকে প্রভাবিত করতে পারে। যদি আপনি নেটওয়ার্ক সমস্যা অনুভব করেন, আপনার ভিপিএন সংযোগ বিচ্ছিন্ন করুন বা একটি প্রক্সি ব্যবহার করুন। আপনি এটি “সেটিংস >> কন্টেন্ট >> প্রক্সি”-তে কনফিগার করতে পারেন। + + স্থানীয় গান সফলভাবে সিঙ্ক করা হয়েছে + স্থানীয় স্ক্যানার + + স্বয়ংক্রিয়ভাবে স্ক্যান করুন + অ্যাপ খোলার সময় স্বয়ংক্রিয়ভাবে গান স্ক্যান করুন + + অতিরিক্ত স্ক্যানার সেটিংস + কঠোর ফাইলের নাম + সক্ষম করা হলে এক্সটেনশন উপেক্ষা করা হবে না। যেমন, "Song.ogg" এবং "Song.flac" ভিন্ন গান হিসাবে বিবেচিত হবে। + স্ক্যানার সংবেদনশীলতা কনফিগার করুন + শিরোনাম মেলান + শিরোনাম এবং শিল্পী মেলান + শিরোনাম, শিল্পী, অ্যালবাম মেলান + + ফোল্ডার + সাবফোল্ডার সমতল করুন + ডিস্কে যেমন ফাইল কাঠামো আছে তেমন রাখতে অক্ষম করুন + + টিপস ও ফিচার + টিপস · ফিচার + আপনার স্টোরি তৈরি করুন + আপনি যে গানটি শুনছেন তা অ্যালবামের কভারের প্রধান রঙ দিয়ে তৈরি একটি স্টাইলিশ ডিজাইনের সাথে সোশ্যাল মিডিয়াতে শেয়ার করুন। + কাস্টমাইজযোগ্য সেটিংস + সেটিংস স্ক্রিনে টাইটেল কার্ডের পটভূমি আপনার গ্যালারির একটি ছবি দিয়ে প্রতিস্থাপন করে অ্যাপটিকে আপনার নিজের করে তুলুন। + নতুন গানের তালিকা + আপনার বর্তমান প্লেলিস্ট দেখুন এবং প্লেয়ার স্ক্রিন থেকে উপরে সোয়াইপ করে সহজেই গান পরিবর্তন করুন (প্লেয়ার UI 2.0-এর জন্য নতুন ডিজাইন)। + রিমাস্টার করা সেটিংস ডিজাইন + একটি নতুন কার্ড-ভিত্তিক সেটিংস ডিজাইনের মাধ্যমে যা আরও আধুনিক এবং সুবিধাজনক, আপনি যা খুঁজছেন তা সহজেই খুঁজে পান। + ক্লাসিক মিউজিক উইজেট + আপনার হোম স্ক্রিন থেকে দ্রুত সমস্ত নিয়ন্ত্রণ এবং গানের তথ্য অ্যাক্সেস করুন। + মিনিমালিস্ট মিউজিক উইজেট + অ্যান্ড্রয়েড ১৬-এর কুইক সেটিংস বোতাম ডিজাইন দ্বারা অনুপ্রাণিত "কুইক" মিউজিক উইজেট। + স্টোরিতে শেয়ার করুন + গানের লিঙ্ক কপি করুন + লিঙ্ক ক্লিপবোর্ডে কপি করা হয়েছে! + Loudly-তে স্ট্রিম করা হয়েছে + + সব নির্বাচন করুন + সমস্ত প্লেলিস্ট আনা হচ্ছে… + নির্বাচিত আইটেম আমদানি করুন + কিছু একটা ভুল হয়েছে। আমরাও আপনার মতোই বিভ্রান্ত। + আচ্ছা, এটা বিব্রতকর। কী হয়েছে সে সম্পর্কে আমাদেরও কোনো ধারণা নেই। + প্রয়োজনীয় শংসাপত্র পাওয়ার নির্দেশাবলী + 3. উপরের স্থানীয় পাথ প্রবেশ করার পরে "Add" ক্লিক করতে ভুলবেন না। + 5. পরিষেবার শর্তাবলী গ্রহণ করুন এবং "Save" ক্লিক করুন। + আপনাকে একটি নতুন পৃষ্ঠায় পুনঃনির্দেশিত করা হবে। Settings-এ ক্লিক করুন, তারপর Client ID এবং Client Secret কপি করুন, এবং এই অ্যাপের নীচের টেক্সট ফিল্ডে পেস্ট করুন। + 2. প্রয়োজনীয় বিবরণ লিখুন এবং Redirect URIs হিসাবে http://127.0.0.1:45454 ব্যবহার করুন। + 4. Web API টগল করুন। চেকবক্সে এখন একটি টিক চিহ্ন থাকা উচিত। + এখন, নীচের Authorize and Continue বোতামে ক্লিক করুন এবং আপনার যে অ্যাকাউন্ট থেকে আমদানি করতে চান তা দিয়ে লগইন করুন, এবং Authorize and Continue করুন, যা আপনাকে এমন একটি সাইটে পুনঃনির্দেশিত করবে যা http://127.0.0.1:45454/?code= দিয়ে শুরু হবে।\n\nAuthorization Code লেখা নীচের টেক্সট ফিল্ডে সম্পূর্ণ URL-টি পেস্ট করুন এবং Authenticate বোতামে ক্লিক করুন। + 1. Spotify for developers ড্যাশবোর্ডে যান এবং "Create app"-এ ক্লিক করুন। + Spotify API শংসাপত্র দিয়ে লগইন করুন + অনুমোদন করুন এবং চালিয়ে যান + অনুমোদন কোড + প্রমাণীকরণ করুন + ক্লায়েন্ট সিক্রেট + ক্লায়েন্ট আইডি + আমদানি চলছে... + অ্যাপটি বন্ধ করবেন না বা ফিরে যাবেন না। এই অপারেশনটি ব্যাকগ্রাউন্ডে চলে না, তাই এটি সম্পন্ন না হওয়া পর্যন্ত এখানেই থাকুন!\nযদি এটি আটকে গেছে বলে মনে হয় তবে আতঙ্কিত হবেন না; কখনও কখনও পুনরুদ্ধার করতে কিছু সময় লাগতে পারে। + "পছন্দের গান"-এর গন্তব্য বাছুন + পছন্দের গানগুলি কোথায় আমদানি করা উচিত? + "পছন্দের গান" নামের একটি নতুন প্লেলিস্ট + ডিফল্ট "পছন্দের গান"-এর মধ্যে + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000..8031c82 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,656 @@ + + + + Vítejte v Loudly + Začněme s přizpůsobením vašeho hudebního zážitku. + YouTube Music na dosah ruky + Synchronizace účtu YT Music + Přehrávání bez reklam + Poslech hudby offline + Podpora Android Auto + Podpora stavu Discord + Plně open source + Vyberte si uživatelské rozhraní přehrávače + Vyberte zobrazení přehrávače v aplikaci. Toto nastavení můžete později změnit v „Nastavení > Vzhled“. + Nastavení dokončeno! + Jste připraveni objevovat Loudly. + Dokončit + Zpět + Další + Předchozí + + Domů + Objevit + Skladby + Umělci + Alba + Seznamy skladeb + + Oblíbená hudba + To se mi líbí + Offline hudba + Moje top hudba + Hudba v mezipaměti + Místní hudba + + Filtr obsahu + + Všechna doba + Posledních 24 hodin + Poslední týden + Poslední měsíc + Poslední rok + + + %d vybrán + %d vybrány + %d vybráno + %d vybráno + + + Dobré ráno + Dobré odpoledne + Dobrý večer + Dobrou noc + Nedávná aktivita + Historie + Vaše historie se zobrazí zde + Vaše statistiky se zobrazí zde + Statistiky + Zobrazit méně + Zobrazit více + Nálady a žánry + Účet + Rychlý výběr + Poslouchejte skladby pro generování rychlého výběru + Zapomenuté oblíbené + Pokračujte v poslechu + Vaše seznamy skladeb na YouTube + Podobné jako + Nově vydaná alba + Žádné připojení k internetu + Zdá se, že nemáte připojení k internetu. Zkontrolujte prosím svou síť. + Doporučeno pro vás + Doporučeno pro vás dnes + + Dnes + Včera + Tento týden + Minulý týden + Místní + Vzdálená + Místní historie + Online historie + + Nejhranější skladby + Nejhranější umělci + Nejhranější alba + + Hledat + Hledat v Loudly + Hledat v knihovně… + Historie vyhledávání + Návrhy + Výsledky vyhledávání + Knihovna + Oblíbené + Stažené + Vše + Skladby + Videa + Alba + Umělci + Seznamy skladeb + Komunitní seznamy skladeb + Doporučené seznamy skladeb + Uložené do záložek + Nebyly nalezeny žádné výsledky + + Knihovna se zobrazí zde + Skladby z knihovny se zobrazí zde + Umělci z knihovny se zobrazí zde + Alba z knihovny se zobrazí zde + Vaše seznamy skladeb se zobrazí zde + + Z vaší knihovny + + Jiné verze + Oblíbené + Top skladby + + Oblíbené skladby + Stažené skladby + Seznam skladeb je prázdný + Opravdu chcete odstranit všechny skladby ze seznamu „%s“ z úložiště stažených skladeb? + Opravdu chcete smazat seznam skladeb „%s“? + + Vypnuto + + Zkusit znovu + Rádio + Zamíchat + Resetovat + + Podrobnosti + Upravit + Spustit rádio + Přehrát + Přehrát další + Přidat do fronty + Přidat do knihovny + Přidat vše do knihovny + Odebrat z knihovny + Odebrat vše z knihovny + Stáhnout + Stahování + Odebrat stažené + Importovat seznam skladeb + Přidat do seznamu skladeb + Zobrazit umělce + Zobrazit album + Znovu načíst + Sdílet + Smazat + Odebrat z historie + Odebrat ze seznamu skladeb + Odebrat z fronty + Odebrat z mezipaměti + Exportovat jako soubor M3U + Hledat online + Synchronizovat + Pokročilé + Tempo a výška tónu + + Datum přidání + Datum aktualizace + Název + Umělec + Rok + Počet skladeb + Délka + Doba přehrávání + Vlastní pořadí + + ID média + Typ MIME + Kodeky + Datový tok + Vzorkovací frekvence + Hlasitost + Hlasitost zvuku + Velikost souboru + Neznámé + Zkopírováno do schránky + + Upravit text + Hledat text + Opravdu chcete smazat uložený text pro „%s“? + + Upravit skladbu + Název skladby + Umělci skladby + Název skladby nemůže být prázdný. + Umělec skladby nemůže být prázdný. + Uložit + + Synchronizovat seznam skladeb + Vybrat seznam skladeb + Upravit seznam skladeb + Vytvořit seznam skladeb + Název seznamu skladeb + Název seznamu skladeb nemůže být prázdný. + + Upravit umělce + Jméno umělce + Jméno umělce nemůže být prázdné. + + Duplikáty + Přeskočit duplikáty + Přidat i tak + Skladba je již ve vašem seznamu skladeb + %d skladeb je již ve vašem seznamu skladeb + Poznámka: Toto umožňuje synchronizaci s YouTube Music + + + %d skladba + %d skladby + %d skladeb + %d skladeb + + + %d umělec + %d umělci + %d umělců + %d umělců + + + %d album + %d alba + %d alb + %d alb + + + %d seznam skladeb + %d seznamy skladeb + %d seznamů skladeb + %d seznamů skladeb + + + %d týden + %d týdny + %d týdnů + %d týdnů + + + %d měsíc + %d měsíce + %d měsíců + %d měsíců + + + %d rok + %d roky + %d let + %d let + + + Seznam skladeb importován + Skladba „%s“ odebrána ze seznamu skladeb + Seznam skladeb synchronizován + Zpět + + Právě hraje + Text nebyl nalezen + Časovač vypnutí + Konec skladby + + 1 minuta + %d minuty + %d minut + %d minut + + Není dostupný žádný stream + Žádné připojení k síti + Časový limit vypršel + Neznámá chyba + Menu + Sdílet seznam + + 5 sekund zpět + 5 sekund vpřed + + To se mi líbí + Označit vše jako oblíbené + Odebrat z oblíbených + Odebrat vše z oblíbených + Náhodné přehrávání zapnuto + Náhodné přehrávání vypnuto + Režim opakování vypnut + Opakovat aktuální skladbu + Opakovat frontu + + Všechny skladby + Hledané skladby + + Přehrávač hudby + + Oznámení + Zabránit systému v ukončení aplikace + Použijte oznámení na popředí, abyste zabránili náhodnému ukončení Loudly systémem. + Je vyžadováno povolení k oznámením + + Nastavení + Vzhled + Motiv + Proxy + Povolit dynamický motiv + Tmavý motiv + Zapnuto + Vypnuto + Podle systému + Čistě černá + Design aplikace + UI aplikace 1.0 + UI aplikace 1.5 + Poslouchat na YouTube Music + Navštívit Loudly + Umělec + Poslouchat Loudly + Povolit přejetí po náhledu pro změnu skladby + Přizpůsobit navigační karty + Přehrávač + Zarovnání textu přehrávače + Pozice textu + Stranou + Vlevo + Na střed + Styl přehrávače + Staré UI přehrávače + UI přehrávače 1.0 + UI přehrávače 2.0 + Podle motivu + Přechod + Rozmazání + Rozmazání (pohyb) + Styl pozadí přehrávače + Vpravo + Styl posuvníku + Výchozí + Vlnitý + Compose + Nastavit poloměr zaoblení náhledu + Zobrazit filtr obsahu + Různé + Hlavní + Přizpůsobení automatických seznamů skladeb + Zobrazit automatický seznam oblíbených + Zobrazit automatický seznam stažených + Zobrazit automatický seznam top + Zobrazit automatický seznam v mezipaměti + Zobrazit automatický seznam místních + Přejetím doprava přehrajete další, přejetím doleva přidáte do fronty + Výchozí otevřená karta + Výchozí filtry knihovny + Velikost buňky mřížky + Zobrazit popisky spodní navigační lišty + Malý + Velký + Seznam změn + Nebyly nalezeny žádné nedávné změny + Poslední změny: + Zavřít + Je k dispozici nová verze + Zdravím znovu + Přispět + Oficiální kanál + Webová stránka + Oficiální webová stránka ❯ + Zdrojový kód + Přístup ke zdrojovému kódu aplikace + Problémy + Hlášení chyb a další + Nápověda + Pokud chcete přispět, klikněte zde ❯ + Vývojář | @RRechz + + Obsah + Přihlášení + Přihlaste se ke svým seznamům skladeb na YT Music a dalším + Klepnutím zobrazíte token + Klepněte znovu pro zkopírování nebo úpravu + Toto je POKROČILÁ metoda přihlášení. Jako alternativu k webovému portálu můžete přímo zde zadat nebo aktualizovat svůj přihlašovací token. To může například urychlit přihlášení na více zařízeních. Upozorňujeme, že jakékoli neplatné formáty tokenů, které aplikace nedokáže analyzovat, nebudou přijaty. + Importovat ze Spotify + Spotify + Discord + Automaticky stahovat oblíbené skladby + Automaticky synchronizovat s vaším účtem YouTube Music + Synchronizujte své seznamy skladeb a další s vaším účtem + Pouze Wi-Fi + Nepřihlášen + Výchozí jazyk obsahu + Výchozí země obsahu + Podle systému + Povolit proxy + Typ proxy + URL proxy + Pro projevení změn restartujte + Jazyk aplikace + Konfigurovat jazyky aplikace + Nelze najít nastavení jazyka aplikace, nakonfigurujte je prosím ručně + Standardně otevírat podporované odkazy + Konfigurovat podporované odkazy + Nelze najít nastavení podporovaných odkazů, nakonfigurujte je prosím ručně + + Přehrávač a zvuk + Kvalita zvuku + Maximální + Automatická + Vysoká + Nízká + Minimální doba přehrávání + Minimální doba, po kterou musí být skladba přehrávána, aby byla považována za „přehranou“ + Fronta + Trvalá fronta + Obnovit poslední frontu při spuštění aplikace + Automaticky načíst více skladeb + Automaticky přidat více skladeb po dosažení konce fronty, pokud je to možné + Přeskočit ticho + Normalizace zvuku + Pokračovat v přehrávání po připojení zařízení Bluetooth + Zastavit přehrávání při minimální hlasitosti + Přidávání přehrávaných skladeb do historie YouTube Music + Automaticky přeskočit na další skladbu při chybě + Zajistěte si nepřetržitý zážitek z poslechu + Zastavit hudbu při vymazání úkolů + Ekvalizér + Texty + Texty + Nastavení místního přehrávače + Úložiště + Mezipaměť + Mezipaměť obrázků + Mezipaměť skladeb + Maximální velikost mezipaměti + Neomezená + Povolit offload + Použijte offload audio cestu pro přehrávání zvuku. Vypnutí této funkce může zvýšit spotřebu energie, ale může být užitečné, pokud máte problémy s přehráváním zvuku nebo následným zpracováním. + Vymazat všechna stažení + Maximální velikost mezipaměti obrázků + Vymazat mezipaměť obrázků + Maximální velikost mezipaměti skladeb + Vymazat mezipaměť skladeb + použito + Opravdu chcete vymazat veškerou mezipaměť? + Opravdu chcete vymazat všechna stažení? + Opravdu chcete vymazat všechny skladby v mezipaměti? + Opravdu chcete vymazat všechny obrázky v mezipaměti? + Opravdu chcete vymazat všechny modely překladu v mezipaměti? + + Soukromí + Historie poslechu + Pozastavit historii poslechu + Vymazat historii poslechu + Opravdu chcete vymazat veškerou historii poslechu? + Pozastavit historii vyhledávání + Vymazat historii vyhledávání + Opravdu chcete vymazat veškerou historii vyhledávání? + Historie vyhledávání + Použít přihlášení pro procházení obsahu + To může ovlivnit, jaký obsah vidíte, a například zobrazit alba pouze pro prémiové účty, pokud jste přihlášeni s prémiovým účtem. + Zakázat snímky obrazovky + Když je tato možnost zapnutá, snímky obrazovky a náhled aplikace v nedávných úlohách jsou zakázány. + Povolit poskytovatele textů LrcLib + Povolit poskytovatele textů KuGou + Výchozí poskytovatel textů + Číst víceřádkové texty + Považovat všechny řádky mezi synchronizačními body za jeden text + Odebrat mezery kolem textu + Velikost písma + Skrýt explicitní obsah + Povolit oznámení + + Zálohování a obnovení + Zálohovat + Obnovit + Importovat seznam skladeb (m3u) + Import seznamu skladeb se nezdařil + Importovaný seznam skladeb + Záloha úspěšně vytvořena + Nepodařilo se vytvořit zálohu + Obnovení zálohy se nezdařilo + + Integrace s Discordem + Loudly používá knihovnu KizzyRPC k nastavení stavu vašeho účtu na Discordu. To zahrnuje použití připojení k bráně Discord, což může být považováno za porušení podmínek služby Discord. Nejsou však známy žádné případy, kdy by byl účet uživatele z tohoto důvodu pozastaven. Používejte na vlastní nebezpečí.\n\nLoudly extrahuje pouze váš token, vše ostatní je uloženo lokálně. + Zavřít + Možnosti + Náhled + Přihlášení se nezdařilo + Odhlásit se + Povolit Rich Presence + + O aplikaci + Verze aplikace + Kontrola aktualizací... + verze %1$s je k dispozici ke stažení. + Stahování... + Aktualizovat + Zkusit znovu + Stáhnout nyní + Typ aplikace + + Je k dispozici nová verze + Modely překladu + Vymazat modely překladu + Doba historie + + 1 sekunda + %d sekundy + %d sekund + %d sekund + + Hudba 3x2 + Hudba 2x1 + Přehrát/Pozastavit + Verze · Vývojáři · Licence + UI + Obsah a účet + Přehrávač + Systém + Knihovna + Integrace + Systémová integrace + Celkové využité místo + Aktuálně využito + Vymazat mezipaměť + Zálohování · Obnovení · Import + Historie · Hledání · Snímek obrazovky + Stažené · Mezipaměť · Obrázky + Zvuk · Fronta · Texty + Jazyk · Umístění · Proxy + Přihlášení · Spotify · Discord + Motiv · Posuvník · Přehrávač + + Obrázek profilové karty + Přizpůsobit obrázek na profilové kartě + Vybrat profilový obrázek + Předdefinované obrázky + Vybrat z galerie + Upravit fotografii + Zrušit + + Odeslat vývojáři + Zavřít + Zobrazit podrobnosti o chybě + Stažení se nezdařilo + Zobrazit chybu + Ruční aktualizace + Podrobnosti naleznete na stránce nastavení + Software je aktuální + + UI aplikace + Vyberte jeden ze 3 různých designů + Výběr verze UI + Starý design verze aplikace. (Aplikace, Přehrávač, MiniPřehrávač) + Kompaktní design vytvořený s modernějším přístupem. (Aplikace, Přehrávač, MiniPřehrávač) + Nejpopulárnější design. (Aplikace, Přehrávač, MiniPřehrávač) + Vytvořte si vlastní design UI aplikace. (Aplikace, Přehrávač, MiniPřehrávač) + Přizpůsobit UI + + Další nastavení + Klikněte pro další různá nastavení + + Automaticky upravuje kvalitu mezi 45 a 512 Kbps + Nabízí nejvyšší kvalitu až 512 Kbps + Nastavuje kvalitu pro průměrné rychlosti internetu mezi 128 a 256 Kbps. + Omezuje kvalitu na 45 až 52 Kbps pro úsporu dat (Šetřič dat) + + Nelze importovat + + Zarovnání textu + Nastavte pozici textu na kartě + Intenzita přechodu + Zaoblení rohů + Profilový obrázek + Profilová karta + Odebrat profilový obrázek + Zobrazit texty + Zobrazit text pozdravu a uživatelského jména na profilové kartě + Klepnutím změníte + + Přeložit (BETA) + Kopírovat + + Chyba sítě + Zkontrolujte připojení k internetu a zkuste to znovu. + Nepodporovaný obsah + Tento formát skladby není na vašem zařízení podporován. + Chyba živého vysílání + Vyskytl se problém s živým přenosem. Restartujte prosím. + Přehrávání se nezdařilo + Vyskytla se neznámá chyba. Zkuste to prosím znovu. + Zkouším znovu… + Používání VPN může ovlivnit kvalitu vašeho připojení. Pokud máte problémy se sítí, odpojte VPN připojení nebo použijte proxy. To můžete nakonfigurovat v „Nastavení >> Obsah >> Proxy.“ + + Místní skladby úspěšně synchronizovány + Místní skener + + Skenovat automaticky + Automaticky skenovat skladby při otevření aplikace + + Další nastavení skeneru + Přísné názvy souborů + Když je povoleno, přípony NEBUDOU ignorovány. Např. „Song.ogg“ bude jiná skladba než „Song.flac“ + Konfigurovat citlivost skeneru + Shoda názvu + Shoda názvu a umělců + Shoda názvu, umělců, alb + + Složky + Sloučit podsložky + Vypněte pro zachování struktury souborů jako na disku + + Tipy a funkce + Tipy · Funkce + Vytvořte si svůj příběh + Sdílejte skladbu, kterou posloucháte, na sociálních sítích se stylovým designem vytvořeným s dominantními barvami obalu alba. + Přizpůsobitelná nastavení + Přizpůsobte si aplikaci nahrazením pozadí titulní karty na obrazovce nastavení obrázkem z vaší galerie. + Nový seznam skladeb + Zobrazte si aktuální seznam skladeb a snadno přepínejte mezi skladbami přejetím nahoru z obrazovky přehrávače (nový design exkluzivní pro Player UI 2.0). + Přepracovaný design nastavení + Najděte snadno, co hledáte, s novým designem nastavení založeným na kartách, který je modernější a pohodlnější. + Klasický hudební widget + Rychlý přístup ke všem ovládacím prvkům a informacím o skladbě z vaší domovské obrazovky. + Minimalistický hudební widget + Hudební widget „Quik“ inspirovaný designem tlačítka Rychlých nastavení Androidu 16. + Sdílet v příběhu + Kopírovat odkaz na skladbu + Odkaz zkopírován do schránky! + Streamováno na Loudly + + Vybrat vše + Načítání všech seznamů skladeb… + Importovat vybrané položky + Něco se pokazilo. Jsme zmateni stejně jako vy. + No, to je trapné. Také netušíme, co se stalo. + Pokyny k získání požadovaných přihlašovacích údajů + 3. Ujistěte se, že jste klikli na „Přidat“ po zadání výše uvedené místní cesty. + 5. Přijměte podmínky služby a klikněte na „Uložit“. + Budete přesměrováni na novou stránku. Klikněte na Nastavení, poté zkopírujte ID klienta a Tajemství klienta a vložte je do textových polí níže v této aplikaci. + 2. Zadejte potřebné údaje a jako URI pro přesměrování použijte http://127.0.0.1:45454. + 4. Přepněte Web API. Zaškrtávací políčko by nyní mělo mít zatržítko. + Nyní klikněte na tlačítko Autorizovat a pokračovat níže a přihlaste se svým účtem, ze kterého chcete importovat, a Autorizujte a pokračujte, což vás přesměruje na stránku, která by měla začínat http://127.0.0.1:45454/?code=\n\nVložte celou tuto URL do textového pole níže, které říká Autorizační kód, a klikněte na tlačítko Ověřit. + 1. Navštivte Spotify pro vývojáře a klikněte na „Vytvořit aplikaci“. + Přihlásit se pomocí přihlašovacích údajů Spotify API + Autorizovat a pokračovat + Autorizační kód + Ověřit + Tajemství klienta + ID klienta + Probíhá import… + Nezavírejte aplikaci ani se nevracejte zpět. Tato operace neprobíhá na pozadí, takže počkejte, dokud nebude dokončena!\nNEPANIKAŘTE, POKUD TO VYPADÁ, ŽE SE TO ZASEKLO; někdy může načítání chvíli trvat. + Vyberte cíl pro „Oblíbené skladby“ + Kam by se měly importovat oblíbené skladby? + Nový seznam skladeb s názvem „Oblíbené skladby“ + Do výchozího seznamu „Oblíbené skladby“ + \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml deleted file mode 100644 index a2bb068..0000000 --- a/app/src/main/res/values-da/strings.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - AirNote - Noter - Ny note - Ingen oprettede noter. - Ingen noter fundet. - Oprettelsestidspunkt - Gittervisning - Kolonnevisning - Ord - Tegn - Navn - Beskrivelse - Vælg alle - Slet - Information - Søg - Indstillinger - Skærm - Systemtema - Mørkt tema - Dynamiske farver - Amoled-farver - Database - Sikkerhedskopi - Ikke understøttet endnu - Om - Version - Seneste udgivelse - Stjernemarkér projekt - Donér til mig :) - Gendan - Noter - Støtte - - Sprog - Markdown - Værktøjer - Historik - Widgets - Temaer • Størrelse • Skrifttype - Sky - Skrifttype • Sprog - NextCloud • Lokalt • Kryptering - Version • Udviklere • Licens - Widgets • Markdown - Historik • Gendan - Oprettet • Slet alt - Support • Billeder - Vis din kærlighed - Skrifttype - Radius - Vælg radius - Minimalistisk tilstand - Ekstreme Amoled-farver - Privatliv - Skærmbeskyttelse - Billedstøtte - Ustabil - Kryptovaluta - Tilpasser app-temaet baseret på systemindstillinger - Skift mellem mørke og lyse farveskemaer - Justerer farverne, så de matcher dit tapet - Brug sort farve til baggrunden - Ekstrem batterioptimering - Tilpas kanternes radius - Vælg visningsstil - Fjern de øverste knapper fra redigeringsskærmen - Gem alle noter i en fil - Gendan noter fra en sikkerhedskopi-fil - Aktivér markdown i noter - Skjul skærmen i seneste apps og deaktiver skærmbilleder - Byggetype - Kildekode - Liste over støtter - E-mail - Discord - Fejlrapport og mere - Fastgjort - Andre - Kopiér - Kryptér database - Kryptér databasen under sikkerhedskopiering - Indtast adgangskode - Adgangskoden er ugyldig, eller databasen er ikke krypteret - Ugyldigt input - Sortér stigende - Sortér faldende - Sortér noter efter deres oprettelsestidspunkt - Vælg note - Ikke-understøttet billedstørrelse (maks. 15mb) - Boks - Aktivér krypteret noteboks - fortsæt - Skift appens sprog - Systemsprog - Adfærd - Prioritér redigeringstilstand - Gå til redigeringstilstand på eksisterende noter - Ugyldig adgangskode - Donér - Oversættelse - Bidragydere - Sletter billeder fra app-lager, når de fjernes fra galleriet - Galleri-synkronisering - Vis kun titel - Vis kun titlen i forhåndsvisning - 1. Accept af vilkår - Ved at trykke på "Accepter" og bruge Easy Notes accepterer du disse servicevilkår. Hvis du ikke er enig, skal du ikke bruge applikationen. - 2. Licens - AirNote (Easy Notes) distribueres under **GNU General Public License (GPL) version 3**. Det betyder:\n\n- Du er fri til at bruge, ændre og dele applikationen.\n- Hvis du distribuerer applikationen eller eventuelle ændrede versioner, skal du også distribuere kildekoden og bevare GPL-licensen. - 3. Brug af applikationen - Du accepterer kun at bruge AirNote (Easy Notes) til lovlige formål og i overensstemmelse med disse vilkår. Du må **ikke**:\n\n- _Bruge appen til ulovlige formål_\n- _Krænke andres rettigheder_\n- _Forstyrre appens funktionalitet eller sikkerhed_ - 4. Intellektuel ejendom - Alle intellektuelle ejendomsrettigheder i AirNote (Easy Notes) ejes af os eller vores licensgivere. Din brug af appen giver dig ikke ejerskab til nogen intellektuelle ejendomsrettigheder. - 5. Ansvarsfraskrivelse for garantier - Easy Notes leveres "som den er", uden nogen garantier. Vi garanterer ikke, at appen vil være fejlfri, sikker eller uafbrudt. - 6. Ansvarsbegrænsning - Vi er ikke ansvarlige for nogen skader, der skyldes din brug af AirNote (Easy Notes), herunder indirekte, hændelige eller følgeskader. - 7. Ophør - Vi kan opsige eller suspendere din adgang til AirNote (Easy Notes), hvis du overtræder disse vilkår. Ved ophør ophører din ret til at bruge appen øjeblikkeligt. - 8. Ændringer af vilkår - Vi kan opdatere disse servicevilkår fra tid til anden. Din fortsatte brug af AirNote (Easy Notes) efter eventuelle ændringer udgør accept af de reviderede vilkår. - 9. Kontakt os - For spørgsmål om disse servicevilkår eller AirNote (Easy Notes), bedes du kontakte os på - 10. Privatlivspolitik - AirNote (Easy Notes) indsamler ingen personlige data. For mere information henvises til vores privatlivspolitik, der er tilgængelig på - Ikrafttrædelsesdato - Accepter - Servicevilkår - Kunne ikke importere - Import af nogle filer mislykkedes - Importeret med succes - Importér - Importér andre tekstfiler som noter - Brug monospace-skrifttype under redigering af note - Monospace-skrifttype - Er du sikker på, at du vil slette de valgte noter - Ja - Annuller - App-lås - Lås appen med en adgangskode - Opret adgangskode - Indtast adgangskode - Pin (6 tal) - Adgangskode - Fingeraftryk - Godkendelse påkrævet - er låst - UID - Spørg om fingeraftryk ved start - Spørg om adgangskode ved start - Mønster - Spørg om mønster ved start - Opret mønster - Indtast mønster - Lås øjeblikkeligt - Lås øjeblikkeligt, når appen genoptages - Deaktiver swipe - "Deaktiver swipe til forhåndsvisning i redigeringstilstand " - Skalér søgelinjens bredde - Få søgelinjens bredde til at matche noternes bredde - Skriftstørrelse - Skift størrelsen på teksten i noterne - Skriftstørrelse: %1$d - Forhåndsvisning - Sådan vil dine noter se ud med den valgte skriftstørrelse. - Grundlægger af Babel Software og Android-udvikler - Tilføj ny mappe - Mappenavn - Mappefarve - Tilføj - Flyt til mappe - Ingen mapper tilgængelige - Slet mappe - Er du sikker på, at du vil slette mappen ‘%1$s’? Noterne i den vil blive flyttet til sektionen ‘Alle noter’. - Rediger mappe - Gem - Mappemærke - Viser etiketten for den mappe, som noterne tilhører. - AirNote AI Indstillinger - Konfigurer AI · API-indstillinger - Brug AirNote API - Brug AI-funktioner med appens indbyggede API (Anbefales) - Din API-nøgle (BETA) - Tjek - Model der skal bruges - Modelvalg (BETA) - AI\'s forslag - Luk - Alle noter - Spørg, chat, find med AI... - Hvordan får du din egen API-nøgle? - Fra Google AI Studio kan du generere en personlig API-nøgle med en generøs gratis brugsgrænse. Denne nøgle tilhører kun dig, og du har fuld kontrol. - 1. Gå til Google AI Studio-webstedet ved at klikke på knappen nedenfor. - 2. Log ind med din Google-konto. - 3. På skærmen, der åbnes, skal du klikke på knappen \"Opret API-nøgle\". - 4. Du bliver muligvis bedt om at oprette et nyt projekt; følg trinene for at oprette det. - 5. Kopier den genererede lange tegnstreng. - 6. Indsæt den kopierede nøgle i tekstboksen nedenfor, og tryk på knappen \"Tjek\". - Gå til Google AI Studio - Indtast venligst en API-nøgle først. - API-nøgle gyldig og registreret med succes! ✅ - Ugyldig API-nøgle. Tjek venligst og prøv igen. - Stil et spørgsmål - Lav en note om et emne - Generér idéer - Giv mig 5 interessante idéer om et tilfældigt emne - Ny version - Opdater - Ny opdatering tilgængelig! - Opdater app - Ændringslog kunne ikke hentes. - Kunne ikke modtage ændringslog: %1$s - Søger efter opdateringer... - Opdatering tilgængelig - Ny version tilgængelig! - Version %1$s er klar til download. - Opdater nu - Download - Downloader... %1$d%% - Installer - Installer - Opdatering mislykkedes - Fejl - Prøv igen - Prøv igen - Din app er opdateret - Opdateret - Opdateringen kræver tilladelse til at installere fra ukendte kilder. - Downloadlink ikke fundet. - Nyskabelser - Gem note - Genopbygge - - Optimer skrivning - Opsummer - Gør den kortere - Gør den længere - Skift tone - - Formel - Balanceret - Venskabelig - Brugerens API-nøgle blev ikke fundet. Tjek venligst i indstillingerne. - API-anmodning mislykkedes: %1$s - Hej med dig! Hvordan kan jeg hjælpe dig? - FORSLAG - Oplist fordele og ulemper - Opret en to-do-liste - Fortæl det enklere - Anbefales til importerede filer - Foreslå titel - - Super, hvad skal vi lave et udkast om? Skriv venligst emnet. - - Ret grammatik- og udtryksfejl i nedenstående tekst for at gøre den mere flydende. Svar kun med den rettede tekst:\n\n---\n\n%1$s - Opsummer nedenstående tekst i en eller to sætninger, og bevar hovedideen. Svar kun med resuméteksten:\n\n---\n\n%1$s - Forkort følgende tekst betydeligt, mens dens betydning bevares. Svar kun med den forkortede tekst:\n\n---\n\n%1$s - Tilføj kreative detaljer til nedenstående tekst for at gøre den længere. Svar kun med den udvidede tekst:\n\n---\n\n%1$s - AI-servere er i øjeblikket optaget. Prøv venligst igen om et par minutter. - - %1$s\n\n---\n\n%2$s - Omskriv nedenstående tekst i et professionelt og formelt sprog. Bevar betydningen, skift kun tonen. Svar kun med den endelige tekst: - Omskriv nedenstående tekst i et afbalanceret sprog, hverken for formelt eller for oprigtigt. Bevar betydningen. Svar kun med konklusionen: - Omskriv nedenstående tekst på en mere oprigtig, varm, samtalevenlig og venlig måde. Bevar betydningen. Svar kun med konklusionen: - - En bruger af en note-app arbejder på en note med titlen \"%1$s\". Foreslå nogle kreative og inspirerende idéer eller udgangspunkter, der passer til denne titel. Hold dit svar kort og i listeformat. - Fortsæt nedenstående tekst, og bevar meningsintegriteten og forfatterens stil. Svar kun på den nye tekst, gentag ikke den eksisterende tekst:\n\n---\n\n\nTitel: %1$s\n\nIndhold:\n%2$s - Analyser nedenstående tekst og skriv et afsnit om, hvordan man kan gribe dette emne an fra et helt andet perspektiv. Opsummer ikke den eksisterende tekst, men tilbyd et alternativt perspektiv:\n\n---\n\n\nTitel: %1$s\n\nIndhold:\n%2$s - Analyser nedenstående tekst og oplist de positive og negative punkter under overskrifterne \'### Fordele\' og \'### Ulemper\'. Svar kun med disse to overskrifter og punkter:\n\n\n---\n\n\n\n%1$s - Analyser nedenstående tekst. Hvis teksten nævner opgaver, der skal udføres i løbet af dagen, skal du omdanne dem til en to-do-liste ved at sætte \'[] \' i begyndelsen af hver linje. Hvis teksten ikke nævner opgaver, skal du KUN svare \'NO_TASKS\' uden at skrive noget andet. Registrer sproget i teksten, og opret listen på det sprog:\n\n---\n\n%1$s - Oversæt den komplekse tekst nedenfor til et enkelt og flydende sprog, som alle kan forstå. Bevar hovedideen og vigtige detaljer i teksten. Svar kun med den forenklede tekst:\n\n---\n\n%1$s - Foreslå 5 kreative og iøjnefaldende titler, der bedst passer til indholdet af nedenstående tekst. Giv dit svar som en nummereret liste:\n\n---\n\n%1$s - - En bruger ønsker at lave et udkast til en note om emnet \"%1$s\". Du SKAL give dit svar i følgende format: TITEL: [indsæt titel her]\n\nINDHOLD: [indsæt noteindhold her] - \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml deleted file mode 100644 index 750c949..0000000 --- a/app/src/main/res/values-de/strings.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - AirNote - Notizen - Neue Notiz - Keine Notizen erstellt. - Keine Notizen gefunden. - Erstellungszeit - Gitteransicht - Spaltenansicht - Wörter - Zeichen - Name - Beschreibung - Alles auswählen - Löschen - Informationen - Suchen - Einstellungen - Anzeige - System-Theme - Dunkles Theme - Dynamische Farben - Amoled-Farben - Datenbank - Sicherung - Noch nicht unterstützt - Über - Version - Neueste Veröffentlichung - Projekt mit Stern markieren - Spenden Sie mir :) - Wiederherstellen - Notizen - Unterstützung - - Sprache - Markdown - Werkzeuge - Verlauf - Widgets - Themes • Größe • Schriftart - Cloud - Schriftart • Sprache - NextCloud • Lokal • Verschlüsselung - Version • Entwickler • Lizenz - Widgets • Markdown - Verlauf • Wiederherstellen - Erstellt • Alles löschen - Unterstützung • Bilder - Zeigen Sie Ihre Unterstützung - Schriftart - Radius - Radius auswählen - Minimalistischer Modus - Extreme Amoled-Farben - Datenschutz - Bildschirmschutz - Bildunterstützung - Instabil - Kryptowährung - Passt das App-Theme an die Systemeinstellungen an - Wechseln Sie zwischen dunklen und hellen Farbschemata - Passt die Farben an Ihr Hintergrundbild an - Verwenden Sie Schwarz als Hintergrundfarbe - Extreme Akkuoptimierung - Passen Sie den Randradius an - Wählen Sie den Ansichtsstil - Entfernen Sie die oberen Schaltflächen aus dem Bearbeitungsbildschirm - Speichern Sie alle Notizen in einer Datei - Stellen Sie Notizen aus einer Sicherungsdatei wieder her - Aktivieren Sie Markdown in Notizen - Bildschirm in den letzten Apps ausblenden und Screenshots deaktivieren - Build-Typ - Quellcode - Unterstützerliste - E-Mail - Discord - Fehlerbericht und mehr - Angeheftet - Andere - Kopieren - Datenbank verschlüsseln - Datenbank während der Sicherung verschlüsseln - Passwort eingeben - Das Passwort ist ungültig oder die Datenbank ist nicht verschlüsselt - Ungültige Eingabe - Aufsteigend sortieren - Absteigend sortieren - Notizen nach ihrer Erstellungszeit sortieren - Notiz auswählen - Nicht unterstützte Bildgröße (max. 15 MB) - Tresor - Verschlüsselten Notiztresor aktivieren - weiter - Ändern Sie die Sprache der App - Systemsprache - Verhalten - Bearbeitungsmodus priorisieren - Bearbeitungsmodus bei vorhandenen Notizen aufrufen - Ungültiges Passwort - Spenden - Übersetzung - Mitwirkende - Löscht Bilder aus dem App-Speicher, wenn sie aus der Galerie entfernt werden - Galerie-Synchronisierung - Nur Titel anzeigen - Nur den Titel in der Vorschau anzeigen - 1. Annahme der Bedingungen - Durch Drücken von "Zustimmen" und die Nutzung von Easy Notes akzeptieren Sie diese Nutzungsbedingungen. Wenn Sie nicht zustimmen, verwenden Sie die Anwendung nicht. - 2. Lizenz - AirNote (Easy Notes) wird unter der **GNU General Public License (GPL) Version 3** vertrieben. Das bedeutet:\n\n- Sie dürfen die Anwendung frei verwenden, ändern und weitergeben.\n- Wenn Sie die Anwendung oder geänderte Versionen weitergeben, müssen Sie auch den Quellcode weitergeben und die GPL-Lizenz beibehalten. - 3. Nutzung der Anwendung - Sie stimmen zu, AirNote (Easy Notes) nur für rechtmäßige Zwecke und in Übereinstimmung mit diesen Bedingungen zu verwenden. Sie dürfen **nicht**:\n\n- _Die App für rechtswidrige Zwecke verwenden_\n- _Die Rechte anderer verletzen_\n- _Die Funktionalität oder Sicherheit der App stören_ - 4. Geistiges Eigentum - Alle geistigen Eigentumsrechte an AirNote (Easy Notes) liegen bei uns oder unseren Lizenzgebern. Ihre Nutzung der App gewährt Ihnen kein Eigentum an geistigen Eigentumsrechten. - 5. Gewährleistungsausschluss - Easy Notes wird "wie besehen" zur Verfügung gestellt, ohne jegliche Gewährleistung. Wir garantieren nicht, dass die App fehlerfrei, sicher oder ununterbrochen ist. - 6. Haftungsbeschränkung - Wir haften nicht für Schäden, die aus Ihrer Nutzung von AirNote (Easy Notes) resultieren, einschließlich indirekter, zufälliger oder Folgeschäden. - 7. Kündigung - Wir können Ihren Zugang zu AirNote (Easy Notes) kündigen oder sperren, wenn Sie gegen diese Bedingungen verstoßen. Bei Kündigung endet Ihr Recht zur Nutzung der App sofort. - 8. Änderungen der Bedingungen - Wir können diese Nutzungsbedingungen von Zeit zu Zeit aktualisieren. Ihre fortgesetzte Nutzung von AirNote (Easy Notes) nach Änderungen gilt als Annahme der überarbeiteten Bedingungen. - 9. Kontaktieren Sie uns - Bei Fragen zu diesen Nutzungsbedingungen oder AirNote (Easy Notes) kontaktieren Sie uns bitte unter - 10. Datenschutzrichtlinie - AirNote (Easy Notes) sammelt keine persönlichen Daten. Weitere Informationen finden Sie in unserer Datenschutzrichtlinie unter - Gültigkeitsdatum - Zustimmen - Nutzungsbedingungen - Konnte nicht importiert werden - Import einiger Dateien fehlgeschlagen - Erfolgreich importiert - Importieren - Andere Textdateien als Notizen importieren - Verwenden Sie eine Monospace-Schriftart beim Bearbeiten von Notizen - Monospace-Schriftart - Sind Sie sicher, dass Sie die ausgewählten Notizen löschen möchten? - Ja - Abbrechen - App-Sperre - App mit einem Passwort sperren - Passcode einrichten - Passcode eingeben - Pin (6 Zahlen) - Passcode - Fingerabdruck - Authentifizierung erforderlich - ist gesperrt - UID - Beim Start nach Fingerabdruck fragen - Beim Start nach Passcode fragen - Muster - Beim Start nach Muster fragen - Muster einrichten - Muster eingeben - Sofort sperren - Sofort sperren, wenn die App fortgesetzt wird - Wischen deaktivieren - "Wischen zur Vorschau im Bearbeitungsmodus deaktivieren" - Breite der Suchleiste skalieren - Breite der Suchleiste an die Breite der Notizen anpassen - Schriftgröße - Ändern Sie die Größe des Textes in den Notizen - Schriftgröße: %1$d - Vorschau - So werden Ihre Notizen mit der ausgewählten Schriftgröße aussehen. - Gründer von Babel Software und Android-Entwickler - Neuen Ordner hinzufügen - Ordnername - Ordnerfarbe - Hinzufügen - In Ordner verschieben - Keine Ordner verfügbar - Ordner löschen - Sind Sie sicher, dass Sie den Ordner „%1$s“ löschen möchten? Die darin enthaltenen Notizen werden in den Bereich „Alle Notizen“ verschoben. - Ordner bearbeiten - Speichern - Ordner-Tag - Zeigt das Label des Ordners an, zu dem die Notizen gehören. - AirNote KI-Einstellungen - KI konfigurieren · API-Einstellungen - AirNote API verwenden - KI-Funktionen mit der integrierten API der App verwenden (Empfohlen) - Ihr API-Schlüssel (BETA) - Prüfen - Zu verwendendes Modell - Modellauswahl (BETA) - Vorschlag der KI - Schließen - Alle Notizen - Fragen, chatten, finden mit KI... - Wie erhalte ich meinen eigenen API-Schlüssel? - Im Google AI Studio können Sie einen persönlichen API-Schlüssel mit einem großzügigen kostenlosen Nutzungslimit generieren. Dieser Schlüssel gehört nur Ihnen und Sie haben die volle Kontrolle. - 1. Gehen Sie zur Google AI Studio-Website, indem Sie auf die Schaltfläche unten klicken. - 2. Melden Sie sich mit Ihrem Google-Konto an. - 3. Klicken Sie auf dem sich öffnenden Bildschirm auf die Schaltfläche „API-Schlüssel erstellen“. - 4. Möglicherweise werden Sie aufgefordert, ein neues Projekt zu erstellen. Befolgen Sie die Schritte, um es zu erstellen. - 5. Kopieren Sie die generierte lange Zeichenfolge. - 6. Fügen Sie den kopierten Schlüssel in das Textfeld unten ein und drücken Sie die Schaltfläche „Prüfen“. - Gehe zu Google AI Studio - Bitte geben Sie zuerst einen API-Schlüssel ein. - API-Schlüssel gültig und erfolgreich registriert! ✅ - Ungültiger API-Schlüssel. Bitte überprüfen und erneut versuchen. - Eine Frage stellen - Eine Notiz zu einem Thema machen - Ideen generieren - Gib mir 5 interessante Ideen zu einem zufälligen Thema - Neue Version - Aktualisieren - Neues Update verfügbar! - App aktualisieren - Änderungsprotokoll konnte nicht abgerufen werden. - Fehler beim Empfangen des Änderungsprotokolls: %1$s - Suche nach Updates... - Update verfügbar - Neue Version verfügbar! - Version %1$s ist zum Download verfügbar. - Jetzt aktualisieren - Herunterladen - Wird heruntergeladen... %1$d%% - Installieren - Installieren - Update fehlgeschlagen - Fehler - Erneut versuchen - Erneut versuchen - Ihre App ist auf dem neuesten Stand - Aktualisiert - Das Update erfordert die Berechtigung zur Installation aus unbekannten Quellen. - Download-Link nicht gefunden. - Neuerungen - Notiz speichern - Wiederherstellen - - Schreiben optimieren - Zusammenfassen - Kürzer machen - Länger machen - Ton ändern - - Offiziell - Ausgewogen - Freundlich - Benutzer-API-Schlüssel nicht gefunden. Bitte in den Einstellungen überprüfen. - API-Anfrage fehlgeschlagen: %1$s - Hallo! Wie kann ich Ihnen helfen? - VORSCHLÄGE - Vor- und Nachteile auflisten - To-Do-Liste erstellen - Einfacher erklären - Empfohlen für importierte Dateien - Titel vorschlagen - - Großartig, worüber sollen wir einen Entwurf erstellen? Bitte schreiben Sie das Thema. - - Korrigieren Sie die Grammatik- und Ausdrucksfehler im folgenden Text, um ihn flüssiger zu machen. Antworten Sie nur mit dem korrigierten Text:\n\n---\n\n%1$s - Fassen Sie den folgenden Text in ein oder zwei Sätzen zusammen und behalten Sie die Hauptidee bei. Antworten Sie nur mit dem zusammenfassenden Text:\n\n---\n\n%1$s - Kürzen Sie den folgenden Text erheblich, während seine Bedeutung erhalten bleibt. Antworten Sie nur mit dem gekürzten Text:\n\n---\n\n%1$s - Fügen Sie dem folgenden Text kreative Details hinzu, um ihn länger zu machen. Antworten Sie nur mit dem erweiterten Text:\n\n---\n\n%1$s - Die KI-Server sind derzeit ausgelastet. Bitte versuchen Sie es in ein paar Minuten erneut. - - %1$s\n\n---\n\n%2$s - Schreiben Sie den folgenden Text in einer professionellen und formellen Sprache neu. Behalten Sie die Bedeutung bei, ändern Sie nur den Ton. Antworten Sie nur mit dem endgültigen Text: - Schreiben Sie den folgenden Text in einer ausgewogenen Sprache neu, weder zu formell noch zu herzlich. Behalten Sie die Bedeutung bei. Antworten Sie nur mit der Schlussfolgerung: - Schreiben Sie den folgenden Text auf eine herzlichere, wärmere, gesprächigere und freundlichere Weise neu. Behalten Sie die Bedeutung bei. Antworten Sie nur mit der Schlussfolgerung: - - Ein Benutzer einer Notiz-App arbeitet an einer Notiz mit dem Titel „%1$s“. Schlagen Sie einige kreative und inspirierende Ideen oder Ansatzpunkte vor, die zu diesem Titel passen. Halten Sie Ihre Antwort kurz und in Listenform. - Setzen Sie den folgenden Text fort und bewahren Sie dabei die inhaltliche Integrität und den Stil des Autors. Antworten Sie nur auf den neuen Text, wiederholen Sie nicht den bestehenden Text:\n\n---\n\n\nTitel: %1$s\n\nInhalt:\n%2$s - Analysieren Sie den folgenden Text und schreiben Sie einen Absatz darüber, wie man dieses Thema aus einer völlig anderen Perspektive angehen kann. Fassen Sie den bestehenden Text nicht zusammen, sondern bieten Sie eine alternative Perspektive an:\n\n---\n\n\nTitel: %1$s\n\nInhalt:\n%2$s - Analysieren Sie den folgenden Text und listen Sie die positiven und negativen Punkte unter den Überschriften \'### Vorteile\' und \'### Nachteile\' auf. Antworten Sie nur mit diesen beiden Überschriften und Punkten:\n\n\n---\n\n\n\n%1$s - Analysieren Sie den folgenden Text. Wenn der Text Aufgaben erwähnt, die während des Tages erledigt werden müssen, wandeln Sie sie in eine To-Do-Liste um, indem Sie \'[] \' an den Anfang jeder Zeile setzen. Wenn der Text keine Aufgaben erwähnt, antworten Sie NUR mit \'NO_TASKS\', ohne etwas anderes einzugeben. Erkennen Sie die Sprache des Textes und erstellen Sie die Liste in dieser Sprache:\n\n---\n\n%1$s - Übersetzen Sie den komplexen Text unten in eine einfache und flüssige Sprache, die jeder verstehen kann. Behalten Sie die Hauptidee und wichtige Details des Textes bei. Antworten Sie nur mit dem vereinfachten Text:\n\n---\n\n%1$s - Schlagen Sie 5 kreative und aufmerksamkeitsstarke Titel vor, die am besten zum Inhalt des folgenden Textes passen. Geben Sie Ihre Antwort als nummerierte Liste:\n\n---\n\n%1$s - - Ein Benutzer möchte einen Notizentwurf zum Thema „%1$s“ erstellen. Sie MÜSSEN Ihre Antwort im folgenden Format geben: TITEL: [Titel hier einfügen]\n\nINHALT: [Notizinhalt hier einfügen] - \ No newline at end of file diff --git a/app/src/main/res/values-el-rGR/strings.xml b/app/src/main/res/values-el-rGR/strings.xml new file mode 100644 index 0000000..b629148 --- /dev/null +++ b/app/src/main/res/values-el-rGR/strings.xml @@ -0,0 +1,636 @@ + + + + Καλώς ήρθατε στο Loudly + Ας ξεκινήσουμε την εξατομίκευση της μουσικής σας εμπειρίας. + Το YouTube Music στα δάχτυλά σας + Συγχρονισμός λογαριασμού YT Music + Αναπαραγωγή χωρίς διαφημίσεις + Εμπειρία μουσικής εκτός σύνδεσης + Υποστήριξη Android Auto + Υποστήριξη κατάστασης Discord + Πλήρως ανοιχτού κώδικα + Επιλέξτε το περιβάλλον αναπαραγωγής + Επιλέξτε την προβολή του player εντός της εφαρμογής. Μπορείτε να αλλάξετε αυτήν τη ρύθμιση αργότερα από τις «Ρυθμίσεις > Εμφάνιση». + Η εγκατάσταση ολοκληρώθηκε! + Είστε έτοιμοι να εξερευνήσετε το Loudly. + Τέλος + Πίσω + Επόμενο + Προηγούμενο + + Αρχική + Εξερεύνηση + Τραγούδια + Καλλιτέχνες + Άλμπουμ + Λίστες αναπαραγωγής + + Αγαπημένα + Μου αρέσει + Μουσική εκτός σύνδεσης + Τα κορυφαία μου + Στην κρυφή μνήμη + Τοπική μουσική + + Φίλτρο περιεχομένου + + Όλων των εποχών + Τελευταίες 24 ώρες + Τελευταία εβδομάδα + Τελευταίος μήνας + Τελευταίο έτος + + + %d επιλεγμένο + %d επιλεγμένα + + + Καλημέρα + Καλό απόγευμα + Καλό βράδυ + Καληνύχτα + Πρόσφατη δραστηριότητα + Ιστορικό + Το ιστορικό σας θα εμφανιστεί εδώ + Τα στατιστικά σας θα εμφανιστούν εδώ + Στατιστικά + Εμφάνιση λιγότερων + Εμφάνιση περισσότερων + Διαθέσεις και είδη + Λογαριασμός + Γρήγορες επιλογές + Ακούστε τραγούδια για να δημιουργηθούν οι γρήγορες επιλογές σας + Ξεχασμένα αγαπημένα + Συνεχίστε να ακούτε + Οι λίστες αναπαραγωγής σας στο YouTube + Παρόμοια με + Νέες κυκλοφορίες άλμπουμ + Δεν υπάρχει σύνδεση στο διαδίκτυο + Φαίνεται ότι δεν έχετε σύνδεση στο διαδίκτυο. Παρακαλώ ελέγξτε το δίκτυό σας. + Προτεινόμενα για εσάς + Προτεινόμενα για εσάς σήμερα + + Σήμερα + Χθες + Αυτή την εβδομάδα + Την προηγούμενη εβδομάδα + Τοπικό + Απομακρυσμένο + Τοπικό ιστορικό + Διαδικτυακό ιστορικό + + Πιο πολυπαιγμένα τραγούδια + Πιο πολυπαιγμένοι καλλιτέχνες + Πιο πολυπαιγμένα άλμπουμ + + Αναζήτηση + Αναζήτηση στο Loudly + Αναζήτηση στη βιβλιοθήκη… + Ιστορικό αναζήτησης + Προτάσεις + Αποτελέσματα αναζήτησης + Βιβλιοθήκη + Αγαπημένα + Ληφθέντα + Όλα + Τραγούδια + Βίντεο + Άλμπουμ + Καλλιτέχνες + Λίστες αναπαραγωγής + Λίστες αναπαραγωγής κοινότητας + Προτεινόμενες λίστες αναπαραγωγής + Με σελιδοδείκτη + Δεν βρέθηκαν αποτελέσματα + + Η βιβλιοθήκη σας θα εμφανιστεί εδώ + Τα τραγούδια της βιβλιοθήκης σας θα εμφανιστούν εδώ + Οι καλλιτέχνες της βιβλιοθήκης σας θα εμφανιστούν εδώ + Τα άλμπουμ της βιβλιοθήκης σας θα εμφανιστούν εδώ + Οι λίστες αναπαραγωγής σας θα εμφανιστούν εδώ + + Από τη βιβλιοθήκη σας + + Άλλες εκδόσεις + Αγαπημένο + Κορυφαία τραγούδια + + Αγαπημένα τραγούδια + Ληφθέντα τραγούδια + Η λίστα αναπαραγωγής είναι κενή + Θέλετε πραγματικά να αφαιρέσετε όλα τα τραγούδια της λίστας «%s» από τον αποθηκευτικό χώρο των ληφθέντων τραγουδιών; + Θέλετε πραγματικά να διαγράψετε τη λίστα αναπαραγωγής «%s»; + + Απενεργοποίηση + + Επανάληψη + Ραδιόφωνο + Τυχαία αναπαραγωγή + Επαναφορά + + Λεπτομέρειες + Επεξεργασία + Έναρξη ραδιοφώνου + Αναπαραγωγή + Αναπαραγωγή επομένου + Προσθήκη στην ουρά + Προσθήκη στη βιβλιοθήκη + Προσθήκη όλων στη βιβλιοθήκη + Αφαίρεση από τη βιβλιοθήκη + Αφαίρεση όλων από τη βιβλιοθήκη + Λήψη + Λήψη σε εξέλιξη + Αφαίρεση λήψης + Εισαγωγή λίστας αναπαραγωγής + Προσθήκη σε λίστα αναπαραγωγής + Προβολή καλλιτέχνη + Προβολή άλμπουμ + Ανανέωση + Κοινοποίηση + Διαγραφή + Αφαίρεση από το ιστορικό + Αφαίρεση από τη λίστα αναπαραγωγής + Αφαίρεση από την ουρά + Αφαίρεση από την κρυφή μνήμη + Εξαγωγή ως αρχείο M3U + Αναζήτηση στο διαδίκτυο + Συγχρονισμός + Για προχωρημένους + Ρυθμός και τόνος + + Ημερομηνία προσθήκης + Ημερομηνία ενημέρωσης + Όνομα + Καλλιτέχνης + Έτος + Αριθμός τραγουδιών + Διάρκεια + Χρόνος αναπαραγωγής + Προσαρμοσμένη σειρά + + Αναγνωριστικό πολυμέσου + Τύπος MIME + Κωδικοποιητές + Ρυθμός bit + Ρυθμός δειγματοληψίας + Ένταση ήχου + Ένταση + Μέγεθος αρχείου + Άγνωστο + Αντιγράφηκε στο πρόχειρο + + Επεξεργασία στίχων + Αναζήτηση στίχων + Θέλετε πραγματικά να διαγράψετε τους αποθηκευμένους στίχους για το «%s»; + + Επεξεργασία τραγουδιού + Τίτλος τραγουδιού + Καλλιτέχνες τραγουδιού + Ο τίτλος του τραγουδιού δεν μπορεί να είναι κενός. + Ο καλλιτέχνης του τραγουδιού δεν μπορεί να είναι κενός. + Αποθήκευση + + Συγχρονισμός λίστας αναπαραγωγής + Επιλογή λίστας αναπαραγωγής + Επεξεργασία λίστας αναπαραγωγής + Δημιουργία λίστας αναπαραγωγής + Όνομα λίστας αναπαραγωγής + Το όνομα της λίστας αναπαραγωγής δεν μπορεί να είναι κενό. + + Επεξεργασία καλλιτέχνη + Όνομα καλλιτέχνη + Το όνομα του καλλιτέχνη δεν μπορεί να είναι κενό. + + Διπλότυπα + Παράλειψη διπλότυπων + Προσθήκη ούτως ή άλλως + Το τραγούδι βρίσκεται ήδη στη λίστα αναπαραγωγής σας + %d τραγούδια βρίσκονται ήδη στη λίστα αναπαραγωγής σας + Σημείωση: Αυτό επιτρέπει το συγχρονισμό με το YouTube Music + + + %d τραγούδι + %d τραγούδια + + + %d καλλιτέχνης + %d καλλιτέχνες + + + %d άλμπουμ + %d άλμπουμ + + + %d λίστα αναπαραγωγής + %d λίστες αναπαραγωγής + + + %d εβδομάδα + %d εβδομάδες + + + %d μήνας + %d μήνες + + + %d έτος + %d έτη + + + Η λίστα αναπαραγωγής εισήχθη + Το «%s» αφαιρέθηκε από τη λίστα αναπαραγωγής + Η λίστα αναπαραγωγής συγχρονίστηκε + Αναίρεση + + Τώρα παίζει + Δεν βρέθηκαν στίχοι + Χρονοδιακόπτης ύπνου + Στο τέλος του τραγουδιού + + 1 λεπτό + %d λεπτά + + Δεν υπάρχει διαθέσιμη ροή + Δεν υπάρχει σύνδεση δικτύου + Χρονικό όριο έληξε + Άγνωστο σφάλμα + Μενού + Κοινοποίηση λίστας + + 5 δευτερόλεπτα πίσω + 5 δευτερόλεπτα μπροστά + + Μου αρέσει + Μου αρέσουν όλα + Αναίρεση "Μου αρέσει" + Αναίρεση όλων των "Μου αρέσει" + Τυχαία αναπαραγωγή ενεργή + Τυχαία αναπαραγωγή ανενεργή + Λειτουργία επανάληψης ανενεργή + Επανάληψη τρέχοντος τραγουδιού + Επανάληψη ουράς + + Όλα τα τραγούδια + Τραγούδια από αναζήτηση + + Αναπαραγωγή μουσικής + + Ειδοποιήσεις + Αποτροπή τερματισμού της εφαρμογής από το σύστημα + Χρησιμοποιήστε μια ειδοποίηση στο προσκήνιο για να αποτρέψετε το τυχαίο κλείσιμο του Loudly από το σύστημα. + Απαιτείται άδεια ειδοποιήσεων + + Ρυθμίσεις + Εμφάνιση + Θέμα + Διακομιστής μεσολάβησης (Proxy) + Ενεργοποίηση δυναμικού θέματος + Σκοτεινό θέμα + Ενεργό + Ανενεργό + Ακολούθηση συστήματος + Απόλυτο μαύρο + Σχεδιασμός εφαρμογής + Περιβάλλον εφαρμογής 1.0 + Περιβάλλον εφαρμογής 1.5 + Ακούστε στο YouTube Music + Επισκεφθείτε το Loudly + Καλλιτέχνης + Ακούστε το Loudly + Ενεργοποίηση σάρωσης στη μικρογραφία για αλλαγή τραγουδιού + Προσαρμογή καρτελών πλοήγησης + Player + Στοίχιση κειμένου στο player + Θέση κειμένου στίχων + Πλαϊνά + Αριστερά + Κέντρο + Στυλ player + Παλιό περιβάλλον player + Περιβάλλον player 1.0 + Περιβάλλον player 2.0 + Ακολούθηση θέματος + Διαβάθμιση + Θόλωση + Θόλωση (Κίνηση) + Στυλ φόντου στο player + Δεξιά + Στυλ ρυθμιστικού + Προεπιλογή + Κυματιστό + Compose + Διαμόρφωση ακτίνας γωνίας μικρογραφίας + Εμφάνιση φίλτρου περιεχομένου + Διάφορα + Κύρια + Προσαρμογή αυτόματων λιστών + Εμφάνιση αυτόματης λίστας αγαπημένων + Εμφάνιση αυτόματης λίστας ληφθέντων + Εμφάνιση αυτόματης λίστας κορυφαίων + Εμφάνιση αυτόματης λίστας προσωρινά αποθηκευμένων + Εμφάνιση αυτόματης λίστας τοπικών + Σύρετε δεξιά για επόμενο τραγούδι ή αριστερά για προσθήκη στην ουρά + Προεπιλεγμένη καρτέλα ανοίγματος + Προεπιλεγμένα φίλτρα βιβλιοθήκης + Μέγεθος κελιού πλέγματος + Εμφάνιση ετικετών κάτω γραμμής πλοήγησης + Μικρό + Μεγάλο + Αρχείο αλλαγών + Δεν βρέθηκαν πρόσφατες αλλαγές + Πρόσφατες αλλαγές: + Κλείσιμο + Νέα έκδοση διαθέσιμη + Γεια σας ξανά + Δωρεά + Επίσημο κανάλι + Ιστοσελίδα + Επίσημη ιστοσελίδα ❯ + Πηγαίος κώδικας + Πρόσβαση στον πηγαίο κώδικα της εφαρμογής + Ζητήματα + Αναφορές σφαλμάτων και άλλα + Βοήθεια + Αν θέλετε να συνεισφέρετε, κάντε κλικ εδώ ❯ + Προγραμματιστής | @RRechz + + Περιεχόμενο + Σύνδεση + Συνδεθείτε στις λίστες αναπαραγωγής του YT Music και σε άλλα + Πατήστε για εμφάνιση του token + Πατήστε ξανά για αντιγραφή ή επεξεργασία + Αυτή είναι μια μέθοδος σύνδεσης ΓΙΑ ΠΡΟΧΩΡΗΜΕΝΟΥΣ. Ως εναλλακτική λύση στην πύλη ιστού, μπορείτε να εισαγάγετε ή να ενημερώσετε απευθείας το token σύνδεσής σας εδώ. Για παράδειγμα, αυτό μπορεί να επιταχύνει τη σύνδεση σε πολλές συσκευές. Παρακαλούμε σημειώστε ότι οποιεσδήποτε μη έγκυρες μορφές token που η εφαρμογή δεν μπορεί να αναλύσει δεν θα γίνονται αποδεκτές. + Εισαγωγή από το Spotify + Spotify + Discord + Αυτόματη λήψη των αγαπημένων σας τραγουδιών + Αυτόματος συγχρονισμός με τον λογαριασμό σας στο YouTube Music + Συγχρονίστε τις λίστες αναπαραγωγής σας και άλλα με τον λογαριασμό σας + Μόνο Wi-Fi + Δεν έχετε συνδεθεί + Προεπιλεγμένη γλώσσα περιεχομένου + Προεπιλεγμένη χώρα περιεχομένου + Προεπιλογή συστήματος + Ενεργοποίηση proxy + Τύπος proxy + URL του proxy + Επανεκκινήστε για να εφαρμοστούν οι αλλαγές + Γλώσσα εφαρμογής + Διαμόρφωση γλωσσών εφαρμογής + Δεν ήταν δυνατή η εύρεση των ρυθμίσεων γλώσσας της εφαρμογής, παρακαλούμε διαμορφώστε τις χειροκίνητα + Άνοιγμα υποστηριζόμενων συνδέσμων από προεπιλογή + Διαμόρφωση υποστηριζόμενων συνδέσμων + Δεν ήταν δυνατή η εύρεση των ρυθμίσεων υποστηριζόμενων συνδέσμων, παρακαλούμε διαμορφώστε τις χειροκίνητα + + Player και ήχος + Ποιότητα ήχου + Μέγιστη + Αυτόματη + Υψηλή + Χαμηλή + Ελάχιστη διάρκεια αναπαραγωγής + Η ελάχιστη διάρκεια που πρέπει να παίξει ένα τραγούδι για να θεωρηθεί «αναπαραγμένο» + Ουρά + Μόνιμη ουρά + Επαναφέρετε την τελευταία σας ουρά κατά την έναρξη της εφαρμογής + Αυτόματη φόρτωση περισσότερων τραγουδιών + Αυτόματη προσθήκη περισσότερων τραγουδιών όταν φτάσετε στο τέλος της ουράς, εάν είναι δυνατόν + Παράλειψη σιγής + Εξομάλυνση ήχου + Συνέχιση αναπαραγωγής όταν συνδέεται συσκευή Bluetooth + Διακοπή αναπαραγωγής όταν η ένταση είναι στο ελάχιστο + Προσθήκη αναπαραγμένων τραγουδιών στο ιστορικό του YouTube Music + Αυτόματη μετάβαση στο επόμενο τραγούδι όταν παρουσιαστεί σφάλμα + Εξασφαλίστε τη συνεχή εμπειρία αναπαραγωγής σας + Διακοπή μουσικής κατά την εκκαθάριση εργασιών + Ισοσταθμιστής + Στίχοι + Στίχοι + Ρυθμίσεις τοπικού player + Αποθηκευτικός χώρος + Κρυφή μνήμη + Κρυφή μνήμη εικόνων + Κρυφή μνήμη τραγουδιών + Μέγιστο μέγεθος κρυφής μνήμης + Απεριόριστο + Ενεργοποίηση offload + Χρησιμοποιήστε τη διαδρομή ήχου offload για την αναπαραγωγή. Η απενεργοποίησή της μπορεί να αυξήσει την κατανάλωση ενέργειας, αλλά μπορεί να είναι χρήσιμη εάν αντιμετωπίζετε προβλήματα με την αναπαραγωγή ή την επεξεργασία του ήχου. + Εκκαθάριση όλων των λήψεων + Μέγιστο μέγεθος κρυφής μνήμης εικόνων + Εκκαθάριση κρυφής μνήμης εικόνων + Μέγιστο μέγεθος κρυφής μνήμης τραγουδιών + Εκκαθάριση κρυφής μνήμης τραγουδιών + σε χρήση + Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλη την κρυφή μνήμη; + Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλες τις λήψεις; + Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλα τα αποθηκευμένα τραγούδια; + Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλες τις αποθηκευμένες εικόνες; + Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλα τα αποθηκευμένα μοντέλα μετάφρασης; + + Απόρρητο + Ιστορικό ακρόασης + Παύση ιστορικού ακρόασης + Εκκαθάριση ιστορικού ακρόασης + Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλο το ιστορικό ακρόασης; + Παύση ιστορικού αναζήτησης + Εκκαθάριση ιστορικού αναζήτησης + Είστε βέβαιοι ότι θέλετε να καθαρίσετε όλο το ιστορικό αναζήτησης; + Ιστορικό αναζήτησης + Χρήση σύνδεσης για περιήγηση περιεχομένου + Αυτό μπορεί να επηρεάσει το περιεχόμενο που βλέπετε και για παράδειγμα να εμφανίζει άλμπουμ μόνο για premium χρήστες εάν είστε συνδεδεμένοι με λογαριασμό Premium. + Απενεργοποίηση στιγμιότυπου οθόνης + Όταν αυτή η επιλογή είναι ενεργοποιημένη, τα στιγμιότυπα οθόνης και η προβολή της εφαρμογής στις Πρόσφατες εφαρμογές είναι απενεργοποιημένα. + Ενεργοποίηση παρόχου στίχων LrcLib + Ενεργοποίηση παρόχου στίχων KuGou + Προεπιλεγμένος πάροχος στίχων + Ανάγνωση στίχων πολλαπλών γραμμών + Αντιμετωπίστε όλες τις γραμμές μεταξύ των σημείων συγχρονισμού ως ένα κείμενο στίχων + Αφαίρεση κενών γύρω από τους στίχους + Μέγεθος γραμματοσειράς + Απόκρυψη ακατάλληλου περιεχομένου + Ενεργοποίηση ειδοποιήσεων + + Δημιουργία αντιγράφων ασφαλείας και επαναφορά + Αντίγραφο ασφαλείας + Επαναφορά + Εισαγωγή λίστας αναπαραγωγής (m3u) + Η εισαγωγή της λίστας αναπαραγωγής απέτυχε + Εισηγμένη λίστα αναπαραγωγής + Το αντίγραφο ασφαλείας δημιουργήθηκε με επιτυχία + Δεν ήταν δυνατή η δημιουργία αντιγράφου ασφαλείας + Η επαναφορά του αντιγράφου ασφαλείας απέτυχε + + Ενσωμάτωση Discord + Το Loudly χρησιμοποιεί τη βιβλιοθήκη KizzyRPC για να ορίσει την κατάσταση του λογαριασμού σας στο Discord. Αυτό περιλαμβάνει τη χρήση της σύνδεσης Discord Gateway, η οποία μπορεί να θεωρηθεί παραβίαση των Όρων Παροχής Υπηρεσιών του Discord. Ωστόσο, δεν υπάρχουν γνωστές περιπτώσεις αναστολής λογαριασμών χρηστών για αυτόν τον λόγο. Χρησιμοποιήστε με δική σας ευθύνη.\n\nΤο Loudly θα εξαγάγει μόνο το token σας, και όλα τα άλλα αποθηκεύονται τοπικά. + Παράβλεψη + Επιλογές + Προεπισκόπηση + Η σύνδεση απέτυχε + Αποσύνδεση + Ενεργοποίηση Rich Presence + + Σχετικά + Έκδοση εφαρμογής + Έλεγχος για ενημερώσεις... + η έκδοση %1$s είναι διαθέσιμη για λήψη. + Λήψη σε εξέλιξη... + Αναβάθμιση + Προσπαθήστε ξανά + Λήψη τώρα + Τύπος εφαρμογής + + Νέα έκδοση διαθέσιμη + Μοντέλα μετάφρασης + Εκκαθάριση μοντέλων μετάφρασης + Διάρκεια ιστορικού + + 1 δευτερόλεπτο + %d δευτερόλεπτα + + Μουσική 3x2 + Μουσική 2x1 + Αναπαραγωγή/Παύση + Έκδοση · Προγραμματιστές · Άδεια + Περιβάλλον + Περιεχόμενο & Λογαριασμός + Player + Σύστημα + Βιβλιοθήκη + Ενσωματώσεις + Ενσωμάτωση συστήματος + Συνολικός χρησιμοποιημένος χώρος + Τρέχουσα χρήση + Εκκαθάριση κρυφής μνήμης + Αντίγραφο ασφαλείας · Επαναφορά · Εισαγωγή + Ιστορικό · Αναζήτηση · Στιγμιότυπο οθόνης + Λήψεις · Κρυφή μνήμη · Εικόνες + Ήχος · Ουρά · Στίχοι + Γλώσσα · Τοποθεσία · Proxy + Σύνδεση · Spotify · Discord + Θέμα · Ρυθμιστικό · Player + + Εικόνα κάρτας προφίλ + Προσαρμόστε την εικόνα στην κάρτα προφίλ + Επιλογή εικόνας προφίλ + Έτοιμες εικόνες + Επιλογή από τη συλλογή + Επεξεργασία φωτογραφίας + Άκυρο + + Αποστολή στον προγραμματιστή + Κλείσιμο + Εμφάνιση λεπτομερειών σφάλματος + Η λήψη απέτυχε + Εμφάνιση σφάλματος + Χειροκίνητη ενημέρωση + Ελέγξτε τη σελίδα ρυθμίσεων για λεπτομέρειες + Το λογισμικό είναι ενημερωμένο + + Περιβάλλον εφαρμογής + Επιλέξτε ένα από 3 διαφορετικά σχέδια + Επιλογή έκδοσης περιβάλλοντος + Παλιά έκδοση σχεδιασμού της εφαρμογής. (Εφαρμογή, Player, MiniPlayer) + Συμπαγής σχεδιασμός που δημιουργήθηκε με μια πιο σύγχρονη προσέγγιση. (Εφαρμογή, Player, MiniPlayer) + Ο πιο δημοφιλής σχεδιασμός. (Εφαρμογή, Player, MiniPlayer) + Δημιουργήστε το δικό σας σχέδιο περιβάλλοντος εφαρμογής. (Εφαρμογή, Player, MiniPlayer) + Προσαρμογή περιβάλλοντος + + Άλλες ρυθμίσεις + Κάντε κλικ για διάφορες άλλες ρυθμίσεις + + Προσαρμόζει αυτόματα την ποιότητα μεταξύ 45 και 512 Kbps + Προσφέρει την υψηλότερη ποιότητα έως 512 Kbps + Ορίζει την ποιότητα για μέσες ταχύτητες διαδικτύου μεταξύ 128 και 256 Kbps. + Περιορίζει την ποιότητα μεταξύ 45 και 52 Kbps για εξοικονόμηση δεδομένων (Εξοικονόμηση δεδομένων) + + Δεν ήταν δυνατή η εισαγωγή + + Στοίχιση κειμένου + Ορίστε τη θέση του κειμένου στην κάρτα + Ένταση διαβάθμισης + Στρογγυλοποίηση γωνιών + Εικόνα προφίλ + Κάρτα προφίλ + Αφαίρεση εικόνας προφίλ + Εμφάνιση κειμένων + Εμφάνιση του κειμένου χαιρετισμού και ονόματος χρήστη στην κάρτα προφίλ + Πατήστε για αλλαγή + + Μετάφραση (BETA) + Αντιγραφή + + Σφάλμα δικτύου + Ελέγξτε τη σύνδεσή σας στο διαδίκτυο και προσπαθήστε ξανά. + Μη υποστηριζόμενο περιεχόμενο + Αυτή η μορφή τραγουδιού δεν υποστηρίζεται στη συσκευή σας. + Σφάλμα ζωντανής μετάδοσης + Υπάρχει πρόβλημα με τη ζωντανή ροή. Παρακαλώ επανεκκινήστε. + Η αναπαραγωγή απέτυχε + Παρουσιάστηκε ένα άγνωστο σφάλμα. Παρακαλώ προσπαθήστε ξανά. + Προσπαθούμε ξανά… + Η χρήση VPN μπορεί να επηρεάσει την ποιότητα της σύνδεσής σας. Εάν αντιμετωπίζετε προβλήματα δικτύου, αποσυνδέστε τη σύνδεση VPN ή χρησιμοποιήστε έναν proxy. Μπορείτε να το διαμορφώσετε στις «Ρυθμίσεις >> Περιεχόμενο >> Proxy». + + Τα τοπικά τραγούδια συγχρονίστηκαν με επιτυχία + Τοπικός σαρωτής + + Αυτόματη σάρωση + Αυτόματη σάρωση για τραγούδια κατά το άνοιγμα της εφαρμογής + + Πρόσθετες ρυθμίσεις σαρωτή + Αυστηρά ονόματα αρχείων + Όταν είναι ενεργοποιημένο, οι επεκτάσεις ΔΕΝ θα αγνοούνται. Π.χ. το «Song.ogg» θα είναι διαφορετικό τραγούδι από το «Song.flac» + Διαμόρφωση ευαισθησίας σαρωτή + Αντιστοίχιση τίτλου + Αντιστοίχιση τίτλου και καλλιτεχνών + Αντιστοίχιση τίτλου, καλλιτεχνών, άλμπουμ + + Φάκελοι + Επιπέδωση υποφακέλων + Απενεργοποιήστε για να διατηρήσετε τη δομή των αρχείων όπως είναι στο δίσκο + + Συμβουλές & Δυνατότητες + Συμβουλές · Δυνατότητες + Δημιουργήστε την ιστορία σας + Μοιραστείτε το τραγούδι που ακούτε στα μέσα κοινωνικής δικτύωσης με ένα κομψό σχέδιο που δημιουργήθηκε με τα κυρίαρχα χρώματα του εξωφύλλου του άλμπουμ. + Προσαρμόσιμες ρυθμίσεις + Κάντε την εφαρμογή δική σας αντικαθιστώντας το φόντο της κάρτας τίτλου στην οθόνη ρυθμίσεων με μια εικόνα από τη συλλογή σας. + Νέα λίστα τραγουδιών + Δείτε την τρέχουσα λίστα αναπαραγωγής σας και αλλάξτε εύκολα μεταξύ των τραγουδιών σύροντας προς τα πάνω από την οθόνη του player (νέος σχεδιασμός αποκλειστικά για το Player UI 2.0) + Ανασχεδιασμένες ρυθμίσεις + Βρείτε εύκολα αυτό που ψάχνετε με έναν νέο σχεδιασμό ρυθμίσεων βασισμένο σε κάρτες που είναι πιο σύγχρονος και βολικός. + Κλασικό widget μουσικής + Γρήγορη πρόσβαση σε όλα τα στοιχεία ελέγχου και τις πληροφορίες τραγουδιών από την αρχική σας οθόνη. + Μινιμαλιστικό widget μουσικής + Widget μουσικής «Quik» εμπνευσμένο από το σχέδιο του κουμπιού Γρήγορων Ρυθμίσεων του Android 16 + Κοινοποίηση σε story + Αντιγραφή συνδέσμου τραγουδιού + Ο σύνδεσμος αντιγράφηκε στο πρόχειρο! + Αναπαράγεται στο Loudly + + Επιλογή όλων + Λήψη όλων των λιστών αναπαραγωγής… + Εισαγωγή επιλεγμένων στοιχείων + Κάτι πήγε στραβά. Είμαστε το ίδιο μπερδεμένοι με εσάς. + Λοιπόν, αυτό είναι ντροπιαστικό. Ούτε εμείς έχουμε ιδέα τι συνέβη. + Οδηγίες για την απόκτηση των απαιτούμενων διαπιστευτηρίων + 3. Βεβαιωθείτε ότι έχετε κάνει κλικ στο «Προσθήκη» αφού εισαγάγετε την παραπάνω τοπική διαδρομή. + 5. Αποδεχτείτε τους όρους παροχής υπηρεσιών και κάντε κλικ στο «Αποθήκευση». + Θα μεταφερθείτε σε μια νέα σελίδα. Κάντε κλικ στις Ρυθμίσεις, στη συνέχεια αντιγράψτε το Client ID και το Client Secret, και επικολλήστε τα στα παρακάτω πεδία κειμένου σε αυτήν την εφαρμογή. + 2. Εισαγάγετε τις απαραίτητες λεπτομέρειες και χρησιμοποιήστε το http://127.0.0.1:45454 ως Redirect URIs. + 4. Ενεργοποιήστε το Web API. Το πλαίσιο ελέγχου θα πρέπει τώρα να έχει ένα σημάδι επιλογής. + Τώρα, κάντε κλικ στο παρακάτω κουμπί Εξουσιοδότηση και Συνέχεια και συνδεθείτε με τον λογαριασμό από τον οποίο θέλετε να εισαγάγετε, και Εξουσιοδοτήστε και Συνεχίστε, το οποίο θα σας ανακατευθύνει σε έναν ιστότοπο που θα πρέπει να ξεκινά με http://127.0.0.1:45454/?code=\n\nΕπικολλήστε ολόκληρη αυτή τη διεύθυνση URL στο παρακάτω πεδίο κειμένου που λέει Κωδικός εξουσιοδότησης και κάντε κλικ στο κουμπί Έλεγχος ταυτότητας. + 1. Επισκεφθείτε τον πίνακα ελέγχου του Spotify για προγραμματιστές και κάντε κλικ στο «Δημιουργία εφαρμογής». + Σύνδεση με διαπιστευτήρια Spotify API + Εξουσιοδότηση και Συνέχεια + Κωδικός εξουσιοδότησης + Έλεγχος ταυτότητας + Client secret + Client ID + Η εισαγωγή βρίσκεται σε εξέλιξη… + Μην κλείσετε την εφαρμογή και μην πάτε πίσω. Αυτή η λειτουργία δεν εκτελείται στο παρασκήνιο, οπότε μείνετε εδώ μέχρι να ολοκληρωθεί!\nΜΗΝ ΠΑΝΙΚΟΒΛΗΘΕΙΤΕ ΑΝ ΦΑΙΝΕΤΑΙ ΚΟΛΛΗΜΕΝΟ. μερικές φορές η ανάκτηση μπορεί να διαρκέσει λίγο. + Επιλέξτε προορισμό για τα «Αγαπημένα τραγούδια» + Πού πρέπει να εισαχθούν τα αγαπημένα τραγούδια; + Μια νέα λίστα αναπαραγωγής με το όνομα «Αγαπημένα τραγούδια» + Στα προεπιλεγμένα «Αγαπημένα τραγούδια» + \ No newline at end of file diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml deleted file mode 100644 index 36923f5..0000000 --- a/app/src/main/res/values-eo/strings.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - AirNote - Notoj - Nova Noto - Neniuj notoj kreitaj. - Neniuj notoj trovitaj. - Kreita Tempo - Krada vido - Kolumna vido - Vortoj - Signoj - Nomo - Priskribo - Elekti ĉion - Forigi - Informoj - Serĉi - Agordoj - Vidigo - Sistema Etoso - Malhela Etoso - Dinamikaj Koloroj - Amoled-Koloroj - Datumbazo - Sekurkopio - Ankoraŭ ne subtenata - Pri - Versio - Plej nova eldono - Stele marku Projekton - Donacu al mi :) - Restarigi - Notoj - Subteno - - Lingvo - Markdaŭno - Iloj - Historio - Fenestraĵoj - Etosoj • Grandeco • Tiparo - Nubo - Tiparo • Lingvo - NextCloud • Loka • Ĉifrado - Versio • Programistoj • Permesilo - Fenestraĵoj • Markdaŭno - Historio • Malfari - Kreita • Forigi ĉion - Subteno • Bildoj - Montru vian amon - Tiparo - Radiuso - Elektu Radiuson - Minimumisma Reĝimo - Ekstremaj Amoled-Koloroj - Privateco - Ekrana Protekto - Bildsubteno - Nestabila - Kriptovaluto - Adaptas programan etoson laŭ sistemaj agordoj - Baskulu inter malhelaj kaj helaj kolorskemoj - Ĝustigas kolorojn por kongrui kun via tapeto - Uzu nigran koloron por fono - Ekstrema bateria optimumigo - Agordu randan radiuson - Elektu vidan stilon - Forigu suprajn butonojn de redakta ekrano - Konservu ĉiujn notojn en dosiero - Restarigu notojn el sekurkopia dosiero - Ebligu markdaŭnon en notoj - Kaŝu ekranon en lastatempaj programoj kaj malŝaltu ekrankopiojn - Konstrua Tipo - Fontkodo - Subtenantoj-Listo - Retpoŝto - Diskordo - Cimo-Raporto kaj Pli - Alpinglita - Aliaj - Kopii - Ĉifri Datumbazon - Ĉifri datumbazon dum sekurkopio - Enigu pasvorton - Pasvorto estas nevalida aŭ la datumbazo ne estas ĉifrita - Nevalida enigo - Ordigi kreske - Ordigi malkreske - Ordigi notojn laŭ ilia krea tempo - Elektu Noton - Ne subtenata bildgrandeco (15mb maksimume) - Trezorejo - Ebligu ĉifritan notan trezorejon - daŭrigi - Ŝanĝu la lingvon de la programo - Sistema Lingvo - Konduto - Prioritatu Redaktan Reĝimon - Eniru redaktan reĝimon sur ekzistantaj notoj - Nevalida Pasvorto - Donaci - Tradukado - Kontribuantoj - Forigas bildojn el la programa stokado kiam ili estas forigitaj el la galerio - Galeria Sinkronigo - Montru nur titolon - Montru nur la titolon en antaŭvido - 1. Akcepto de Kondiĉoj - Premante "Konsenti" kaj uzante Easy Notes, vi akceptas ĉi tiujn Servokondiĉojn. Se vi ne konsentas, ne uzu la aplikaĵon. - 2. Permesilo - AirNote (Easy Notes) estas distribuita sub la **GNU Ĝenerala Publika Permesilo (GPL) versio 3**. Tio signifas:\n\n- Vi rajtas uzi, modifi kaj kunhavigi la aplikaĵon.\n- Se vi distribuas la aplikaĵon aŭ iujn ajn modifitajn versiojn, vi devas ankaŭ distribui la fontkodon kaj konservi la GPL-permesilon. - 3. Uzo de la Aplikaĵo - Vi konsentas uzi AirNote (Easy Notes) nur por laŭleĝaj celoj kaj konforme al ĉi tiuj Kondiĉoj. Vi **ne** rajtas:\n\n- _Uzi la apon por iu ajn kontraŭleĝa celo_\n- _Malobservi la rajtojn de aliaj_\n- _Perturbi la funkciadon aŭ sekurecon de la apo_ - 4. Intelekta Propraĵo - Ĉiuj intelektaj propraĵaj rajtoj en AirNote (Easy Notes) apartenas al ni aŭ niaj permesil-donantoj. Via uzo de la apo ne donas al vi posedon de iuj ajn intelektaj propraĵaj rajtoj. - 5. Senrespondecigo pri Garantioj - Easy Notes estas provizita "kiel ĝi estas", sen iuj ajn garantioj. Ni ne garantias, ke la apo estos sen eraroj, sekura aŭ seninterrompa. - 6. Limigo de Respondeco - Ni ne respondecas pri iuj ajn damaĝoj rezultantaj el via uzo de AirNote (Easy Notes), inkluzive de nerektaj, incidentaj aŭ konsekvencaj damaĝoj. - 7. Fino - Ni povas fini aŭ suspendi vian aliron al AirNote (Easy Notes) se vi malobeas ĉi tiujn Kondiĉojn. Post fino, via rajto uzi la apon tuj ĉesos. - 8. Ŝanĝoj al Kondiĉoj - Ni povas ĝisdatigi ĉi tiujn Servokondiĉojn de tempo al tempo. Via daŭra uzo de AirNote (Easy Notes) post iuj ajn ŝanĝoj konsistigas akcepton de la reviziitaj kondiĉoj. - 9. Kontaktu Nin - Por iuj ajn demandoj pri ĉi tiuj Servokondiĉoj aŭ AirNote (Easy Notes), bonvolu kontakti nin ĉe - 10. Privateca Politiko - AirNote (Easy Notes) ne kolektas iujn ajn personajn datumojn. Por pli da informoj, bonvolu konsulti nian Privatecan Politikon haveblan ĉe - Efektiva Dato - Konsenti - Servokondiĉoj - Ne eblis importi - Importado de iuj dosieroj malsukcesis - Sukcese importita - Importi - Importu aliajn tekstajn dosierojn kiel notojn - Uzu monotipan tiparon dum redaktado de noto - Monotipa tiparo - Ĉu vi certas, ke vi volas forigi la elektitajn notojn - Jes - Nuligi - Aplikaĵa seruro - Ŝlosu apon per pasvorto - Agordu paskodon - Enigu Paskodon - PIN (6 nombroj) - Paskodo - Fingrospuro - Aŭtentikigo bezonata - estas ŝlosita - UID - Petu fingrospuron ĉe starto - Petu paskodon ĉe starto - Skemo - Petu skemon ĉe starto - Agordu skemon - Enigu skemon - Ŝlosi Tuj - Ŝlosu tuj kiam la programo estas reaktivigita - Malŝalti glitadon - "Malŝalti glitadon por antaŭvidi en redakta reĝimo " - Skalu Serĉbretan Larĝon - Igu la serĉbretan larĝon kongrui kun la larĝo de la notoj - Tipara Grandeco - Ŝanĝu la grandecon de teksto en notoj - Tipara Grandeco: %1$d - Antaŭvido - Jen kiel viaj notoj aspektos kun la elektita tipara grandeco. - Fondinto de Babel Software kaj Android-programisto - Aldoni novan dosierujon - Nomo de dosierujo - Koloro de dosierujo - Aldoni - Movi al Dosierujo - Neniu dosierujo disponebla - Forigi dosierujon - Ĉu vi certas, ke vi volas forigi la dosierujon ‘%1$s’? La notoj en ĝi estos movitaj al la sekcio ‘Ĉiuj Notoj’. - Redakti dosierujon - Konservi - Dosieruja etikedo - Ĝi montras la etikedon de la dosierujo al kiu la notoj apartenas. - AirNote AI Agordoj - Agordu AI · API-agordoj - Uzu AirNote API - Uzu AI-funkciojn kun la enkonstruita API de la programo (Rekomendita) - Via API-ŝlosilo (BETA) - Kontroli - Uzenda Modelo - Modela Elekto (BETA) - Sugesto de AI - Fermi - Ĉiuj Notoj - Demandu, babilu, trovu kun AI... - Kiel Akiri Vian Propran API-Ŝlosilon? - De Google AI Studio, vi povas generi personan API-ŝlosilon kun malavara senpaga uzadlimo. Ĉi tiu ŝlosilo apartenas nur al vi kaj vi havas plenan kontrolon. - 1. Iru al la retejo de Google AI Studio alklakante la suban butonon. - 2. Ensalutu per via Google-konto. - 3. Sur la ekrano kiu malfermiĝas, alklaku la butonon \"Krei API-ŝlosilon\". - 4. Vi eble estos petita krei novan projekton, sekvu la paŝojn por krei ĝin. - 5. Kopiu la generitan longan signoĉenon. - 6. Algluu la kopiitan ŝlosilon en la suban tekstujon kaj premu la butonon \"Kontroli\". - Iru al Google AI Studio - Bonvolu unue enigi API-ŝlosilon. - API-ŝlosilo valida kaj sukcese registrita! ✅ - Nevalida API-ŝlosilo. Bonvolu kontroli kaj reprovi. - Faru demandon - Faru noton pri temo - Generu ideojn - Donu al mi 5 interesajn ideojn pri hazarda temo - Nova Versio - Ĝisdatigi - Nova ĝisdatigo disponebla! - Ĝisdatigi Programon - Ŝanĝprotokolo ne povis esti elŝutita. - Malsukcesis ricevi ŝanĝprotokolon: %1$s - Kontrolante pri ĝisdatigoj... - Ĝisdatigo Disponebla - Nova Versio Disponebla! - Versio %1$s estas preta por elŝuti. - Ĝisdatigi Nun - Elŝuti - Elŝutante... %1$d%% - Instali - Instali - Ĝisdatigo Malsukcesis - Eraro - Reprovi - Reprovi - Via Programo estas Ĝisdata - Ĝisdatigita - La ĝisdatigo postulas permeson por instali el nekonataj fontoj. - Elŝuta ligilo ne trovita. - Novaĵoj - Konservi Noton - Rekonstrui - - Optimumigi Skribadon - Resumigi - Mallongigi - Plilongigi - Ŝanĝi Tonon - - Oficiala - Ekvilibra - Amika - API-ŝlosilo de uzanto ne trovita. Bonvolu kontroli en agordoj. - API-peto malsukcesis: %1$s - Saluton! Kiel mi povas helpi vin? - SUGESTOJ - Listigu Avantaĝojn kaj Malavantaĝojn - Kreu Taskoliston - Diru ĝin Pli Simple - Rekomendita por importitaj dosieroj - Sugestu Titolon - - Bonege, pri kio ni kreu malneton? Bonvolu skribi la temon. - - Korektu la gramatikajn kaj esprimajn erarojn en la suba teksto por fari ĝin pli flua. Respondu nur la korektitan tekston:\n\n---\n\n%1$s - Resumigu la suban tekston en unu aŭ du frazoj, konservante la ĉefan ideon. Respondu nur la resuman tekston:\n\n---\n\n%1$s - Mallongigu la sekvan tekston signife konservante ĝian signifon. Respondu nur per la mallongigita teksto:\n\n---\n\n%1$s - Aldonu kreivajn detalojn al la suba teksto por plilongigi ĝin. Respondu nur per la etendita teksto:\n\n---\n\n%1$s - AI-serviloj estas nuntempe okupitaj. Bonvolu reprovi post kelkaj minutoj. - - %1$s\n\n---\n\n%2$s - Reskribu la suban tekston en profesia kaj formala lingvo. Konservu la signifon, nur ŝanĝu la tonon. Respondu nur per la fina teksto: - Reskribu la suban tekston en ekvilibra lingvo, nek tro formala nek tro sincera. Konservu la signifon. Respondu nur per la konkludo: - Reskribu la suban tekston en pli sincera, varma, konversacia kaj amika maniero. Konservu la signifon. Respondu nur per la konkludo: - - Uzanto de not-programo laboras pri noto titolita \"%1$s\". Sugestu kelkajn kreivajn kaj inspirajn ideojn aŭ ekpunktojn, kiuj taŭgas por ĉi tiu titolo. Tenu vian respondon mallonga kaj en lista formato. - Daŭrigu la suban tekston, konservante la integrecon de signifo kaj la stilon de la aŭtoro. Respondu nur al la nova teksto, ne ripetu la ekzistantan tekston:\n\n---\n\n\nTitolo: %1$s\n\nEnhavo:\n%2$s - Analizu la suban tekston kaj skribu paragrafon pri kiel alproksimiĝi al ĉi tiu temo el tute malsama perspektivo. Ne resumigu la ekzistantan tekston, proponu alternativan perspektivon:\n\n---\n\n\nTitolo: %1$s\n\nEnhavo:\n%2$s - Analizu la suban tekston kaj listigu la pozitivajn kaj negativajn punktojn sub la rubrikoj \'### Avantaĝoj\' kaj \'### Malavantaĝoj\'. Respondu nur ĉi tiujn du rubrikojn kaj erojn:\n\n\n---\n\n\n\n%1$s - Analizu la suban tekston. Se la teksto mencias taskojn farendajn dum la tago, transformu ilin en taskoliston metante \'[] \' komence de ĉiu linio. Se la teksto ne mencias taskojn, respondu NUR \'NO_TASKS\' sen tajpi ion alian. Detektu la lingvon de la teksto kaj kreu la liston en tiu lingvo:\n\n---\n\n%1$s - Traduku la kompleksan tekston sube en simplan kaj fluan lingvon, kiun ĉiuj povas kompreni. Konservu la ĉefan ideon kaj gravajn detalojn de la teksto. Respondu nur la simpligitan tekston:\n\n---\n\n%1$s - Sugestu 5 kreivajn kaj atentokaptajn titolojn, kiuj plej bone taŭgas al la enhavo de la suba teksto. Donu vian respondon kiel numeritan liston:\n\n---\n\n%1$s - - Uzanto volas verki malneton de noto pri la temo \"%1$s\". VI DEVAS doni vian respondon en la sekva formato: TITOLO: [enmetu titolon ĉi tie]\n\nENHAVO: [enmetu notan enhavon ĉi tie] - \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-es-rUS/strings.xml similarity index 100% rename from app/src/main/res/values-bn/strings.xml rename to app/src/main/res/values-es-rUS/strings.xml diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0192673..00d0e4d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,167 +1,308 @@ - No se encontraron notas. - No hay notas creadas. - AirNote - Vista de columna - Descripción - Seleccionar todo - Eliminar - Tema Oscuro - Colores Amoled - Base de Datos - No soportado por el momento - Acerca de - Dóname :) - Restaurar - Soporte - Idioma + + Inicio + Canciones + Artistas + Álbumes + Listas de reproducción + + + %d seleccionada + %d seleccionadas + %d seleccionadas + + Historial - Versión • Desarrolladores • Licencia - Radio - Notas - Seleccionar Radio - Modo Minimalista - Nueva Nota - Hora de Creación - Caracteres - Vista de cuadrícula - Información - Ajustes - Copia de Seguridad - Palabras - Nombre + Estadísticas + Ánimo y géneros + Cuenta + Selecciones rápidas + Escucha algunas canciones para generar tus selecciones rápidas + Favoritos olvidados + Seguir escuchando + Similar a + Nuevos lanzamientos + + Hoy + Ayer + Esta semana + La semana pasada + + Canciones más reproducidas + Artistas más reproducidos + Álbumes más reproducidos + Buscar - Pantalla - Tema del Sistema - Colores Dinámicos - Versión - Última Actualización - Notas - Dale una estrella al proyecto - - Markdown - Herramientas - Temas • Tamaño • Fuente - Widgets - Nube - Historial • Revertir - Fuente • Idioma - NextCloud • Local • Encriptación - Widgets • Markdown - Creado • Eliminar Todo - Soporte • Imágenes - Muestra tu amor - Fuente - Colores Amoled Extremos - Inestable - Criptomoneda - Alternar entre esquemas de colores oscuros y claros - Utilice el color negro para el fondo - Imágenes soportadas - Personalizar el radio del borde - Seleccionar forma de visualización - Eliminar los botones superiores de la pantalla de edición - Almacenar todas las notas en un archivo - Restaurar notas desde una copia de seguridad - Habilitar Markdown en las notas - Ocultar la pantalla de recientes y deshabilitar capturas de pantalla - Tipos de desarrollo - Código fuente - Lista de patrocinadores - Correo electrónico + Buscar en Loudly + Buscar en la biblioteca… + Biblioteca + Me gusta + Descargado + Todo + Canciones + Vídeos + Álbumes + Artistas + Listas de reproducción + Listas de la comunidad + Listas destacadas + En marcadores + No se han encontrado resultados + + Las canciones de la biblioteca aparecerán aquí + Los artistas de la biblioteca aparecerán aquí + Los álbumes de la biblioteca aparecerán aquí + Las listas de reproducción de la biblioteca aparecerán aquí + + De tu biblioteca + + Canciones que te gustan + Canciones descargadas + La lista de reproducción está vacía + ¿Estás seguro de que quieres eliminar todas las canciones de la lista de reproducción «%s» del almacenamiento de canciones descargadas? + ¿Estás seguro de que quieres borrar la lista de reproducción «%s»? + + Reintentar + Radio + Mezclar + Restablecer + + Detalles + Editar + Comenzar radio + Reproducir + Reproducir siguiente + Añadir a la cola + Agregar a la biblioteca + Quitar de la biblioteca + Descargar + Descargando + Borrar descarga + Importar lista de reproducción + Agregar a la lista de reproducción + Ver artista + Ver álbum + Cargar de nuevo + Compartir + Borrar + Eliminar del historial + Eliminar de la lista de reproducción + Eliminar de la cola + Buscar online + Sincronizar + Avanzado + Tempo y tono + + Fecha añadida + Nombre + Artista + Año + Número de canciones + Duración + Tiempo de reproducción + Orden personalizado + + ID multimedia + Tipo de MIME + Códecs + Tasa de bits + Frecuencia + Intensidad + Volumen + Tamaño del archivo + Desconocido + Copiado al portapapeles + Editar letras + Buscar letras + Editar canción + Título de la canción + Artista de la canción + El título de la canción no puede estar vacío. + El artista de la canción no puede estar vacío. + Guardar + Elegir lista de reproducción + Editar lista de reproducción + Crear lista de reproducción + Nombre de la lista de reproducción + El nombre de la lista de reproducción no puede estar vacío. + Editar artista + Nombre del artista + El nombre del artista no puede estar vacío. + Duplicados + Saltar duplicados + Añadir de todas formas + Esta canción ya está en tu lista de reproducción + %d canciones ya están en tu lista de reproducción + + + %d canción + %d canciones + %d canciones + + + %d artista + %d artistas + %d artistas + + + %d álbum + %d álbumes + %d álbumes + + + %d lista de reproducción + %d listas de reproducción + %d listas de reproducción + + + %d semana + %d semanas + %d semanas + + + %d mes + %d meses + %d meses + + + %d año + %d años + %d años + + + Lista de reproducción importada + Eliminado «%s» de la lista de reproducción + Lista de reproducción sincronizada + Deshacer + + Letras no encontradas + Temporizador de apagado + Al finalizar la canción + + 1 minuto + %d minutos + %d minutos + + No hay un stream disponible + No hay conexión a internet + Tiempo de espera agotado + Error desconocido + + Dar me gusta + Dar me gusta a todo + Quitar me gusta + Quitar me gusta a todo + Activar aleatorio + Desactivar aleatorio + Modo de repetición desactivado + Repetir canción actual + Repetir cola + + Todas las canciones + Canciones buscadas + + Reproductor de música + + Ajustes + Apariencia + Tema + Habilitar tema dinámico + Tema oscuro + Activado + Desactivado + Seguir el sistema + Negro puro + Personalizar pestañas de navegación + Reproductor + Alineación de texto del reproductor + Posición del texto de las letras + En el lado + Izquierda + Centro + Derecha + Estilo de la barra + Por defecto + Ondulado + Otros + Pestaña de inicio por defecto + Tamaño de la celda de cuadrícula + Pequeño + Grande + Contenido + Iniciar sesión + Sesión no iniciada + Idioma de contenido predeterminado + País de contenido predeterminado + Por defecto del sistema + Habilitar proxy + Tipo de proxy + URL del proxy + Reinicia para aplicar los cambios + Reproductor y sonido + Calidad del sonido + Automático + Alta + Baja + Cola + Cola persistente + Restaura tu última cola al iniciar la aplicación + Saltar silencio + Normalización del audio + Saltar automáticamente a la siguiente canción cuando se produzca un error + Asegura una experiencia de reproducción continua + Detener la música al cerrar la tarea + Ecualizador + Almacenamiento + Caché + Caché de imágenes + Caché de canciones + Tamaño máximo de la caché + Ilimitado + Borrar todas las descargas + Tamaño máximo de la caché de imágenes + Borrar la caché de imágenes + Tamaño máximo de la caché de canciones + Borrar la caché de canciones + usado Privacidad - Protección de pantalla - Adapta el tema de la aplicación según la configuración del sistema - Solicitud de características - Ajusta los colores para que coincidan con tu fondo de pantalla - Optimización extrema de la batería - Discord - Fijado - Copiar - Otros - Cifrar base de datos - Cifrar la base de datos durante la copia de seguridad - Introducir la contraseña - La contraseña no es válida o la base de datos no está cifrada - Entrada inválida - Orden Ascendente - Orden Descendente - Ordenar notas por fecha de creación - Seleccionar nota - Tamaño de imagen no compatible (máximo 15 MB) - Bóveda - Habilitar bóveda de notas encriptadas - continuar - Cambiar el idioma de la aplicación - Idioma del sistema - Comportamiento - Priorizar el modo de edición - Acceder al modo de edición de notas ya existentes - Contraseña incorrecta - Donar - Traducciones - Sincronización de la galería - Elimina las imágenes del almacenamiento de la aplicación cuando se eliminan de la galería - Mostrar solo el título - Aportaciones - Mostrar solo el título en la vista previa - 4. Propiedad intelectual - 6. Limitación de la responsabilidad - 7. Rescisión - 9. Contacto - 10. Protección de datos - 1. Aceptación de las condiciones - Al pulsar Acepto y utilizar Easy Notes, aceptas estas Condiciones del servicio. Si no está de acuerdo, no utilices la aplicación. - 2. Licencia - Easy Notes se distribuye bajo la **Licencia Pública General de GNU (GPL) versión 3**. Esto significa que: -\n -\n- Eres libre de utilizar, modificar y compartir la aplicación. -\n- Si distribuyes la aplicación o cualquier versión modificada, debes distribuir también el código fuente y conservar la licencia GPL. - 3. Uso de la aplicación - Te comprometes a utilizar Easy Notes únicamente con fines lícitos y de conformidad con las presentes Condiciones. **No debes**: -\n -\n- _Utilizar la aplicación para fines ilegales_ -\n- _Violar los derechos de terceros_ -\n- _Perturbar la funcionalidad o la seguridad de la aplicación_ - Todos los derechos de propiedad intelectual de Easy Notes nos pertenecen a nosotros o a nuestros licenciantes. El uso de la aplicación no te otorga la titularidad de ningún derecho de propiedad intelectual. - 5. Descargo de responsabilidad - Easy Notes se proporciona como es, sin ninguna garantía. No garantizamos que la aplicación esté libre de errores, sea segura o no tenga interrupciones o cuelgues. - No nos hacemos responsables de los daños derivados del uso de Easy Notes, incluidos los daños indirectos, accidentales o consecuentes. - Podemos cancelar o suspender tu acceso a Easy Notes si incumples estas Condiciones. Tras la rescisión, tu derecho a utilizar la aplicación terminará inmediatamente. - 8. Cambios en las condiciones - Podemos actualizar estas Condiciones del servicio de vez en cuando. El uso continuado de Easy Notes después de cualquier cambio constituye la aceptación de los nuevos términos , o de la revisión de los términos. - Si tienes alguna pregunta sobre estas Condiciones del servicio o de Easy Notes, pónte en contacto con nosotros en - Easy Notes no recopila datos personales. Para más información, consulta nuestra Política de privacidad disponible en - Fecha de vigencia de los términos - Condiciones del servicio - Estoy de acuerdo - No se pudo importar - Error en la importación de algunos archivos - Importado exitosamente - Importar - Importar otros archivos de texto como notas - Utilizar una fuente monoespaciada al editar la nota - Fuente monoespaciada - ¿Está seguro de que desea eliminar las notas seleccionadas? - - Cancelar - Bloqueo de aplicaciones - Bloquear la aplicación usando una contraseña - Configurar contraseña - Introducir código de acceso - Pin (6 números) - Código de acceso - Huella dactilar - Se requiere autenticación - Está bloqueado - Identificación única - Solicitar huella dactilar al iniciar - Solicitar contraseña al iniciar - Patrón - Solicitar patrón al inicio - Configurar patrón - Introducir patrón - Bloquear inmediatamente - Bloquear instantáneamente cuando se reanuda la aplicación + Pausar historial de escuchas + Borrar historial de escuchas + ¿Estás seguro de que quieres limpiar todo el historial de escuchados? + Pausar historial de búsquedas + Borrar historial de búsquedas + ¿Estás seguro de que quieres limpiar todo el historial de búsquedas? + Habilitar el proveedor de letras LrcLib + Habilitar el proveedor de letras KuGou + Ocultar contenido explícito + Copias de seguridad y restauración + Hacer copia de seguridad + Restaurar + Lista de reproducción importada + Copia de seguridad creada con éxito + No se ha podido crear la copia de seguridad + Error al restaurar la copia de seguridad + Integración con Discord + Loudly utiliza la biblioteca KizzyRPC para establecer el estado de tu cuenta de Discord. Esto implica el uso de la conexión Discord Gateway, lo que podría considerarse una violación de las condiciones del servicio de Discord. Sin embargo, no se conocen casos de cuentas de usuario suspendidas por este motivo. Úsalo bajo tu propio riesgo.\n\nLoudly solo extraerá tu token y todo lo demás se almacena localmente. + Descartar + Opciones + Vista previa + Ha fallado el inicio de sesión + Cerrar sesión + Activar Rich Presence + Acerca de + Versión de la app + Nueva versión disponible + Modelos de traducción + Limpiar modelos de traducción + Historial de escuchadas + Tus listas de reproducción de YouTube + Eliminar todo de la biblioteca + Historial de búsqueda + Desactivar captura de pantalla + Cargar automáticamente más canciones + Cuando esta opción está activada, las capturas de pantalla y la vista de la aplicación en Recientes estará deshabilitada. + Agregar automáticamente más canciones cuando se llegue al final de la cola, si es posible + Otras versiones + Agregar todo a la biblioteca \ No newline at end of file diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml new file mode 100644 index 0000000..7506477 --- /dev/null +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -0,0 +1,270 @@ + + + خانه + ترانه‌ها + هنرمندها + مجموعه‌ها + فهرست‌پخش‌ها + + + + %d انتخاب‌شده + %d انتخاب‌شده‌اند + + + + تاریخچه + آمار + حالت و سبک + حساب + انتخاب‌های سریع + به ترانه‌ها گوش دهید تا انتخاب سریع خود ساخته شود + مجموعه‌های منتشرشده‌ی جدید + + + امروز + دیروز + این هفته + هفته‌ی گذشته + + + بیشترین ترانه‌های پخش‌شده + بیشترین هنرمندهای پخش‌شده + بیشترین مجموعه‌های پخش‌شده + + + جستجو + جستجو در یوتیوب موزیک… + جستجوی کتاب‌خانه… + کتاب‌خانه + پسندشده + بارگیری‌شده + همه + ترانه‌ها + فیلم‌ها + مجموعه‌ها + هنرمندها + فهرست‌پخش‌ها + فهرست‌پخش‌های انجمن + فهرست‌پخش‌های ویژه + نشانک‌گذاری‌شده + نتیجه‌ای پیدا نشد + + + از کتاب‌خانه شما + + + ترانه‌های موردپسند + ترانه‌های بارگیری‌شده + فهرست‌پخش خالی است + + + تلاش‌مجدد + رادیو + بُرزدن + بازنشانی + + + جزئیات + ویرایش + شروع رادیو + پخش + پخش بعدی + اضافه‌کردن به صف + اضافه‌کردن به کتاب‌خانه + حذف از کتاب‌خانه + بارگیری + درحال بارگیری + حذف بارگیری + واردکردن فهرست‌پخش + اضافه‌کردن به فهرست‌پخش + مشاهده‌ی هنرمند + مشاهده‌ی مجموعه + نوسازی + هم‌رسانی + حذف + حذف از تاریخچه + جستجوی برخط + همگام‌سازی + پیشرفته + + + تاریخ اضافه‌شده + نام + هنرمند + سال + تعداد ترانه + طول + زمان پخش + ترتیب سفارشی + + + شناسه‌ی رسانه + نوع رسانه + رمزگشاها + نرخ‌ذره + نرخ نمونه + بلندی + حجم + اندازه‌ی پرونده + ناشناخته + در بُریده‌دان رونوشت‌شد + + ویرایش متن‌ترانه + جستجوی متن‌ترانه + + ویرایش ترانه + عنوان ترانه + هنرمند ترانه + عنوان ترانه نمی تواند خالی باشد. + هنرمند ترانه نمی تواند خالی باشد. + ذخیره + + انتخاب فهرست‌پخش + ویرایش فهرست‌پخش + ایجاد فهرست‌پخش + نام فهرست‌پخش + نام فهرست‌پخش نمی تواند خالی باشد. + + ویرایش هنرمند + نام هنرمند + نام هنرمند نمی تواند خالی باشد. + + + + %d ترانه + %d ترانه‌ها + + + %d هنرمند + %d هنرمندها + + + %d مجموعه + %d مجموعه‌ها + + + %d فهرست‌پخش + %d فهرست‌پخش‌ها + + + %d هفته + %d هفته‌ها + + + %d ماه + %d ماه‌ها + + + %d سال + %d سال‌ها + + + + فهرست‌پخش واردشد + «%s» از فهرست‌پخش حذف شد + فهرست‌پخش همگام‌شد + واگرد + + + متن‌ترانه پیدا نشد + شمارنده‌ی خواب + پایان ترانه + + ۱ دقیقه + %d دقیقه + + جریانی در دسترس نیست + بدون اتصال به تارکده + اتمام وقت + خطای ناشناخته + + + پسندیدن + حذف پسندیدن + بُرزدن روشن + بُرزدن خاموش + حالت تکرار خاموش + تکرار ترانه‌ی فعلی + ترار صف + + + همه‌ی ترانه‌ها + ترانه‌های جستجوشده + + + پخش‌کننده‌ی موسیقی + + + تنظیمات + ظاهر + فعال‌کردن طرح پویا + تم تاریک + روشن + خاموش + پیروی از سیستم + مشکی خالص + زبانه باز پیش‌فرض + سفارشی‌کردن زبانه‌های ناوبری + موقعیت متن ترانه + چپ + وسط + راست + + محتوا + ورود + زبان پیش‌فرض محتوا + کشور پیش‌فرض محتوا + پیش فرض سیستم + فعال‌کردن پروکسی + نوع پروکسی + آدرس‌اینترنتی پروکسی + راه‌اندازی‌مجدد برای اعمال اثر + + پخش‌کننده و صدا + کیفیت صدا + خودکار + بالا + پایین + صف مداوم + گذر از سکوت + طبیعی‌سازی صدا + میزان‌گر + + ذخیره‌سازی + حافظه‌ی‌پنهان + حافظه‌‌ی‌پنهان تصویر + حافظه‌‌ی‌پنهان ترانه + حداکثر اندازه‌ی حافظه‌ی‌پنهان + نامحدود + پاک‌کردن تمامی بارگیری‌ها + بیشترین اندازه‌ی حافظه‌ی‌پنهان تصویر + پاک‌کردن حافظه‌‌ی‌پنهان تصویر + بیشترین اندازه‌ی حافظه‌ی‌پنهان ترانه + پاک‌کردن حافظه‌‌ی‌پنهان ترانه + استفاده‌شده‌است + + حریم‌خصوصی + متوقف‌کردن تاریخچه‌ی گوش‌دادن + پاک‌کردن تاریخچه‌ی گوش‌دادن + آیا از پاک‌کردن تمامی سابقه‌ی گوش‌دادن مطمئن هستید؟ + متوقف‌کردن تاریخچه جستجو + پاک‌کردن تاریخچه جستجو + آیا برای پاک‌کردن تمام سابقه جستجو مطمئن هستید؟ + فعال‌کردن ارائه‌دهنده‌ی متن‌ترانه‌ی KuGou + + پشتیبان‌گیری و بازگردانی + پشتیبان‌گیری + بازگردانی + فهرست‌پخش واردشد + پشتیبان باموفقیت ایجادشد + پشتیبان ایجاد نشد + بازیابی پشتیبان انجام‌نشد + + درباره + نسخه‌ی برنامه + + نسخه‌ی جدید دردسترس‌است + نمونه‌های ترجمه + پاک‌کردن نمونه‌های ترجمه + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml deleted file mode 100644 index 11a38d1..0000000 --- a/app/src/main/res/values-fa/strings.xml +++ /dev/null @@ -1,164 +0,0 @@ - - - AirNote - نوشتارها - نوشته‌ی نو - نوشتاری ساخته نشده است. - نوشتاری یافت نشد. - زمان ساخته شدن - گزارش یادداشت - پیشینه - ابزارک‌ها - نمای رده‌ای - نمای تکه ای - واژگان - واج‌ها - نام - گزینش همه - پاک کردن - ریزگان - جستجو - پیکربندی - نمایش - تم تاریک - تم سامانه - رنگ‌های پویا - مشکی ناب - هنوز پشتیبانی نمی‌شود - پایگاه داده - درباره برنامه - نگارش - تازه‌ترین نگارش - پشتیبان گیری - ستاره دادن به برنامه - پیشکشم کنید :) - بازگردانی - نوشته ها - همیاری - تم‌ها • اندازه • کلک - ابر - زبان - رنگ‌ها و سبک‌ها - نشانه گذاری - ابزار‌ها - کلک • زبان - NextCloud • برزنی • رمزگذاری - ابزارک‌ها • نشانه‌گذاری - نگارش • برنامه نویسان • پروانه - پیشینه • برگشت - ساخته شده • پاک‌کردن همه - پشتیبانی • فرتور‌ها - دلبستگی خود را بنمایید - کلک - خمیدگی - گزیدن خمیدگی - سبک ساده‌گرا - رنگ های بسیار تیره - خوشیک - ارز دیجیتال - همپوش کردن تم برنامه بر اساس پیکر‌بندی‌های دستگاه - پاسداری از نمایشگر - پشتیبانی از فرتورها - ناپایدار - سفارشی ساختن خمیدگی گوشه‌ها - گزیدن فتن نمایش - برداشتن دکمه‌های بالایی از برگه‌ی ویرایش - بازیابی نوشته‌ها از پرونده بکاپ - جهش میان الگو‌های رنگی تیره ‌و روشن - پنهان کردن نمایشگر در تازگی‌ها و خاموش کردن نماگرفت‌ها - سردگ ساخت - بن‌مایه‌ی دستور - پهرست پشتیبانان - ایمیل - درخواست ویژگی نو - دیسکورد - میان الگو‌های رنگی تیره و روشن جابه‌جا شوید - بهینه‌سازی بیش‌ازاندازه‌ی باتری - انبار کردن همه‌ی نوشته‌ها در یک پرونده - جورشدن رنگ‌ها با فرتور پس‌زمینه‌ی شما - روشن کردن کارپ نشانه‌گذاری در نوشتارها - به تاغ دوخته‌ - دیگران - رونوشت - رمزگذاری پایگاه‌ داده‌ها - رمزگذاری پایگاه‌ داده‌ها هنگام پشتیبان‌گیری - گذرواژه را اندرون نشانید - درونشد اشتباه است - چینش فرودی - گذرواژه نادرست است یا پایگاه‌ داده‌ها رمزگذاری نشده است - چینش فرازی - چینش نوشتار‌ها بر اساس زمان ساخت‌شان - گزیدن نوشتار - اندازه‌ی فرتور پشتیبانی نمی‌شود (بیشینه ۱۵ م.ب) - ادامه - گنجه - روشن کردن گنجه‌ی نوشتار‌های رمزگذاری‌شده - وَرتاندن زبان برنامه - زبان سامانه - رفتار - حالت ویرایش را اولویت بندی کنید - وارد حالت ویرایشگر در یادداشت های موجود شوید - رمز عبور نامعتبر - ۲.مجوز - ۳.استفاده از برنامه - ۴.مالکیت فکری - ۵.سلب مسئولیت از ضمانتنامه‌ - این برنامه همانطور که هست و بدون ضمانت ارائه می شود. ما تضمین نمی کنیم که برنامه عاری از خطا، ایمن یا بدون وقفه باشد. - ۶. محدودیت مسئولیت - ۷. فسخ - ۸. تغییرات در شرایط - این برنامه هیچ اطلاعات شخصی را جمع آوری نمی کند. برای اطلاعات بیشتر، لطفاً به خط مشی رازداری ما در دسترس مراجعه کنید - تاریخ اجرایی - موافقم - شرایط سرویس دهی - ۱۰. سیاست حفظ حریم خصوصی - نمیتوان وارد کرد - واردات بعضی از فایلها شکست خورد - وارد شد - واردات - وارد‌کردن سایر فایلها به عنوان یادداشت - کمک‌مالی - ترجمه‌کردن - مشارکت - همگام‌سازی گالری - فقط نمایش عنوان - فقط نمایش عنوان در پیش‌نمایش - قفل است - حذف عکس‌ها از حافظه برنامه وقتی از گالری پاک شدن - با فشار دادن قبول کردن و استفاده از برنامه، این شرایط خدمات را می پذیرید. اگر موافق نیستید، از برنامه استفاده نکنید. - ما مسئولیتی در قبال خسارات ناشی از استفاده شما از برنامه از جمله خسارات غیرمستقیم، اتفاقی یا تبعی نداریم. - در صورت نقض این شرایط، ممکن است دسترسی شما به این برنامه را قطع یا تعلیق کنیم. پس از فسخ، حق شما برای استفاده از برنامه بلافاصله پایان می یابد. - ممکن است هر از چند گاهی این شرایط خدمات را به روز کنیم. ادامه استفاده شما از برنامه پس از هرگونه تغییر به منزله پذیرش شرایط اصلاح شده است. - ۹.ارتباط با ما - برای هرگونه سوال در مورد این شرایط خدمات یا یادداشت های آسان، لطفاً با ما تماس بگیرید - قفل فوری - پرسش رمز عبور هنگام شروع - رمز عبور - یادداشت‌های آسان تحت لیسانس** مجوز عمومی کلی جی.ان.یو (جی.پی.ال) نسخه 3** توزیع شده است. این به این معنی است:\n\n- شما برای استفاده، تغییر و به اشتراک گذاری برنامه آزاد هستید.\n- اگر برنامه یا هر نسخه تغییر یافته ای را توزیع می کنید، باید کد منبع را نیز توزیع کنید و مجوز جی.پی.ال را حفظ کنید. - شما موافقت می کنید که از این برنامه فقط برای مقاصد قانونی و مطابق با این شرایط استفاده کنید. شما حتما **نباید**:\n\n- _از برنامه برای هر هدف غیرقانونی استفاده کنید_-\n- _حقوق دیگران را نقض کنید_\n- _عملکرد یا امنیت برنامه را مختل کنید_ - ۱.قبول کردن موارد - هنگام ویرایش یادداشت از فونت زیرنویس استفاده کنید - فونت زیرنویس - آیا مطمئن هستید که می خواهید یادداشت های انتخاب شده را حذف کنید - بله - خیر - قفل برنامه - قفل برنامه با گذرواژه - تنظیم رمز عبور - وارد کردن رمز عبور - پین (۶ عدد) - اثر انگشت - نیاز به تایید - شناسه - پرسش اثر انگشت هنگام شروع - الگو - پرسش الگو هنگام شروع - تنظیم الگو - وارد کردن الگو - قفل فوری هنگام استفاده مجدد از برنامه - تمامی حقوق مالکیت معنوی در این برنامه متعلق به ما یا صادرکنندگان مجوز ما است. استفاده شما از برنامه به شما حق مالکیت معنوی را نمی دهد. - غیرفعال کردن کشیدن - "غیرفعال کردن کشیدن انگشت برای پیش نمایش در حالت ویرایش. " - مقیاس پهنای نوار جستجو - نوار جستجو را با پهنای یادداشت ها مطابقت دهید - diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml new file mode 100644 index 0000000..8ae0f5b --- /dev/null +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -0,0 +1,636 @@ + + + + Tervetuloa Loudlyyn + Aloitetaan musiikkikokemuksesi personointi. + YouTube Music sormiesi ulottuvilla + YT Music -tilin synkronointi + Toisto ilman mainoksia + Offline-musiikkikokemus + Tukee Android Autoa + Tukee Discord-tilaa + Täysin avoin lähdekoodi + Valitse soittimen käyttöliittymä + Valitse sovelluksen sisäinen soitinnäkymä. Voit muuttaa tätä asetusta myöhemmin kohdasta ”Asetukset > Ulkoasu”. + Asennus valmis! + Olet valmis tutustumaan Loudlyyn. + Valmis + Takaisin + Seuraava + Edellinen + + Koti + Selaa + Kappaleet + Artistit + Albumit + Soittolistat + + Pitätyt + Tykkää + Offline-musiikki + Omat top-listat + Välimuistissa + Paikallinen musiikki + + Sisältösuodatin + + Kaikkien aikojen + Viimeiset 24 tuntia + Viime viikko + Viime kuukausi + Viime vuosi + + + %d valittu + %d valittu + + + Hyvää huomenta + Hyvää iltapäivää + Hyvää iltaa + Hyvää yötä + Viimeaikainen toiminta + Historia + Historiasi näkyy täällä + Tilastosi näkyvät täällä + Tilastot + Näytä vähemmän + Näytä enemmän + Tunnelmat ja genret + Tili + Pikavalinnat + Kuuntele kappaleita luodaksesi pikavalintojasi + Unohdetut suosikit + Jatka kuuntelua + Omat YouTube-soittolistasi + Samankaltaisia kuin + Uudet albumit + Ei internetyhteyttä + Näyttää siltä, että sinulla ei ole internetyhteyttä. Tarkista verkkoyhteytesi. + Sinulle valittua + Sinulle suositeltua tänään + + Tänään + Eilen + Tällä viikolla + Viime viikolla + Paikallinen + Etä + Paikallinen historia + Etähistoria + + Eniten soitetut kappaleet + Eniten soitetut artistit + Eniten soitetut albumit + + Haku + Hae Loudlysta + Hae kirjastosta… + Hakuhistoria + Ehdotukset + Hakutulokset + Kirjasto + Pitätyt + Ladatut + Kaikki + Kappaleet + Videot + Albumit + Artistit + Soittolistat + Yhteisön soittolistat + Esitellyt soittolistat + Kirjanmerkeillä + Tuloksia ei löytynyt + + Kirjastosi näkyy täällä + Kirjaston kappaleet näkyvät täällä + Kirjaston artistit näkyvät täällä + Kirjaston albumit näkyvät täällä + Omat soittolistasi näkyvät täällä + + Kirjastostasi + + Muut versiot + Suosikki + Suosituimmat kappaleet + + Pitätyt kappaleet + Ladatut kappaleet + Soittolista on tyhjä + Haluatko todella poistaa kaikki soittolistan \"%s\" kappaleet ladattujen kappaleiden tallennustilasta? + Haluatko todella poistaa soittolistan \"%s\"? + + Pois päältä + + Yritä uudelleen + Radio + Satunnaistoisto + Nollaa + + Tiedot + Muokkaa + Käynnistä radio + Toista + Toista seuraavaksi + Lisää jonoon + Lisää kirjastoon + Lisää kaikki kirjastoon + Poista kirjastosta + Poista kaikki kirjastosta + Lataa + Ladataan + Poista lataus + Tuo soittolista + Lisää soittolistalle + Näytä artisti + Näytä albumi + Hae uudelleen + Jaa + Poista + Poista historiasta + Poista soittolistalta + Poista jonosta + Poista välimuistista + Vie M3U-tiedostona + Hae verkosta + Synkronoi + Lisäasetukset + Tempo ja sävelkorkeus + + Lisäyspäivä + Päivityspäivä + Nimi + Artisti + Vuosi + Kappalemäärä + Pituus + Soittoaika + Oma järjestys + + Median tunniste + MIME-tyyppi + Koodekit + Bittinopeus + Näytteenottotaajuus + Äänenvoimakkuus + Volyymi + Tiedostokoko + Tuntematon + Kopioitu leikepöydälle + + Muokkaa sanoituksia + Hae sanoituksia + Haluatko todella poistaa tallennetut sanoitukset kappaleelle \"%s\"? + + Muokkaa kappaletta + Kappaleen nimi + Kappaleen artistit + Kappaleen nimi ei voi olla tyhjä. + Kappaleen artisti ei voi olla tyhjä. + Tallenna + + Synkronoi soittolista + Valitse soittolista + Muokkaa soittolistaa + Luo soittolista + Soittolistan nimi + Soittolistan nimi ei voi olla tyhjä. + + Muokkaa artistia + Artistin nimi + Artistin nimi ei voi olla tyhjä. + + Kappaleet, jotka ovat jo listalla + Ohita kaksoiskappaleet + Lisää silti + Kappale on jo soittolistallasi + %d kappaletta on jo soittolistallasi + Huom: Tämä mahdollistaa synkronoinnin YouTube Musicin kanssa + + + %d kappale + %d kappaletta + + + %d artisti + %d artistia + + + %d albumi + %d albumia + + + %d soittolista + %d soittolistaa + + + %d viikko + %d viikkoa + + + %d kuukausi + %d kuukautta + + + %d vuosi + %d vuotta + + + Soittolista tuotu + Poistettiin \"%s\" soittolistalta + Soittolista synkronoitu + Kumoa + + Nyt soi + Sanoituksia ei löytynyt + Uniajastin + Kappaleen lopussa + + 1 minuutti + %d minuuttia + + Striimiä ei saatavilla + Ei verkkoyhteyttä + Aikakatkaisu + Tuntematon virhe + Valikko + Jaa lista + + 5 sekuntia taaksepäin + 5 sekuntia eteenpäin + + Tykkää + Tykkää kaikista + Poista tykkäys + Poista kaikki tykkäykset + Satunnaistoisto päällä + Satunnaistoisto pois päältä + Toistotila pois päältä + Toista nykyinen kappale + Toista jono + + Kaikki kappaleet + Haetut kappaleet + + Musiikkisoitin + + Ilmoitukset + Estä järjestelmää sulkemasta sovellusta + Käytä etualan ilmoitusta estääksesi Loudlya sulkeutumasta satunnaisesti järjestelmän toimesta. + Ilmoituslupa vaaditaan + + Asetukset + Ulkoasu + Teema + Välityspalvelin + Ota käyttöön dynaaminen teema + Tumma teema + Päällä + Pois päältä + Seuraa järjestelmää + Puhdas musta + Sovelluksen suunnittelu + Sovelluksen käyttöliittymä 1.0 + Sovelluksen käyttöliittymä 1.5 + Kuuntele YouTube Musicissa + Vieraile Loudlyssa + Artisti + Kuuntele Loudlya + Vaihda kappaletta pyyhkäisemällä pikkukuvaa + Mukauta navigointivälilehtiä + Soitin + Soittimen tekstin tasaus + Sanoitusten tekstin sijainti + Sivulla + Vasen + Keskitetty + Soittimen tyyli + Soittimen vanha käyttöliittymä + Soittimen käyttöliittymä 1.0 + Soittimen käyttöliittymä 2.0 + Seuraa teemaa + Liukuväri + Sumennus + Sumennus (liike) + Soittimen taustatyyli + Oikea + Liukusäätimen tyyli + Oletus + Mutkitteleva + Compose + Määritä pikkukuvan kulman säde + Näytä sisältösuodatin + Muut + Pää + Automaattisten soittolistojen mukauttaminen + Näytä automaattinen pidettyjen soittolista + Näytä automaattinen ladattujen soittolista + Näytä automaattinen top-soittolista + Näytä automaattinen välimuistissa olevien soittolista + Näytä automaattinen paikallisten soittolista + Vaihda seuraavaan pyyhkäisemällä oikealle tai lisää jonoon pyyhkäisemällä vasemmalle + Oletusvälilehti avattaessa + Oletuskirjaston suodattimet + Ruudukon solukoko + Näytä alanavigointipalkin otsikot + Pieni + Iso + Muutosloki + Viimeaikaisia muutoksia ei löytynyt + Viimeisimmät muutokset: + Sulje + Uusi versio saatavilla + Hei taas + Lahjoita + Virallinen kanava + Verkkosivusto + Virallinen verkkosivusto ❯ + Lähdekoodi + Käytä sovelluksen lähdekoodia + Virheet + Virheraportit ja muuta + Ohje + Jos haluat osallistua, napsauta tästä ❯ + Kehittäjä | @RRechz + + Sisältö + Kirjaudu sisään + Kirjaudu sisään YT Music -soittolistoillesi ja muuhun + Näytä tunniste napauttamalla + Kopioi tai muokkaa napauttamalla uudelleen + Tämä on edistynyt kirjautumistapa. Vaihtoehtona verkkoportaalille voit syöttää tai päivittää kirjautumistunnuksesi suoraan täällä. Tämä voi esimerkiksi nopeuttaa kirjautumista useilla laitteilla. Huomaa, että sovellus ei hyväksy virheellisiä tunnusmuotoja, joita se ei pysty jäsentämään. + Tuo Spotifysta + Spotify + Discord + Lataa automaattisesti pitämäsi kappaleet + Synkronoi automaattisesti YouTube Music -tilisi kanssa + Synkronoi soittolistasi ja muut tiedot tilisi kanssa + Vain Wi-Fi + Ei kirjautunut sisään + Oletussisällön kieli + Oletussisällön maa + Järjestelmän oletus + Ota välityspalvelin käyttöön + Välityspalvelimen tyyppi + Välityspalvelimen URL-osoite + Käynnistä uudelleen, jotta muutokset tulevat voimaan + Sovelluksen kieli + Määritä sovelluksen kielet + Sovelluksen kieliasetuksia ei löytynyt, määritä ne manuaalisesti + Avaa tuetut linkit oletuksena + Määritä tuetut linkit + Tuettujen linkkien asetuksia ei löytynyt, määritä ne manuaalisesti + + Soitin ja ääni + Äänenlaatu + Maksimi + Automaattinen + Korkea + Matala + Minimitoiston kesto + Kappaleen vähimmäiskesto, joka on toistettava, jotta se katsotaan "toistetuksi" + Jono + Pysyvä jono + Palauta viimeisin jonosi sovelluksen käynnistyessä + Lataa lisää kappaleita automaattisesti + Lisää automaattisesti lisää kappaleita, kun jonon loppu saavutetaan, jos mahdollista + Ohita hiljaisuus + Äänen normalisointi + Jatka toistoa, kun Bluetooth-laite on yhdistetty + Lopeta toisto, kun äänenvoimakkuus on minimissä + Lisätään toistettuja kappaleita YouTube Musicin historiaan + Ohita automaattisesti seuraavaan kappaleeseen virheen sattuessa + Varmista jatkuva toistokokemuksesi + Pysäytä musiikki, kun tehtävä tyhjennetään + Taajuuskorjain + Sanoitukset + Sanoitukset + Paikallisen soittimen asetukset + Tallennustila + Välimuisti + Kuvien välimuisti + Kappaleiden välimuisti + Välimuistin enimmäiskoko + Rajoittamaton + Ota offload-toiminto käyttöön + Käytä äänen toistoon offload-äänipolkua. Tämän poistaminen käytöstä voi lisätä virrankulutusta, mutta voi olla hyödyllistä, jos sinulla on ongelmia äänen toiston tai jälkikäsittelyn kanssa. + Tyhjennä kaikki lataukset + Kuvien välimuistin enimmäiskoko + Tyhjennä kuvien välimuisti + Kappaleiden välimuistin enimmäiskoko + Tyhjennä kappaleiden välimuisti + käytetty + Haluatko varmasti tyhjentää kaiken välimuistin? + Haluatko varmasti tyhjentää kaikki lataukset? + Haluatko varmasti tyhjentää kaikki välimuistissa olevat kappaleet? + Haluatko varmasti tyhjentää kaikki välimuistissa olevat kuvat? + Haluatko varmasti tyhjentää kaikki välimuistissa olevat käännösmallit? + + Yksityisyys + Kuunteluhistoria + Keskeytä kuunteluhistoria + Tyhjennä kuunteluhistoria + Haluatko varmasti tyhjentää koko kuunteluhistorian? + Keskeytä hakuhistoria + Tyhjennä hakuhistoria + Haluatko varmasti tyhjentää koko hakuhistorian? + Hakuhistoria + Käytä kirjautumista sisällön selaamiseen + Tämä voi vaikuttaa näkemääsi sisältöön ja näyttää esimerkiksi vain Premium-albumeita, jos olet kirjautunut sisään Premium-tilillä. + Poista kuvakaappaukset käytöstä + Kun tämä asetus on päällä, kuvakaappaukset ja sovelluksen näkymä Viimeisimmät-valikossa poistetaan käytöstä. + Ota käyttöön LrcLib-sanoitusten tarjoaja + Ota käyttöön KuGou-sanoitusten tarjoaja + Oletussanoitusten tarjoaja + Lue moniriviset sanoitukset + Käsittele kaikkia synkronointipisteiden välisiä rivejä yhtenä sanoitustekstinä + Poista välilyönnit sanoitusten ympäriltä + Fonttikoko + Piilota selkeä sisältö + Ota ilmoitukset käyttöön + + Varmuuskopiointi ja palautus + Varmuuskopioi + Palauta + Tuo soittolista (m3u) + Soittolistan tuonti epäonnistui + Tuotu soittolista + Varmuuskopiointi onnistui + Varmuuskopion luonti epäonnistui + Varmuuskopion palautus epäonnistui + + Discord-integraatio + Loudly käyttää KizzyRPC-kirjastoa asettaakseen Discord-tilisi tilan. Tämä edellyttää Discord Gateway -yhteyden käyttöä, jota voidaan pitää Discordin käyttöehtojen rikkomuksena. Käyttäjätilien jäädyttämisestä tästä syystä ei kuitenkaan ole tiedossa tapauksia. Käytä omalla vastuullasi.\n\nLoudly poimii vain tunnuksesi, ja kaikki muu tallennetaan paikallisesti. + Hylkää + Vaihtoehdot + Esikatselu + Kirjautuminen epäonnistui + Kirjaudu ulos + Ota Rich Presence käyttöön + + Tietoja + Sovelluksen versio + Tarkistetaan päivityksiä... + versio %1$s on ladattavissa. + Ladataan... + Päivitä + Yritä uudelleen + Lataa nyt + Sovelluksen tyyppi + + Uusi versio saatavilla + Käännösmallit + Tyhjennä käännösmallit + Historian kesto + + 1 sekunti + %d sekuntia + + Musiikki 3x2 + Musiikki 2x1 + Toista/Tauko + Versio · Kehittäjät · Lisenssi + Käyttöliittymä + Sisältö ja tili + Soitin + Järjestelmä + Kirjasto + Integraatiot + Järjestelmäintegraatio + Yhteensä käytetty tila + Tällä hetkellä käytössä + Tyhjennä välimuisti + Varmuuskopiointi · Palautus · Tuonti + Historia · Haku · Kuvakaappaus + Lataukset · Välimuisti · Kuvat + Ääni · Jono · Sanoitukset + Kieli · Sijainti · Välityspalvelin + Kirjaudu sisään · Spotify · Discord + Teema · Liukusäädin · Soitin + + Profiilikortin kuva + Mukauta profiilikortin kuvaa + Valitse profiilikuva + Valmiit kuvat + Valitse galleriasta + Muokkaa kuvaa + Peruuta + + Lähetä kehittäjälle + Sulje + Näytä virheen tiedot + Lataus epäonnistui + Näytä virhe + Manuaalinen päivitys + Tarkista lisätiedot asetus-sivulta + Ohjelmisto on ajan tasalla + + Sovelluksen käyttöliittymä + Valitse yksi kolmesta eri suunnittelusta + Käyttöliittymän version valinta + Sovelluksen vanha versio. (Sovellus, soitin, minisoitin) + Kompakti muotoilu, joka on luotu modernimmalla lähestymistavalla. (Sovellus, soitin, minisoitin) + Suosituin muotoilu. (Sovellus, soitin, minisoitin) + Luo oma sovelluksen käyttöliittymäsi. (Sovellus, soitin, minisoitin) + Mukauta käyttöliittymää + + Muut asetukset + Napsauta nähdäksesi muita erilaisia asetuksia + + Säätää laatua automaattisesti 45 ja 512 Kbps välillä + Tarjoaa korkeimman laadun jopa 512 Kbps + Asettaa laadun keskimääräisille internet-nopeuksille 128 ja 256 Kbps välillä. + Rajoittaa laadun 45 ja 52 Kbps välille datan säästämiseksi (Datansäästö) + + Tuonti epäonnistui + + Tekstin tasaus + Aseta tekstin sijainti kortilla + Liukuvärin voimakkuus + Kulman pyöristys + Profiilikuva + Profiilikortti + Poista profiilikuva + Näytä tekstit + Näytä tervehdys- ja käyttäjänimiteksti profiilikortilla + Vaihda napauttamalla + + Käännä (BETA) + Kopioi + + Verkkovirhe + Tarkista internetyhteytesi ja yritä uudelleen. + Tukematon sisältö + Tätä kappaleformaattia ei tueta laitteellasi. + Suoran lähetyksen virhe + Suorassa lähetyksessä on ongelma. Käynnistä uudelleen. + Toisto epäonnistui + Tapahtui tuntematon virhe. Yritä uudelleen. + Yritetään uudelleen… + VPN:n käyttö voi vaikuttaa yhteytesi laatuun. Jos sinulla on verkko-ongelmia, katkaise VPN-yhteys tai käytä välityspalvelinta. Voit määrittää tämän kohdassa ”Asetukset >> Sisältö >> Välityspalvelin”. + + Paikalliset kappaleet synkronoitu onnistuneesti + Paikallinen skanneri + + Skannaa automaattisesti + Skannaa kappaleet automaattisesti sovelluksen avaamisen yhteydessä + + Skannerin lisäasetukset + Tarkat tiedostonimet + Kun tämä on käytössä, tiedostopäätteitä EI ohiteta. Esim. "Song.ogg" on eri kappale kuin "Song.flac" + Määritä skannerin herkkyys + Vastaa nimeä + Vastaa nimeä ja artisteja + Vastaa nimeä, artisteja, albumeja + + Kansiot + Tasoita alikansiot + Poista käytöstä säilyttääksesi tiedostorakenteen sellaisena kuin se on levyllä + + Vinkit ja ominaisuudet + Vinkit · Ominaisuudet + Luo tarinasi + Jaa kuuntelemasi kappale sosiaalisessa mediassa tyylikkäällä suunnittelulla, joka on luotu albumin kannen hallitsevilla väreillä. + Mukautettavat asetukset + Tee sovelluksesta omasi korvaamalla asetusnäytön otsikkokortin tausta kuvalla galleriastasi. + Uusi kappalelista + Tarkastele nykyistä soittolistaasi ja vaihda helposti kappaleiden välillä pyyhkäisemällä ylös soittimen näytöltä (uusi suunnittelu, joka on yksinomaan Player UI 2.0:ssa) + Uudistettu asetusnäkymä + Löydä etsimäsi helposti uudella korttipohjaisella asetusnäkymällä, joka on modernimpi ja kätevämpi. + Klassinen musiikki-widget + Pääset nopeasti käsiksi kaikkiin säätimiin ja kappaletietoihin aloitusnäytöltäsi. + Minimalistinen musiikki-widget + ”Quik”-musiikki-widget, joka on saanut inspiraationsa Android 16:n Pika-asetukset-painikkeen suunnittelusta + Jaa tarinassa + Kopioi kappaleen linkki + Linkki kopioitu leikepöydälle! + Striimattu Loudlyssa + + Valitse kaikki + Haetaan kaikkia soittolistoja… + Tuo valitut kohteet + Jotain meni pieleen. Olemme yhtä ymmällämme kuin sinäkin. + No, onpa noloa. Meilläkään ei ole aavistustakaan, mitä tapahtui. + Ohjeet vaadittujen tunnusten saamiseksi + 3. Varmista, että napsautat "Lisää", kun olet syöttänyt yllä olevan paikallisen polun. + 5. Hyväksy käyttöehdot ja napsauta "Tallenna". + Sinut ohjataan uudelle sivulle. Napsauta Asetukset, kopioi sitten asiakastunnus ja asiakassalaisuus ja liitä ne alla oleviin tekstikenttiin tässä sovelluksessa. + 2. Syötä tarvittavat tiedot ja käytä http://127.0.0.1:45454 uudelleenohjaus-URI-osoitteena. + 4. Vaihda Web API:n tilaa. Valintaruudussa pitäisi nyt olla rasti. + Napsauta nyt alla olevaa Valtuuta ja jatka -painiketta ja kirjaudu sisään tililläsi, josta haluat tuoda, ja valtuuta ja jatka, mikä ohjaa sinut sivustolle, jonka pitäisi alkaa http://127.0.0.1:45454/?code=\n\nLiitä koko URL-osoite alla olevaan tekstikenttään, jossa lukee Valtuuskoodi, ja napsauta Tunnistaudu-painiketta + 1. Vieraile Spotifyn kehittäjien hallintapaneelissa ja napsauta "Luo sovellus". + Kirjaudu sisään Spotify API -tunnuksilla + Valtuuta ja jatka + Valtuuskoodi + Tunnistaudu + Asiakassalaisuus + Asiakastunnus + Tuonti käynnissä… + Älä sulje sovellusta tai mene takaisin. Tämä toiminto ei suoriteta taustalla, joten pysy paikallasi, kunnes se on valmis!\nÄLÄ PANIROI, JOS SE NÄYTTÄÄ JUMITTUNEEN; joskus nouto voi viedä jonkin aikaa. + Valitse "Pidettyjen kappaleiden" kohde + Mihin pidetyt kappaleet pitäisi tuoda? + Uusi soittolista nimeltä "Pidettyt kappaleet" + Oletusarvoiseen "Pidettyihin kappaleisiin" + \ No newline at end of file diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml deleted file mode 100644 index a55fcf2..0000000 --- a/app/src/main/res/values-fil/strings.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - AirNote - Mga Tala - Bagong Tala - Walang mga talang nalikha. - Walang nahanap na mga tala. - Oras ng Paglikha - Grid view - Column view - Mga Salita - Mga Karakter - Pangalan - Deskripsyon - Piliin Lahat - Tanggalin - Impormasyon - Hanapin - Mga Setting - Display - Tema ng System - Madilim na Tema - Dinamikong Kulay - Mga Kulay na Amoled - Database - Backup - Hindi pa suportado - Tungkol sa - Bersyon - Pinakabagong Release - Bigyan ng Bituin ang Proyekto - Mag-donate sa akin :) - Ibalik - Mga Tala - Suporta - - Wika - Markdown - Mga Kagamitan - Kasaysayan - Mga Widget - Mga Tema • Laki • Font - Cloud - Font • Wika - NextCloud • Lokal • Enkripsyon - Bersyon • Mga Developer • Lisensya - Mga Widget • Markdown - Kasaysayan • Ibalik - Nalikha • Tanggalin Lahat - Suporta • Mga Larawan - Ipakita ang iyong pagmamahal - Font - Radius - Piliin ang Radius - Minimalistic na Mode - Extreme na mga Kulay na Amoled - Privacy - Proteksyon ng Screen - Suporta sa mga Larawan - Hindi Matatag - Cryptocurrency - Iniaangkop ang tema ng app batay sa mga setting ng system - Magpalit sa pagitan ng madilim at maliwanag na mga scheme ng kulay - Inaayos ang mga kulay upang tumugma sa iyong wallpaper - Gamitin ang itim na kulay para sa background - Extreme na Pag-optimize ng Baterya - I-customize ang radius ng border - Piliin ang istilo ng view - Alisin ang mga itaas na button mula sa edit screen - I-imbak ang lahat ng mga tala sa file - Ibalik ang mga tala mula sa backup file - Paganahin ang markdown sa mga tala - Itago ang screen sa mga kamakailan at huwag paganahin ang mga screenshot - Uri ng Build - Source Code - Listahan ng mga Sumusuporta - Email - Discord - Ulat ng Bug at Higit Pa - Naka-pin - Iba pa - Kopyahin - I-encrypt ang Database - I-encrypt ang database habang nagba-backup - Ilagay ang password - Hindi wasto ang password o hindi naka-encrypt ang database - Hindi wastong input - Pagsunod-sunurin nang Pataas - Pagsunod-sunurin nang Pababa - Pagsunod-sunurin ang mga tala ayon sa oras ng paglikha - Pumili ng Tala - Hindi Suportadong Laki ng Larawan (15mb max) - Vault - Paganahin ang naka-encrypt na vault ng mga tala - magpatuloy - Baguhin ang wika ng app - Wika ng System - Gawi - Unahin ang Editing Mode - Pumasok sa edit mode sa mga umiiral na tala - Hindi Wastong Password - Mag-donate - Pagsasalin - Kontribusyon - Tinanggal ang mga larawan mula sa storage ng app kapag inalis ang mga ito sa gallery - Pag-sync ng Gallery - Ipakita lamang ang pamagat - Ipakita lamang ang pamagat sa preview - 1. Pagtanggap ng mga Tuntunin - Sa pagpindot sa "Sumang-ayon" at paggamit ng Easy Notes, tinatanggap mo ang mga Tuntunin ng Serbisyo na ito. Kung hindi ka sumasang-ayon, huwag gamitin ang application. - 2. Lisensya - Ang AirNote (Easy Notes) ay ipinamamahagi sa ilalim ng **GNU General Public License (GPL) version 3**. Nangangahulugan ito na:\n\n- Malaya kang gamitin, baguhin, at ibahagi ang application.\n- Kung ipamamahagi mo ang application o anumang binagong bersyon, dapat mo ring ipamahagi ang source code at panatilihin ang lisensya ng GPL. - 3. Paggamit ng Application - Sumasang-ayon kang gamitin ang AirNote (Easy Notes) para lamang sa mga legal na layunin at alinsunod sa mga Tuntuning ito. **Hindi** mo dapat:\n\n- _Gamitin ang app para sa anumang labag sa batas na layunin_\n- _Lumabag sa mga karapatan ng iba_\n- _Gulohin ang paggana o seguridad ng app_ - 4. Intelektwal na Ari-arian - Lahat ng mga karapatan sa intelektwal na ari-arian sa AirNote (Easy Notes) ay pag-aari namin o ng aming mga licensor. Ang iyong paggamit ng app ay hindi nagbibigay sa iyo ng pagmamay-ari ng anumang mga karapatan sa intelektwal na ari-arian. - 5. Pagtatatuwa ng mga Warranty - Ang Easy Notes ay ibinibigay "nang walang pagbabago," nang walang anumang mga warranty. Hindi namin ginagarantiyahan na ang app ay magiging malaya sa mga error, ligtas, o walang patid. - 6. Limitasyon ng Pananagutan - Hindi kami mananagot para sa anumang mga pinsala na nagreresulta mula sa iyong paggamit ng AirNote (Easy Notes), kabilang ang hindi direkta, incidental, o consequential na mga pinsala. - 7. Pagwawakas - Maaari naming wakasan o suspindihin ang iyong pag-access sa AirNote (Easy Notes) kung lalabag ka sa mga Tuntuning ito. Sa pagwawakas, ang iyong karapatang gamitin ang app ay agad na magwawakas. - 8. Mga Pagbabago sa mga Tuntunin - Maaari naming i-update ang mga Tuntunin ng Serbisyo na ito paminsan-minsan. Ang iyong patuloy na paggamit ng AirNote (Easy Notes) pagkatapos ng anumang mga pagbabago ay nangangahulugan ng pagtanggap sa mga binagong tuntunin. - 9. Makipag-ugnayan sa Amin - Para sa anumang mga katanungan tungkol sa mga Tuntunin ng Serbisyo na ito o sa AirNote (Easy Notes), mangyaring makipag-ugnayan sa amin sa - 10. Patakaran sa Privacy - Ang AirNote (Easy Notes) ay hindi nangongolekta ng anumang personal na data. Para sa karagdagang impormasyon, mangyaring sumangguni sa aming Patakaran sa Privacy na magagamit sa - Petsa ng Pagkabisa - Sumang-ayon - Mga Tuntunin ng Serbisyo - Hindi ma-import - Nabigo ang pag-import ng ilang mga file - Matagumpay na na-import - I-import - I-import ang ibang mga text file bilang mga tala - Gamitin ang monospace font habang nag-e-edit ng tala - Monospace na font - Sigurado ka bang gusto mong tanggalin ang mga napiling tala - Oo - Kanselahin - Lock ng app - I-lock ang app gamit ang password - I-setup ang passcode - Ilagay ang Passcode - Pin (6 na numero) - Passcode - Fingerprint - Kinakailangan ang pagpapatunay - ay Naka-lock - UID - Humingi ng fingerprint sa pagsisimula - Humingi ng passcode sa pagsisimula - Pattern - Humingi ng pattern sa pagsisimula - I-setup ang pattern - Ilagay ang pattern - I-lock Agad - I-lock agad kapag ipinagpatuloy ang app - Huwag paganahin ang pag-swipe - "Huwag paganahin ang pag-swipe upang mag-preview sa edit mode" - I-scale ang Lapad ng Search Bar - Gawing tumugma ang lapad ng search bar sa lapad ng mga tala - Laki ng Font - Baguhin ang laki ng teksto sa mga tala - Laki ng Font: %1$d - Preview - Ito ang magiging hitsura ng iyong mga tala sa napiling laki ng font. - Tagapagtatag ng Babel Software at developer ng Android - Magdagdag ng bagong folder - Pangalan ng folder - Kulay ng folder - Idagdag - Ilipat sa Folder - Walang available na folder - Tanggalin ang folder - Sigurado ka bang gusto mong tanggalin ang folder na ‘%1$s’? Ang mga tala dito ay ililipat sa seksyong ‘Lahat ng mga Tala’. - I-edit ang folder - I-save - Tag ng folder - Ipinapakita nito ang label ng folder kung saan nabibilang ang mga tala. - Mga Setting ng AirNote AI - I-configure ang AI · Mga setting ng API - Gamitin ang AirNote API - Gamitin ang mga tampok ng AI gamit ang built-in na API ng app (Inirerekomenda) - Ang iyong API key (BETA) - Suriin - Modelong Gagamitin - Pagpili ng Modelo (BETA) - Mungkahi ng AI - Isara - Lahat ng mga Tala - Magtanong, makipag-chat, maghanap gamit ang AI... - Paano Kumuha ng Iyong Sariling API Key? - Mula sa Google AI Studio, maaari kang bumuo ng isang personal na API key na may malaking libreng limitasyon sa paggamit. Ang key na ito ay para lamang sa iyo at mayroon kang ganap na kontrol. - 1. Pumunta sa website ng Google AI Studio sa pamamagitan ng pag-click sa button sa ibaba. - 2. Mag-sign in gamit ang iyong Google account. - 3. Sa screen na magbubukas, i-click ang button na \"Lumikha ng API key\". - 4. Maaaring hilingin sa iyo na lumikha ng isang bagong proyekto, sundin ang mga hakbang upang likhain ito. - 5. Kopyahin ang nabuong mahabang string ng mga karakter. - 6. I-paste ang kinopyang key sa text box sa ibaba at pindutin ang button na \“Suriin\”. - Pumunta sa Google AI Studio - Mangyaring maglagay muna ng API key. - Wasto at matagumpay na nairehistro ang API key! ✅ - Hindi wastong API key. Mangyaring suriin at subukang muli. - Magtanong - Gumawa ng tala tungkol sa isang paksa - Bumuo ng mga ideya - Bigyan mo ako ng 5 kawili-wiling ideya sa isang random na paksa - Bagong Bersyon - I-update - May available na bagong update! - I-update ang App - Hindi makuha ang log ng pagbabago. - Nabigong matanggap ang changelog: %1$s - Sinusuri ang mga update... - Available ang Update - May Available na Bagong Bersyon! - Ang bersyon %1$s ay available para sa pag-download. - I-update Ngayon - I-download - Nagda-download... %1$d%% - I-install - I-install - Nabigo ang Update - Error - Subukang Muli - Subukang Muli - Ang Iyong App ay Napapanahon - Na-update - Nangangailangan ang update ng pahintulot upang mag-install mula sa mga hindi kilalang source. - Hindi nahanap ang link sa pag-download. - Mga Inobasyon - I-save ang Tala - Muling itayo - - I-optimize ang Pagsulat - Ibuod - Gawing Mas Maikli - Gawing Mas Mahaba - Baguhin ang Tono - - Pormal - Balanseng - Palakaibigan - Hindi nahanap ang API key ng user. Mangyaring suriin sa mga setting. - Nabigo ang kahilingan sa API: %1$s - Kamusta! Paano kita matutulungan? - MGA MUNGKAHI - Ilista ang mga Bentahe at Disbentahe - Lumikha ng Listahan ng Gagawin - Sabihin Ito nang Mas Simple - Inirerekomenda para sa mga na-import na file - Magmungkahi ng Pamagat - - Magaling, tungkol saan ang gagawin nating draft? Pakisulat ang paksa. - - Itama ang mga pagkakamali sa gramatika at ekspresyon sa teksto sa ibaba upang gawin itong mas matatas. Sagutin lamang ang naitama na teksto:\n\n---\n\n%1$s - Ibuod ang teksto sa ibaba sa isa o dalawang pangungusap, na pinapanatili ang pangunahing ideya. Sagutin lamang ang buod na teksto:\n\n---\n\n%1$s - Paikliin nang malaki ang sumusunod na teksto habang pinapanatili ang kahulugan nito. Sagutin lamang gamit ang pinaikling teksto:\n\n---\n\n%1$s - Magdagdag ng mga malikhaing detalye sa teksto sa ibaba upang gawin itong mas mahaba. Sagutin lamang gamit ang pinalawak na teksto:\n\n---\n\n%1$s - Ang mga AI server ay kasalukuyang abala. Pakisubukang muli sa loob ng ilang minuto. - - %1$s\n\n---\n\n%2$s - Isulat muli ang teksto sa ibaba sa isang propesyonal at pormal na wika. Panatilihin ang kahulugan, baguhin lamang ang tono. Sagutin lamang gamit ang huling teksto: - Isulat muli ang teksto sa ibaba sa isang balanseng wika, hindi masyadong pormal o masyadong taos-puso. Panatilihin ang kahulugan. Sagutin lamang gamit ang konklusyon: - Isulat muli ang teksto sa ibaba sa isang mas taos-puso, mainit, pang-usap at palakaibigang paraan. Panatilihin ang kahulugan. Sagutin lamang gamit ang konklusyon: - - Ang isang gumagamit ng note-taking app ay gumagawa sa isang tala na may pamagat na \"%1$s\". Magmungkahi ng ilang mga malikhain at nagbibigay-inspirasyon na mga ideya o panimulang punto na akma sa pamagat na ito. Panatilihing maikli at nasa format ng listahan ang iyong sagot. - Ipagpatuloy ang teksto sa ibaba, na pinapanatili ang integridad ng kahulugan at istilo ng may-akda. Sagutin lamang ang bagong teksto, huwag ulitin ang umiiral na teksto:\n\n---\n\n\nPamagat: %1$s\n\nNilalaman:\n%2$s - Suriin ang teksto sa ibaba at magsulat ng isang talata kung paano lapitan ang paksang ito mula sa isang ganap na naiibang pananaw. Huwag ibuod ang umiiral na teksto, mag-alok ng isang alternatibong pananaw:\n\n---\n\n\nPamagat: %1$s\n\nNilalaman:\n%2$s - Suriin ang teksto sa ibaba at ilista ang mga positibo at negatibong puntos sa ilalim ng mga heading na \'### Mga Bentahe\' at \'### Mga Disbentahe\'. Sagutin lamang ang dalawang heading at mga item na ito:\n\n\n---\n\n\n\n%1$s - Suriin ang teksto sa ibaba. Kung binabanggit sa teksto ang mga gawaing dapat gawin sa araw, gawin itong isang listahan ng gagawin sa pamamagitan ng paglalagay ng \'[] \' sa simula ng bawat linya. Kung hindi binabanggit sa teksto ang mga gawain, sagutin LAMANG ang \'NO_TASKS\' nang walang pag-type ng anupaman. Alamin ang wika ng teksto at lumikha ng listahan sa wikang iyon:\n\n---\n\n%1$s - Isalin ang kumplikadong teksto sa ibaba sa simple at matatas na wika na mauunawaan ng lahat. Panatilihin ang pangunahing ideya at mahahalagang detalye ng teksto. Sagutin lamang ang pinasimpleng teksto:\n\n---\n\n%1$s - Magmungkahi ng 5 malikhain at nakakaakit-pansin na mga pamagat na pinaka-akma sa nilalaman ng teksto sa ibaba. Ibigay ang iyong sagot bilang isang may bilang na listahan:\n\n---\n\n%1$s - - Nais ng isang user na mag-draft ng isang tala tungkol sa paksang \"%1$s\". DAPAT mong ibigay ang iyong sagot sa sumusunod na format: PAMAGAT: [ilagay ang pamagat dito]\n\nNILALAMAN: [ilagay ang nilalaman ng tala dito] - \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml deleted file mode 100644 index 5b1c355..0000000 --- a/app/src/main/res/values-fr/strings.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - AirNote - Notes - Nouvelle note - Aucune note créée. - Aucune note trouvée. - Date de création - Vue en grille - Vue en colonne - Mots - Caractères - Nom - Description - Tout sélectionner - Supprimer - Informations - Rechercher - Paramètres - Affichage - Thème du système - Thème sombre - Couleurs dynamiques - Couleurs Amoled - Base de données - Sauvegarde - Pas encore pris en charge - À propos - Version - Dernière version - Mettre une étoile au projet - Faites-moi un don :) - Restaurer - Notes - Soutien - - Langue - Markdown - Outils - Historique - Widgets - Thèmes • Taille • Police - Cloud - Police • Langue - NextCloud • Local • Chiffrement - Version • Développeurs • Licence - Widgets • Markdown - Historique • Rétablir - Créé • Tout supprimer - Support • Images - Montrez votre amour - Police - Rayon - Sélectionner le rayon - Mode minimaliste - Couleurs Amoled extrêmes - Confidentialité - Protection de l\'écran - Prise en charge des images - Instable - Cryptomonnaie - Adapte le thème de l\'application en fonction des paramètres du système - Basculez entre les schémas de couleurs sombres et clairs - Ajuste les couleurs pour correspondre à votre fond d\'écran - Utiliser la couleur noire pour l\'arrière-plan - Optimisation extrême de la batterie - Personnaliser le rayon des bordures - Sélectionner le style d\'affichage - Supprimer les boutons supérieurs de l\'écran d\'édition - Stocker toutes les notes dans un fichier - Restaurer les notes à partir d\'un fichier de sauvegarde - Activer le markdown dans les notes - Masquer l\'écran dans les applications récentes et désactiver les captures d\'écran - Type de build - Code source - Liste des supporters - E-mail - Discord - Rapport de bug et plus - Épinglé - Autres - Copier - Chiffrer la base de données - Chiffrer la base de données lors de la sauvegarde - Entrez le mot de passe - Le mot de passe est invalide ou la base de données n\'est pas chiffrée - Entrée invalide - Tri croissant - Tri décroissant - Trier les notes par leur date de création - Sélectionner une note - Taille d\'image non prise en charge (15 Mo max) - Coffre-fort - Activer le coffre-fort de notes chiffrées - continuer - Changer la langue de l\'application - Langue du système - Comportement - Prioriser le mode d\'édition - Entrer en mode édition sur les notes existantes - Mot de passe invalide - Faire un don - Traduction - Contribution - Supprime les images du stockage de l\'application lorsqu\'elles sont retirées de la galerie - Synchronisation de la galerie - Afficher uniquement le titre - Afficher uniquement le titre dans l\'aperçu - 1. Acceptation des conditions - En appuyant sur "Accepter" et en utilisant Easy Notes, vous acceptez ces Conditions d\'utilisation. Si vous n\'êtes pas d\'accord, n\'utilisez pas l\'application. - 2. Licence - AirNote (Easy Notes) est distribué sous la **Licence publique générale GNU (GPL) version 3**. Cela signifie que :\n\n- Vous êtes libre d\'utiliser, de modifier et de partager l\'application.\n- Si vous distribuez l\'application ou des versions modifiées, vous devez également distribuer le code source et conserver la licence GPL. - 3. Utilisation de l\'application - Vous acceptez d\'utiliser AirNote (Easy Notes) uniquement à des fins légales et conformément à ces Conditions. Vous ne devez **pas** :\n\n- _Utiliser l\'application à des fins illégales_\n- _Violer les droits d\'autrui_\n- _Perturber la fonctionnalité ou la sécurité de l\'application_ - 4. Propriété intellectuelle - Tous les droits de propriété intellectuelle sur AirNote (Easy Notes) sont détenus par nous ou nos concédants de licence. Votre utilisation de l\'application ne vous confère aucun droit de propriété intellectuelle. - 5. Exclusion de garanties - Easy Notes est fourni "en l\'état", sans aucune garantie. Nous ne garantissons pas que l\'application sera exempte d\'erreurs, sécurisée ou ininterrompue. - 6. Limitation de responsabilité - Nous ne sommes pas responsables des dommages résultant de votre utilisation de AirNote (Easy Notes), y compris les dommages indirects, accessoires ou consécutifs. - 7. Résiliation - Nous pouvons résilier ou suspendre votre accès à AirNote (Easy Notes) si vous ne respectez pas ces Conditions. À la résiliation, votre droit d\'utiliser l\'application cessera immédiatement. - 8. Modifications des conditions - Nous pouvons mettre à jour ces Conditions d\'utilisation de temps à autre. Votre utilisation continue de AirNote (Easy Notes) après toute modification constitue une acceptation des conditions révisées. - 9. Contactez-nous - Pour toute question concernant ces Conditions d\'utilisation ou AirNote (Easy Notes), veuillez nous contacter à - 10. Politique de confidentialité - AirNote (Easy Notes) ne collecte aucune donnée personnelle. Pour plus d\'informations, veuillez consulter notre Politique de confidentialité disponible à - Date d\'entrée en vigueur - Accepter - Conditions d\'utilisation - Impossible d\'importer - L\'importation de certains fichiers a échoué - Importation réussie - Importer - Importer d\'autres fichiers texte comme des notes - Utiliser une police à espacement fixe lors de l\'édition d\'une note - Police à espacement fixe - Êtes-vous sûr de vouloir supprimer les notes sélectionnées ? - Oui - Annuler - Verrouillage de l\'application - Verrouiller l\'application avec un mot de passe - Configurer le code d\'accès - Entrez le code d\'accès - PIN (6 chiffres) - Code d\'accès - Empreinte digitale - Authentification requise - est verrouillé - UID - Demander l\'empreinte digitale au démarrage - Demander le code d\'accès au démarrage - Schéma - Demander le schéma au démarrage - Configurer le schéma - Entrez le schéma - Verrouiller immédiatement - Verrouiller instantanément lorsque l\'application est reprise - Désactiver le balayage - "Désactiver le balayage pour prévisualiser en mode édition" - Ajuster la largeur de la barre de recherche - Faire correspondre la largeur de la barre de recherche à celle des notes - Taille de la police - Changer la taille du texte dans les notes - Taille de la police : %1$d - Aperçu - Voici à quoi ressembleront vos notes avec la taille de police sélectionnée. - Fondateur de Babel Software et développeur Android - Ajouter un nouveau dossier - Nom du dossier - Couleur du dossier - Ajouter - Déplacer vers le dossier - Aucun dossier disponible - Supprimer le dossier - Êtes-vous sûr de vouloir supprimer le dossier ‘%1$s’ ? Les notes qu\'il contient seront déplacées dans la section ‘Toutes les notes’. - Modifier le dossier - Enregistrer - Étiquette du dossier - Affiche l\'étiquette du dossier auquel les notes appartiennent. - Paramètres IA d\'AirNote - Configurer l\'IA · Paramètres de l\'API - Utiliser l\'API d\'AirNote - Utiliser les fonctionnalités d\'IA avec l\'API intégrée de l\'application (Recommandé) - Votre clé API (BÊTA) - Vérifier - Modèle à utiliser - Choix du modèle (BÊTA) - Suggestion de l\'IA - Fermer - Toutes les notes - Demandez, discutez, trouvez avec l\'IA... - Comment obtenir votre propre clé API ? - Depuis Google AI Studio, vous pouvez générer une clé API personnelle avec une limite d\'utilisation gratuite généreuse. Cette clé n\'appartient qu\'à vous et vous en avez le contrôle total. - 1. Allez sur le site web de Google AI Studio en cliquant sur le bouton ci-dessous. - 2. Connectez-vous avec votre compte Google. - 3. Sur l\'écran qui s\'ouvre, cliquez sur le bouton \"Créer une clé API\". - 4. Il se peut qu\'on vous demande de créer un nouveau projet, suivez les étapes pour le créer. - 5. Copiez la longue chaîne de caractères générée. - 6. Collez la clé copiée dans la zone de texte ci-dessous et appuyez sur le bouton \"Vérifier\". - Aller à Google AI Studio - Veuillez d\'abord entrer une clé API. - Clé API valide et enregistrée avec succès ! ✅ - Clé API invalide. Veuillez vérifier et réessayer. - Poser une question - Prendre une note sur un sujet - Générer des idées - Donnez-moi 5 idées intéressantes sur un sujet aléatoire - Nouvelle version - Mettre à jour - Nouvelle mise à jour disponible ! - Mettre à jour l\'application - Le journal des modifications n\'a pas pu être récupéré. - Échec de la réception du journal des modifications : %1$s - Vérification des mises à jour... - Mise à jour disponible - Nouvelle version disponible ! - La version %1$s est disponible au téléchargement. - Mettre à jour maintenant - Télécharger - Téléchargement... %1$d%% - Installer - Installer - Échec de la mise à jour - Erreur - Réessayer - Réessayer - Votre application est à jour - Mis à jour - La mise à jour nécessite l\'autorisation d\'installer depuis des sources inconnues. - Lien de téléchargement introuvable. - Nouveautés - Sauvegarder la note - Reconstruction - - Optimiser l\'écriture - Résumer - Raccourcir - Allonger - Changer le ton - - Officiel - Équilibré - Amical - Clé API utilisateur non trouvée. Veuillez vérifier dans les paramètres. - Échec de la requête API : %1$s - Bonjour ! Comment puis-je vous aider ? - SUGGESTIONS - Lister les avantages et les inconvénients - Créer une liste de tâches - Expliquer plus simplement - Recommandé pour les fichiers importés - Suggérer un titre - - Parfait, sur quel sujet devons-nous créer une ébauche ? Veuillez écrire le sujet. - - Corrigez les erreurs de grammaire et d\'expression dans le texte ci-dessous pour le rendre plus fluide. Ne répondez que le texte corrigé :\n\n---\n\n%1$s - Résumez le texte ci-dessous en une ou deux phrases, en gardant l\'idée principale. Ne répondez que le texte du résumé :\n\n---\n\n%1$s - Raccourcissez considérablement le texte suivant tout en préservant son sens. Ne répondez qu\'avec le texte raccourci :\n\n---\n\n%1$s - Ajoutez des détails créatifs au texte ci-dessous pour le rendre plus long. Ne répondez qu\'avec le texte étendu :\n\n---\n\n%1$s - Les serveurs d\'IA sont actuellement occupés. Veuillez réessayer dans quelques minutes. - - %1$s\n\n---\n\n%2$s - Réécrivez le texte ci-dessous dans un langage professionnel et formel. Gardez le sens, ne changez que le ton. Ne répondez qu\'avec le texte final : - Réécrivez le texte ci-dessous dans un langage équilibré, ni trop formel ni trop sincère. Gardez le sens. Ne répondez qu\'avec la conclusion : - Réécrivez le texte ci-dessous de manière plus sincère, chaleureuse, conversationnelle et amicale. Gardez le sens. Ne répondez qu\'avec la conclusion : - - Un utilisateur d\'une application de prise de notes travaille sur une note intitulée \"%1$s\". Suggérez des idées créatives et inspirantes ou des points de départ qui correspondent à ce titre. Gardez votre réponse courte et sous forme de liste. - Continuez le texte ci-dessous, en gardant l\'intégrité du sens et le style de l\'auteur. Ne répondez qu\'au nouveau texte, ne répétez pas le texte existant :\n\n---\n\n\nTitre : %1$s\n\nContenu :\n%2$s - Analysez le texte ci-dessous et écrivez un paragraphe sur la façon d\'aborder ce sujet sous un angle complètement différent. Ne résumez pas le texte existant, proposez une perspective alternative :\n\n---\n\n\nTitre : %1$s\n\nContenu :\n%2$s - Analysez le texte ci-dessous et listez les points positifs et négatifs sous les rubriques \'### Avantages\' et \'### Inconvénients\'. Ne répondez qu\'à ces deux rubriques et éléments :\n\n\n---\n\n\n\n%1$s - Analysez le texte ci-dessous. Si le texte mentionne des tâches à effectuer pendant la journée, transformez-les en une liste de tâches en mettant \'[] \' au début de chaque ligne. Si le texte ne mentionne pas de tâches, ne répondez QUE \'NO_TASKS\' sans taper autre chose. Détectez la langue du texte et créez la liste dans cette langue :\n\n---\n\n%1$s - Traduisez le texte complexe ci-dessous en un langage simple et fluide que tout le monde peut comprendre. Gardez l\'idée principale et les détails importants du texte. Ne répondez que le texte simplifié :\n\n---\n\n%1$s - Suggérez 5 titres créatifs et accrocheurs qui correspondent le mieux au contenu du texte ci-dessous. Donnez votre réponse sous forme de liste numérotée :\n\n---\n\n%1$s - - Un utilisateur souhaite rédiger une ébauche de note sur le sujet \"%1$s\". Vous DEVEZ donner votre réponse au format suivant : TITRE : [insérer le titre ici]\n\nCONTENU : [insérer le contenu de la note ici] - \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml deleted file mode 100644 index bf493d5..0000000 --- a/app/src/main/res/values-hi/strings.xml +++ /dev/null @@ -1,154 +0,0 @@ - - - AirNote - नोट्स - नया नोट - काला थीम - डायनामिक रंग - पिन किए गए - अन्य - कॉपी करें - डेटाबेस कूटलेखित करें - आरोही छांटें - अवरोही छांटें - नोट्स को उनके निर्माण समय के अनुसार छांटें - कोई नोट्स नहीं मिले। - निर्मित समय - ग्रिड दृश्य - कॉलम दृश्य - शब्द - कोई नोट्स नहीं बनाए गए। - वर्ण - नाम - विवरण - सभी चुनें - मिटाएं - जानकारी - खोजें - सेटिंग - डिस्प्ले - सिस्टम थीम - एमोलेड रंग - डेटाबेस - बैकअप - अभी तक समर्थित नहीं - ऐप के बारे में - संस्करण - परियोजना को तारांकित करें - नवीनतम रिलीस - मुझे दान दें :) - पुनर्स्थापित करें - नोट्स - समर्थन - रंग एवं शैलियां - भाषा - मार्कडाउन - टूल्स - इतिहास - विजेट - थीम • आकार • फ़ॉन्ट - क्लाउड - फ़ॉन्ट • भाषा - NextCloud • स्थानीय • कूटलेखन - संस्करण • डेवलपर्स • लाइसेंस - विजेट • मार्कडाउन - इतिहास • पूर्ववत करें - निर्मित • सभी मिटाएं - सहायता • छवियां - अपना प्यार दिखाएं - अस्थिर - क्रिप्टोकरेंसी - सिस्टम सेटिंग के आधार पर ऐप थीम को अनुकूलित करता है - गहरे और हल्के रंग की योजनाओं के बीच बदलें - वॉलपेपर से मेल खाने के लिए रंगों को समायोजित करता है - पृष्ठभूमि के लिए काले रंग का उपयोग करें - अत्यधिक बैटरी अनुकूलन - सीमा त्रिज्या अनुकूलित करें - फ़ॉन्ट - त्रिज्या - त्रिज्या चुनें - न्यूनतम मोड - अत्यधिक एमोलेड रंग - निजता - स्क्रीन सुरक्षा - छवियां समर्थन - दृश्य शैली चुनें - संपादन स्क्रीन से शीर्ष बटन हटाएं - सभी नोट्स को फाइल में संग्रहीत करें - बैकअप फाइल से नोट पुनर्स्थापित करें - नोट्स में मार्कडाउन सक्षम करें - हाल ही में स्क्रीन छिपाएं और स्क्रीनशॉट अक्षम करें - बिल्ड प्रकार - सोर्स कोड - समर्थकों की सूची - ईमेल - डिस्कॉर्ड - ख़ासियत का अनुरोध - बैकअप के दौरान डेटाबेस कूटलेखित करें - पासवर्ड दर्ज करें - पासवर्ड अमान्य है या डेटाबेस कूटलेखित नहीं है - अमान्य इनपुट - कूटलेखित नोट तिजोरी सक्षम करें - नोट चुनें - असमर्थित छवि आकार (अधिकतम 15mb) - तिजोरी - जारी रखें - ऐप की भाषा बदलें - सिस्टम भाषा - व्यवहार - संपादन मोड को प्राथमिकता दें - मौजूदा नोट्स पर संपादन मोड में प्रवेश करें - अमान्य पासवर्ड - 1. शर्तों की स्वीकृति - सहमत बटन दबाकर और ईज़ी नोट्स का उपयोग करके, आप इन सेवा शर्तों को स्वीकार करते हैं। यदि आप सहमत नहीं हैं, तो एप्लिकेशन का उपयोग न करें। - 3. एप्लीकेशन का उपयोग - 4. बौद्धिक संपदा - 5. वारंटी का अस्वीकरण - ईज़ी नोट्स बिना किसी वारंटी के उपलब्ध कराया गया है। हम इस बात की गारंटी नहीं देते कि ऐप में कोई त्रुटि नहीं होगी, यह सुरक्षित होगा या इसमें कोई रुकावट नहीं आएगी। - 6. दायित्व की सीमा - 7. समाप्ति - 8. शर्तों में परिवर्तन - 9. हमसे संपर्क करें - इन सेवा की शर्तों या ईज़ी नोट्स के बारे में किसी भी प्रश्न के लिए, कृपया हमसे संपर्क करें - ईज़ी नोट्स कोई भी व्यक्तिगत डेटा एकत्र नहीं करता है। अधिक जानकारी के लिए, कृपया हमारी गोपनीयता नीति देखें जो यहां उपलब्ध है - प्रभावी तिथि - स्वीकारें - 10. गोपनीयता नीति - आयात नहीं किया जा सका - कुछ फाइलों का आयात विफल हुआ - सफलतापूर्वक आयातित - दान करें - अनुवाद - गैलरी से हटाए जाने पर ऐप स्टोरेज से छवियां हटा देता है - योगदान - गैलरी समन्वयन - केवल शीर्षक दिखाएं - पूर्वावलोकन में केवल शीर्षक दिखाएं - 2. लाइसेंस - ईज़ी नोट्स को **GNU जनरल पब्लिक लाइसेंस (GPL) संस्करण 3** के तहत वितरित किया जाता है। इसका मतलब है: -\n -\n- आप एप्लिकेशन का उपयोग करने, उसे संशोधित करने और साझा करने के लिए स्वतंत्र हैं। -\n- यदि आप एप्लिकेशन या कोई संशोधित संस्करण वितरित करते हैं, तो आपको स्रोत कोड भी वितरित करना होगा और GPL लाइसेंस बनाए रखना होगा। - आप ईज़ी नोट्स का उपयोग केवल मान्य उद्देश्यों के लिए और इन शर्तों के अनुसार करने के लिए सहमत हैं। आपको **नहीं** करना चाहिए: -\n -\n- _किसी भी गैरकानूनी उद्देश्य के लिए ऐप का उपयोग करें_ -\n- _दूसरों के अधिकारों का उल्लंघन करें_ -\n- _ऐप की कार्यक्षमता या सुरक्षा को बाधित करें_ - आपके द्वारा ईज़ी नोट्स के उपयोग से होने वाली किसी भी क्षति के लिए हम उत्तरदायी नहीं हैं, जिसमें अप्रत्यक्ष, आकस्मिक या परिणामी क्षति शामिल है। - ईज़ी नोट्स में सभी बौद्धिक संपदा अधिकार हमारे या हमारे लाइसेंसधारकों के स्वामित्व में हैं। ऐप का आपका उपयोग आपको किसी भी बौद्धिक संपदा अधिकार का स्वामित्व प्रदान नहीं करता है। - यदि आप इन शर्तों का उल्लंघन करते हैं तो हम ईज़ी नोट्स तक आपकी पहुंच को समाप्त या निलंबित कर सकते हैं। समाप्ति पर, ऐप का उपयोग करने का आपका अधिकार तुरंत समाप्त हो जाएगा। - हम समय-समय पर इन सेवा शर्तों को अपडेट कर सकते हैं। किसी भी बदलाव के बाद ईज़ी नोट्स का आपका निरंतर उपयोग संशोधित शर्तों की स्वीकृति माना जाएगा। - सेवा शर्तें - आयात करें - अन्य पाठ फाइलों को नोट्स के रूप में आयात करें - पासवर्ड का उपयोग करके ऐप लॉक करें - क्या आप सुनिश्चित हैं कि आप चयनित नोट्स को हटाना चाहते हैं? - पासकोड स्थापित करें - पासकोड दर्ज करें - रद्द करें - हाँ - पासकोड - प्रमाणीकरण आवश्यक है - पिन (6 अंक) - लॉक किया गया है - diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..d97f187 --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,636 @@ + + + + Üdvözlünk a Loudly-ban + Kezdjük el személyre szabni a zenei élményedet. + YouTube Music egy karnyújtásnyira + YT Music fiók szinkronizálása + Reklámmentes lejátszás + Offline zenei élmény + Android Auto támogatás + Discord állapot támogatása + Teljesen nyílt forráskódú + Válaszd ki a lejátszó felületét + Válaszd ki az alkalmazáson belüli lejátszó nézetet. Ezt később a „Beállítások > Megjelenés” menüpontban módosíthatod. + A beállítás kész! + Készen állsz a Loudly felfedezésére. + Befejezés + Vissza + Tovább + Előző + + Kezdőlap + Felfedezés + Dalok + Előadók + Albumok + Lejátszási listák + + Kedvelt zenék + Kedvelés + Offline zene + Legjobb zenéim + Gyorstárazott zenék + Helyi zenék + + Tartalomszűrő + + Mindkori + Elmúlt 24 óra + Elmúlt hét + Elmúlt hónap + Elmúlt év + + + %d kiválasztva + %d kiválasztva + + + Jó reggelt + Jó napot + Jó estét + Jó éjszakát + Legutóbbi tevékenység + Előzmények + Az előzményeid itt fognak megjelenni + A statisztikáid itt fognak megjelenni + Statisztikák + Kevesebb mutatása + Több mutatása + Hangulatok és műfajok + Fiók + Gyors válogatás + Hallgass dalokat a gyors válogatás létrehozásához + Elfeledett kedvencek + Folytasd a hallgatást + A te YouTube lejátszási listáid + Hasonló ehhez: + Új albumok + Nincs internetkapcsolat + Úgy tűnik, nincs internetkapcsolatod. Kérjük, ellenőrizd a hálózatot. + Neked ajánlva + Mára ajánlva neked + + Ma + Tegnap + Ezen a héten + Múlt héten + Helyi + Távoli + Helyi előzmények + Online előzmények + + Legtöbbet játszott dalok + Legtöbbet játszott előadók + Legtöbbet játszott albumok + + Keresés + Keresés a Loudly-ban + Keresés a könyvtárban… + Keresési előzmények + Javaslatok + Keresési eredmények + Könyvtár + Kedveltek + Letöltöttek + Összes + Dalok + Videók + Albumok + Előadók + Lejátszási listák + Közösségi lejátszási listák + Kiemelt lejátszási listák + Könyvjelzőzött + Nincs találat + + A könyvtárad itt fog megjelenni + A könyvtárban lévő dalok itt fognak megjelenni + A könyvtárban lévő előadók itt fognak megjelenni + A könyvtárban lévő albumok itt fognak megjelenni + A lejátszási listáid itt fognak megjelenni + + A könyvtáradból + + Más verziók + Kedvenc + Legjobb dalok + + Kedvelt dalok + Letöltött dalok + A lejátszási lista üres + Biztosan eltávolítod a(z) „%s” lejátszási lista összes dalát a letöltött dalok közül? + Biztosan törlöd a(z) „%s” lejátszási listát? + + Kikapcsolva + + Újra + Rádió + Véletlenszerű lejátszás + Visszaállítás + + Részletek + Szerkesztés + Rádió indítása + Lejátszás + Lejátszás következőként + Hozzáadás a várólistához + Hozzáadás a könyvtárhoz + Összes hozzáadása a könyvtárhoz + Eltávolítás a könyvtárból + Összes eltávolítása a könyvtárból + Letöltés + Letöltés + Letöltés eltávolítása + Lejátszási lista importálása + Hozzáadás lejátszási listához + Előadó megtekintése + Album megtekintése + Újra lekérés + Megosztás + Törlés + Eltávolítás az előzményekből + Eltávolítás a lejátszási listából + Eltávolítás a várólistáról + Eltávolítás a gyorsítótárból + Exportálás M3U fájlként + Keresés online + Szinkronizálás + Haladó + Tempó és hangmagasság + + Hozzáadás dátuma + Frissítés dátuma + Név + Előadó + Év + Dalok száma + Hossz + Lejátszási idő + Egyéni sorrend + + Médiaazonosító + MIME-típus + Kodekek + Bitráta + Mintavételi frekvencia + Hangerősség + Hangerő + Fájlméret + Ismeretlen + Vágólapra másolva + + Dalszöveg szerkesztése + Dalszöveg keresése + Biztosan törlöd a(z) „%s” dalhoz tárolt dalszöveget? + + Dal szerkesztése + Dal címe + Dal előadói + A dal címe nem lehet üres. + A dal előadója nem lehet üres. + Mentés + + Lejátszási lista szinkronizálása + Lejátszási lista kiválasztása + Lejátszási lista szerkesztése + Lejátszási lista létrehozása + Lejátszási lista neve + A lejátszási lista neve nem lehet üres. + + Előadó szerkesztése + Előadó neve + Az előadó neve nem lehet üres. + + Ismétlődések + Ismétlődések kihagyása + Hozzáadás mindenképp + A dal már szerepel a lejátszási listádon + %d dal már szerepel a lejátszási listádon + Megjegyzés: Ez lehetővé teszi a szinkronizálást a YouTube Music-kal + + + %d dal + %d dal + + + %d előadó + %d előadó + + + %d album + %d album + + + %d lejátszási lista + %d lejátszási lista + + + %d hét + %d hét + + + %d hónap + %d hónap + + + %d év + %d év + + + Lejátszási lista importálva + „%s” eltávolítva a lejátszási listáról + Lejátszási lista szinkronizálva + Visszavonás + + Most játszódik + Dalszöveg nem található + Elalváskapcsoló + Dal végén + + 1 perc + %d perc + + Nincs elérhető stream + Nincs hálózati kapcsolat + Időtúllépés + Ismeretlen hiba + Menü + Lista megosztása + + 5 másodperc vissza + 5 másodperc előre + + Kedvelés + Összes kedvelése + Kedvelés visszavonása + Összes kedvelés visszavonása + Véletlenszerű lejátszás be + Véletlenszerű lejátszás ki + Ismétlés kikapcsolva + Jelenlegi dal ismétlése + Várólista ismétlése + + Összes dal + Keresett dalok + + Zenelejátszó + + Értesítések + Alkalmazás leállításának megakadályozása + Használj egy előtérben futó értesítést, hogy a rendszer ne zárja be véletlenszerűen a Loudly-t. + Értesítési engedély szükséges + + Beállítások + Megjelenés + Téma + Proxy + Dinamikus téma engedélyezése + Sötét téma + Be + Ki + Rendszer követése + Tiszta fekete + Alkalmazás dizájn + Alkalmazás UI 1.0 + Alkalmazás UI 1.5 + Meghallgatás a YouTube Music-on + Látogass el a Loudly-ra + Előadó + Hallgasd a Loudly-t + Húzd a bélyegképet a dalváltáshoz + Navigációs fülek testreszabása + Lejátszó + Lejátszó szövegigazítása + Dalszöveg pozíciója + Oldalsó + Bal + Közép + Lejátszó stílusa + Régi lejátszó UI + Lejátszó UI 1.0 + Lejátszó UI 2.0 + Téma követése + Színátmenet + Elmosás + Elmosás (mozgás) + Lejátszó hátterének stílusa + Jobb + Csúszka stílusa + Alapértelmezett + Hullámos + Compose + Bélyegkép sarkainak sugarának beállítása + Tartalomszűrő mutatása + Egyéb + + Automatikus lejátszási listák testreszabása + „Kedveltek” automatikus lista mutatása + „Letöltések” automatikus lista mutatása + „Legjobbak” automatikus lista mutatása + „Gyorstárazott” automatikus lista mutatása + „Helyi” automatikus lista mutatása + Húzd jobbra a következő dalhoz, vagy balra a várólistához adáshoz + Alapértelmezett fül megnyitáskor + Alapértelmezett könyvtári szűrők + Rács cellamérete + Alsó navigációs sáv címkéinek mutatása + Kicsi + Nagy + Változási napló + Nincsenek friss változások + Legutóbbi változások: + Bezárás + Új verzió elérhető + Szia újra + Adományozás + Hivatalos csatorna + Weboldal + Hivatalos weboldal ❯ + Forráskód + Hozzáférhetsz az alkalmazás forráskódjához + Problémák + Hibajelentés és egyebek + Segítség + Ha szeretnél hozzájárulni, kattints ide ❯ + Fejlesztő | @RRechz + + Tartalom + Bejelentkezés + Jelentkezz be a YT Music lejátszási listáidhoz és egyebekhez + Kattints a token megjelenítéséhez + Kattints újra a másoláshoz vagy szerkesztéshez + Ez egy HALADÓ bejelentkezési módszer. A webportál alternatívájaként itt közvetlenül beírhatod vagy frissítheted a bejelentkezési tokened. Ez például felgyorsíthatja a bejelentkezést több eszközön. Kérjük, vedd figyelembe, hogy az alkalmazás által nem értelmezhető érvénytelen tokenformátumokat nem fogadjuk el. + Importálás a Spotify-ból + Spotify + Discord + Kedvelt dalok automatikus letöltése + Automatikus szinkronizálás a YouTube Music fiókoddal + Szinkronizáld a lejátszási listáidat és egyebeket a fiókoddal + Csak Wi-Fi + Nincs bejelentkezve + Alapértelmezett tartalomnyelv + Alapértelmezett tartalomország + Rendszer alapértelmezett + Proxy engedélyezése + Proxy típusa + Proxy URL + Újraindítás szükséges a változásokhoz + Alkalmazás nyelve + Alkalmazás nyelvének beállítása + Az alkalmazás nyelvi beállításai nem találhatók, kérjük, állítsd be manuálisan + Támogatott linkek alapértelmezett megnyitása + Támogatott linkek beállítása + A támogatott linkek beállításai nem találhatók, kérjük, állítsd be manuálisan + + Lejátszó és hang + Hangminőség + Maximum + Automatikus + Magas + Alacsony + Minimális lejátszási idő + A dal minimális lejátszási ideje, ami után „lejátszottnak” minősül + Várólista + Állandó várólista + Az utolsó várólista visszaállítása az alkalmazás indításakor + További dalok automatikus betöltése + Automatikusan adjon hozzá több dalt, ha a várólista véget ér, ha lehetséges + Csend átugrása + Hang normalizálása + Lejátszás folytatása Bluetooth-eszköz csatlakoztatásakor + Lejátszás leállítása minimális hangerőn + Lejátszott dalok hozzáadása a YouTube Music előzményeihez + Automatikus ugrás a következő dalra hiba esetén + Biztosítsd a folyamatos lejátszási élményt + Zene leállítása a feladatok törlésekor + Hangszínszabályzó + Dalszövegek + Dalszövegek + Helyi lejátszó beállításai + Tárhely + Gyorsítótár + Képek gyorsítótára + Dalok gyorsítótára + Maximális gyorsítótár-méret + Korlátlan + Offload engedélyezése + Használd az offload audio útvonalat a lejátszáshoz. Kikapcsolása növelheti az energiafogyasztást, de hasznos lehet, ha problémákat tapasztalsz a lejátszással vagy az utófeldolgozással. + Összes letöltés törlése + Képek gyorsítótárának maximális mérete + Képek gyorsítótárának törlése + Dalok gyorsítótárának maximális mérete + Dalok gyorsítótárának törlése + felhasznált + Biztosan törlöd az összes gyorsítótárat? + Biztosan törlöd az összes letöltést? + Biztosan törlöd az összes gyorsítótárazott dalt? + Biztosan törlöd az összes gyorsítótárazott képet? + Biztosan törlöd az összes gyorsítótárazott fordítási modellt? + + Adatvédelem + Hallgatási előzmények + Hallgatási előzmények szüneteltetése + Hallgatási előzmények törlése + Biztosan törlöd az összes hallgatási előzményt? + Keresési előzmények szüneteltetése + Keresési előzmények törlése + Biztosan törlöd az összes keresési előzményt? + Keresési előzmények + Bejelentkezés használata a tartalom böngészéséhez + Ez befolyásolhatja, hogy milyen tartalmat látsz, és például csak prémium albumokat mutat, ha prémium fiókkal vagy bejelentkezve. + Képernyőkép letiltása + Ha ez a beállítás be van kapcsolva, a képernyőképek és az alkalmazás nézete a legutóbbiak között le van tiltva. + LrcLib dalszöveg-szolgáltató engedélyezése + KuGou dalszöveg-szolgáltató engedélyezése + Alapértelmezett dalszöveg-szolgáltató + Többsoros dalszövegek olvasása + A szinkronizálási pontok közötti összes sort egyetlen dalszövegként kezeli + Szóközök eltávolítása a dalszövegek körül + Betűméret + Kifejezett tartalom elrejtése + Értesítések engedélyezése + + Biztonsági mentés és visszaállítás + Biztonsági mentés + Visszaállítás + Lejátszási lista importálása (m3u) + A lejátszási lista importálása sikertelen + Importált lejátszási lista + Biztonsági mentés sikeresen létrehozva + A biztonsági mentés létrehozása sikertelen + A biztonsági mentés visszaállítása sikertelen + + Discord integráció + A Loudly a KizzyRPC könyvtárat használja a Discord-fiókod állapotának beállításához. Ez magában foglalja a Discord Gateway kapcsolat használatát, ami a Discord szolgáltatási feltételeinek megsértését jelentheti. Azonban nincsenek ismert esetek, amikor felhasználói fiókokat emiatt felfüggesztettek volna. Használat csak saját felelősségre.\n\nA Loudly csak a tokenedet vonja ki, minden más helyben tárolódik. + Elvetés + Opciók + Előnézet + Bejelentkezés sikertelen + Kijelentkezés + Rich Presence engedélyezése + + Névjegy + Alkalmazás verziója + Frissítések keresése... + a(z) %1$s verzió letölthető. + Letöltés... + Frissítés + Próbáld újra + Letöltés most + Alkalmazás típusa + + Új verzió elérhető + Fordítási modellek + Fordítási modellek törlése + Előzmények időtartama + + 1 másodperc + %d másodperc + + Zene 3x2 + Zene 2x1 + Lejátszás/Szünet + Verzió · Fejlesztők · Licenc + Felhasználói felület + Tartalom és fiók + Lejátszó + Rendszer + Könyvtár + Integrációk + Rendszerintegráció + Összes felhasznált hely + Jelenleg felhasznált + Gyorsítótár törlése + Mentés · Visszaállítás · Importálás + Előzmények · Keresés · Képernyőkép + Letöltések · Gyorsítótár · Képek + Hang · Várólista · Dalszövegek + Nyelv · Hely · Proxy + Bejelentkezés · Spotify · Discord + Téma · Csúszka · Lejátszó + + Profilkártya képe + A profilkártya képének testreszabása + Profilkép kiválasztása + Előre elkészített képek + Kiválasztás a galériából + Fotó szerkesztése + Mégse + + Küldés a fejlesztőnek + Bezárás + Hiba részleteinek mutatása + Letöltés sikertelen + Hiba mutatása + Kézi frissítés + A részletekért nézd meg a beállítások oldalt + A szoftver naprakész + + Alkalmazás UI + Válassz egyet a 3 különböző dizájn közül + UI verzió kiválasztása + Az alkalmazás régi verziójának dizájnja. (Alkalmazás, Lejátszó, MiniLejátszó) + Kompakt dizájn, modernebb megközelítéssel. (Alkalmazás, Lejátszó, MiniLejátszó) + A legnépszerűbb dizájn. (Alkalmazás, Lejátszó, MiniLejátszó) + Hozd létre a saját alkalmazás UI dizájnodat. (Alkalmazás, Lejátszó, MiniLejátszó) + UI testreszabása + + Egyéb beállítások + Kattints a további különféle beállításokért + + Automatikusan beállítja a minőséget 45 és 512 Kbps között + A legmagasabb minőséget kínálja, akár 512 Kbps-ig + Az átlagos internetsebességhez beállítja a minőséget 128 és 256 Kbps között. + A minőséget 45 és 52 Kbps között korlátozza az adatforgalom csökkentése érdekében (Adatmegtakarító) + + Nem sikerült importálni + + Szöveg igazítása + Állítsd be a szöveg pozícióját a kártyán + Színátmenet intenzitása + Sarkok lekerekítése + Profilkép + Profilkártya + Profilkép eltávolítása + Szövegek mutatása + Az üdvözlő és felhasználónév szövegének megjelenítése a profilkártyán + Kattints a változtatáshoz + + Fordítás (BÉTA) + Másolás + + Hálózati hiba + Ellenőrizd az internetkapcsolatot és próbáld újra. + Nem támogatott tartalom + Ezt a dalformátumot az eszközöd nem támogatja. + Élő közvetítési hiba + Probléma van az élő közvetítéssel. Kérjük, indítsd újra. + Lejátszás sikertelen + Ismeretlen hiba történt. Kérjük, próbáld újra. + Újrapróbálkozás… + A VPN használata befolyásolhatja a kapcsolat minőségét. Ha hálózati problémákat tapasztalsz, szakítsd meg a VPN-kapcsolatot, vagy használj proxyt. Ezt a „Beállítások >> Tartalom >> Proxy” menüpontban konfigurálhatod. + + Helyi dalok sikeresen szinkronizálva + Helyi szkenner + + Automatikus szkennelés + Dalok automatikus keresése az alkalmazás megnyitásakor + + További szkenner beállítások + Szigorú fájlnevek + Ha engedélyezve van, a kiterjesztések NEM lesznek figyelmen kívül hagyva. Pl. a „Song.ogg” más dal lesz, mint a „Song.flac” + Szkenner érzékenységének beállítása + Cím egyezése + Cím és előadók egyezése + Cím, előadók, albumok egyezése + + Mappák + Almappák laposítása + Tiltsd le a fájlstruktúra megőrzéséhez, ahogy a lemezen van + + Tippek és funkciók + Tippek · Funkciók + Hozd létre a történeted + Oszd meg a hallgatott dalt a közösségi médiában egy stílusos dizájnnal, amely az album borítójának domináns színeivel készült. + Testreszabható beállítások + Tedd egyedivé az alkalmazást azáltal, hogy a beállítások képernyőn lévő címsor kártyájának hátterét egy képre cseréled a galériádból. + Új dallista + Nézd meg a jelenlegi lejátszási listádat, és válts könnyedén a dalok között a lejátszó képernyőjéről felfelé húzva (új dizájn, kizárólag a Player UI 2.0-ban). + Átdolgozott beállítások dizájn + Találd meg könnyen, amit keresel egy új, kártya alapú beállítási dizájnnal, ami modernebb és kényelmesebb. + Klasszikus zene widget + Gyorsan elérheted az összes vezérlőt és dalinformációt a kezdőképernyődről. + Minimalista zene widget + „Quik” zene widget, amelyet az Android 16 Gyorsbeállítások gombjának dizájnja ihletett. + Megosztás a történetben + Dal linkjének másolása + Link a vágólapra másolva! + Streamelve a Loudly-n + + Összes kijelölése + Összes lejátszási lista lekérése… + Kiválasztott elemek importálása + Valami hiba történt. Ugyanolyan tanácstalanok vagyunk, mint te. + Nos, ez kínos. Nekünk sincs fogalmunk, mi történt. + Útmutató a szükséges hitelesítő adatok megszerzéséhez + 3. Győződj meg róla, hogy a fenti helyi útvonal beírása után a „Hozzáadás” gombra kattintasz. + 5. Fogadd el a szolgáltatási feltételeket, és kattints a „Mentés” gombra. + Át leszel irányítva egy új oldalra. Kattints a Beállításokra, majd másold ki az Ügyfél-azonosítót és az Ügyfél-titkot, és illeszd be őket az alábbi szövegmezőkbe ebben az alkalmazásban. + 2. Add meg a szükséges adatokat, és használd a http://127.0.0.1:45454 címet átirányítási URI-ként. + 4. Kapcsold be a Web API-t. A jelölőnégyzetben most egy pipa kell, hogy legyen. + Most kattints az alábbi Engedélyezés és folytatás gombra, és jelentkezz be azzal a fiókkal, amelyből importálni szeretnél, majd engedélyezd és folytasd, ami átirányít egy olyan webhelyre, amelynek a következővel kell kezdődnie: http://127.0.0.1:45454/?code=\n\nIlleszd be ezt a teljes URL-t az alábbi, „Engedélyezési kód” feliratú szövegmezőbe, majd kattints a Hitelesítés gombra. + 1. Látogass el a Spotify for Developers irányítópultjára, és kattints az „Alkalmazás létrehozása” gombra. + Bejelentkezés a Spotify API hitelesítő adataival + Engedélyezés és folytatás + Engedélyezési kód + Hitelesítés + Ügyfél-titok + Ügyfél-azonosító + Importálás folyamatban… + Ne zárd be az alkalmazást, és ne lépj vissza. Ez a művelet nem fut a háttérben, ezért maradj itt, amíg be nem fejeződik!\nNE PÁNIKOLJ, HA ÚGY TŰNIK, HOGY ELAKADT; néha a lekérés eltarthat egy ideig. + Válaszd ki a „Kedvelt dalok” célhelyét + Hova legyenek importálva a kedvelt dalok? + Egy új, „Kedvelt dalok” nevű lejátszási lista + Az alapértelmezett „Kedvelt dalok” közé + \ No newline at end of file diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index df286b6..e32ab8a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1,278 +1,636 @@ - AirNote - Catatan - Catatan Baru - Tidak ada catatan yang dibuat. - Tidak ada catatan yang ditemukan. - Waktu Dibuat - Tampilan petak - Tampilan kolom - Kata - Karakter - Nama - Deskripsi - Pilih Semua - Hapus - Informasi + + Selamat Datang di Loudly + Ayo mulai personalisasi pengalaman musik Anda. + YouTube Music di ujung jari Anda + Sinkronisasi akun YT Music + Pemutaran tanpa IKLAN + Pengalaman musik offline + Mendukung Android Auto + Mendukung status Discord + Sepenuhnya sumber terbuka + Pilih Tampilan Pemutar Anda + Pilih tampilan pemutar dalam aplikasi. Anda dapat mengubah pengaturan ini nanti di “Setelan > Tampilan”. + Pengaturan Selesai! + Anda siap untuk menjelajahi Loudly. + Selesai + Kembali + Berikutnya + Sebelumnya + + Beranda + Jelajahi + Lagu + Artis + Album + Playlist + + Musik yang disukai + Suka + Musik offline + Musik teratas saya + Musik dalam cache + Musik lokal + + Filter konten + + Semua waktu + 24 jam terakhir + Seminggu terakhir + Sebulan terakhir + Setahun terakhir + + + %d dipilih + %d dipilih + + + Selamat pagi + Selamat siang + Selamat malam + Selamat malam + Aktivitas terkini + Riwayat + Riwayat Anda akan muncul di sini + Statistik Anda akan muncul di sini + Statistik + Tampilkan Lebih Sedikit + Tampilkan Lebih Banyak + Suasana Hati dan Genre + Akun + Pilihan cepat + Dengarkan lagu untuk menghasilkan pilihan cepat Anda + Favorit yang terlupakan + Terus mendengarkan + Playlist YouTube Anda + Mirip dengan + Album rilis baru + Tidak Ada Koneksi Internet + Sepertinya Anda tidak memiliki koneksi Internet. Silakan periksa jaringan Anda. + Unggulan untuk Anda + Direkomendasikan untuk Anda Hari Ini + + Hari ini + Kemarin + Minggu ini + Minggu lalu + Lokal + Jarak Jauh + Riwayat lokal + Riwayat online + + Lagu yang paling sering diputar + Artis yang paling sering diputar + Album yang paling sering diputar + Cari - Pengaturan - Tampilan - Tema Sistem - Tema Gelap - Warna Dinamis - Warna Amoled - Basis Data + Cari di Loudly + Cari di koleksi… + Riwayat Pencarian + Saran + Hasil Pencarian + Koleksi + Disukai + Diunduh + Semua + Lagu + Video + Album + Artis + Playlist + Playlist komunitas + Playlist unggulan + Ditandai + Tidak ada hasil yang ditemukan + + Koleksi akan muncul di sini + Lagu koleksi akan muncul di sini + Artis koleksi akan muncul di sini + Album koleksi akan muncul di sini + Playlist Anda akan muncul di sini + + Dari koleksi Anda + + Versi lain + Favorit + Lagu Teratas + + Lagu yang disukai + Lagu yang diunduh + Playlist ini kosong + Apakah Anda benar-benar ingin menghapus semua lagu playlist \"%s\" dari penyimpanan Lagu yang Diunduh? + Apakah Anda benar-benar ingin menghapus playlist \"%s\"? + + Nonaktif + + Coba Lagi + Radio + Acak + Setel Ulang + + Detail + Edit + Mulai radio + Putar + Putar berikutnya + Tambahkan ke antrean + Tambahkan ke koleksi + Tambahkan semua ke koleksi + Hapus dari koleksi + Hapus semua dari koleksi + Unduh + Mengunduh + Hapus unduhan + Impor playlist + Tambahkan ke playlist + Lihat artis + Lihat album + Ambil Ulang + Bagikan + Hapus + Hapus dari riwayat + Hapus dari playlist + Hapus dari antrean + Hapus dari cache + Ekspor sebagai file M3U + Cari online + Sinkronkan + Lanjutan + Tempo dan Nada + + Tanggal ditambahkan + Tanggal diperbarui + Nama + Artis + Tahun + Jumlah lagu + Durasi + Waktu putar + Urutan kustom + + ID Media + Jenis MIME + Kodek + Bitrate + Sample rate + Kenyaringan + Volume + Ukuran file + Tidak diketahui + Disalin ke papan klip + + Edit lirik + Cari lirik + Apakah Anda benar-benar ingin menghapus lirik yang tersimpan untuk \"%s\"? + + Edit lagu + Judul lagu + Artis lagu + Judul lagu tidak boleh kosong. + Artis lagu tidak boleh kosong. + Simpan + + Sinkronkan Playlist + Pilih playlist + Edit playlist + Buat playlist + Nama playlist + Nama playlist tidak boleh kosong. + + Edit artis + Nama artis + Nama artis tidak boleh kosong. + + Duplikat + Lewati duplikat + Tetap tambahkan + Lagu tersebut sudah ada di playlist Anda + %d lagu sudah ada di playlist Anda + Catatan: Ini memungkinkan sinkronisasi dengan YouTube Music + + + %d lagu + %d lagu + + + %d artis + %d artis + + + %d album + %d album + + + %d playlist + %d playlist + + + %d minggu + %d minggu + + + %d bulan + %d bulan + + + %d tahun + %d tahun + + + Playlist diimpor + Menghapus \"%s\" dari playlist + Playlist disinkronkan + Urungkan + + Sedang diputar + Lirik tidak ditemukan + Timer tidur + Akhir lagu + + 1 menit + %d menit + + Tidak ada streaming yang tersedia + Tidak ada koneksi jaringan + Waktu habis + Kesalahan tidak diketahui + Menu + Bagikan Daftar + + 5 detik ke belakang + 5 detik ke depan + + Suka + Sukai semua + Batal suka + Batal sukai semua + Acak aktif + Acak nonaktif + Mode ulang nonaktif + Ulangi lagu saat ini + Ulangi antrean + + Semua lagu + Lagu yang dicari + + Pemutar Musik + + Notifikasi + Mencegah aplikasi dimatikan oleh sistem + Gunakan notifikasi latar depan untuk mencegah Loudly ditutup secara acak oleh sistem. + Izin notifikasi diperlukan + + Setelan + Tampilan + Tema + Proksi + Aktifkan tema dinamis + Tema gelap + Aktif + Nonaktif + Ikuti sistem + Hitam pekat + Desain aplikasi + UI Aplikasi 1.0 + UI Aplikasi 1.5 + Dengarkan di YouTube Music + Kunjungi Loudly + Artis + Dengarkan Loudly + Aktifkan geser pada thumbnail untuk mengganti lagu + Kustomisasi tab navigasi + Pemutar + Perataan teks pemutar + Posisi teks lirik + Samping + Kiri + Tengah + Gaya pemutar + UI Pemutar Lama + UI Pemutar 1.0 + UI Pemutar 2.0 + Ikuti tema + Gradien + Buram + Buram (Gerak) + Gaya latar belakang pemutar + Kanan + Gaya slider + Default + Bergelombang + Compose + Konfigurasi radius sudut thumbnail + Tampilkan filter konten + Lain-lain + Utama + Kustomisasi playlist otomatis + Tampilkan playlist otomatis yang disukai + Tampilkan playlist otomatis unduhan + Tampilkan playlist otomatis teratas + Tampilkan playlist otomatis cache + Tampilkan playlist otomatis lokal + Geser lagu ke kanan untuk putar berikutnya atau ke kiri untuk menambahkan ke antrean + Tab buka default + Chip koleksi default + Ukuran sel grid + Tampilkan label bilah navigasi bawah + Kecil + Besar + Log perubahan + Tidak ada perubahan terbaru yang ditemukan + Perubahan terbaru: + Tutup + Versi baru tersedia + Halo Lagi + Donasi + Saluran Resmi + Situs web + Situs web resmi ❯ + Kode sumber + Akses kode sumber aplikasi + Masalah + Laporan Bug dan Lainnya + Bantuan + Jika Anda ingin berkontribusi, klik di sini ❯ + Pengembang | @RRechz + + Konten + Login + Masuk ke playlist YT Music Anda dan lainnya + Ketuk untuk menampilkan token + Ketuk lagi untuk menyalin atau mengedit + Ini adalah metode login LANJUTAN. Sebagai alternatif portal web, Anda dapat langsung memasukkan atau memperbarui token login Anda di sini. Misalnya, ini dapat mempercepat login di beberapa perangkat. Harap dicatat bahwa format token yang tidak valid yang gagal diurai oleh aplikasi tidak akan diterima. + Impor dari spotify + Spotify + Discord + Otomatis unduh lagu yang Anda sukai + Otomatis sinkronisasi dengan akun YouTube Music Anda + Sinkronkan playlist Anda dan lainnya dengan akun Anda + Hanya Wifi + Belum login + Bahasa konten default + Negara konten default + Default sistem + Aktifkan proksi + Jenis proksi + URL Proksi + Mulai ulang agar berlaku + Bahasa aplikasi + Konfigurasi bahasa aplikasi + Tidak dapat menemukan setelan bahasa aplikasi, harap konfigurasikan secara manual + Buka tautan yang didukung secara default + Konfigurasi tautan yang didukung + Tidak dapat menemukan setelan tautan yang didukung, harap konfigurasikan secara manual + + Pemutar dan Audio + Kualitas audio + Maks + Otomatis + Tinggi + Rendah + Durasi pemutaran minimum + Jumlah minimum lagu yang harus diputar sebelum dianggap "diputar" + Antrean + Antrean persisten + Pulihkan antrean terakhir Anda saat aplikasi dimulai + Muat lebih banyak lagu secara otomatis + Secara otomatis menambahkan lebih banyak lagu saat akhir antrean tercapai, jika memungkinkan + Lewati hening + Normalisasi audio + Lanjutkan pemutaran saat perangkat bluetooth terhubung + Hentikan pemutaran lagu saat suara volume minimum + Menambahkan lagu yang diputar ke riwayat YouTube-Music + Lewati otomatis ke lagu berikutnya saat terjadi kesalahan + Pastikan pengalaman pemutaran berkelanjutan Anda + Hentikan musik saat tugas dibersihkan + Equalizer + Lirik + Lirik + Setelan pemutar lokal + Penyimpanan + Cache + Cache Gambar + Cache Lagu + Ukuran cache maks + Tidak terbatas + Aktifkan offload + Gunakan jalur audio offload untuk pemutaran audio. Menonaktifkan ini dapat meningkatkan penggunaan daya tetapi dapat berguna jika Anda mengalami masalah dengan pemutaran audio atau pasca-pemrosesan + Hapus semua unduhan + Ukuran cache gambar maks + Hapus cache gambar + Ukuran cache lagu maks + Hapus cache lagu + digunakan + Apakah Anda yakin ingin menghapus semua cache? + Apakah Anda yakin ingin menghapus semua unduhan? + Apakah Anda yakin ingin menghapus semua lagu yang di-cache? + Apakah Anda yakin ingin menghapus semua gambar yang di-cache? + Apakah Anda yakin ingin menghapus semua model terjemahan yang di-cache? + + Privasi + Riwayat dengar + Jeda riwayat dengar + Hapus riwayat dengar + Apakah Anda yakin ingin menghapus semua riwayat dengar? + Jeda riwayat pencarian + Hapus riwayat pencarian + Apakah Anda yakin ingin menghapus semua riwayat pencarian? + Riwayat pencarian + Gunakan login untuk menjelajahi konten + Ini dapat memengaruhi konten apa yang Anda lihat dan misalnya menampilkan album khusus premium jika Anda login dengan akun Premium + Nonaktifkan tangkapan layar + Saat opsi ini aktif, tangkapan layar dan tampilan aplikasi di Terbaru dinonaktifkan. + Aktifkan penyedia lirik LrcLib + Aktifkan penyedia lirik KuGou + Penyedia lirik default + Baca lirik multibaris + Perlakukan semua baris di antara titik sinkronisasi sebagai satu teks lirik + Hapus spasi di sekitar lirik + Ukuran font + Sembunyikan konten eksplisit + Aktifkan notifikasi + + Cadangkan dan pulihkan Cadangkan - Belum didukung - Tentang - Versi - Rilisan Terbaru - Beri Bintang Proyek - Donasi untuk saya :) Pulihkan - Catatan - Dukungan - - Bahasa - Markdown - Alat - Riwayat - Widget - Tema • Ukuran • Font - Cloud - Font • Bahasa - NextCloud • Lokal • Enkripsi - Versi • Pengembang • Lisensi - Widget • Markdown - Riwayat • Kembalikan - Dibuat • Hapus Semua - Dukungan • Gambar - Tunjukkan dukungan Anda - Font - Radius - Pilih Radius - Mode Minimalis - Warna Amoled Ekstrem - Privasi - Perlindungan Layar - Dukungan Gambar - Tidak Stabil - Mata Uang Kripto - Menyesuaikan tema aplikasi berdasarkan pengaturan sistem - Beralih antara skema warna gelap dan terang - Menyesuaikan warna agar sesuai dengan wallpaper Anda - Gunakan warna hitam untuk latar belakang - Optimalisasi Baterai Ekstrem - Sesuaikan radius batas - Pilih gaya tampilan - Hapus tombol atas dari layar edit - Simpan semua catatan ke file - Pulihkan catatan dari file cadangan - Aktifkan markdown di catatan - Sembunyikan layar di aplikasi terkini dan nonaktifkan tangkapan layar - Tipe Build - Kode Sumber - Daftar Pendukung - Email - Discord - Laporan Bug dan Lainnya - Disematkan - Lainnya - Salin - Enkripsi Basis Data - Enkripsi basis data selama pencadangan - Masukkan kata sandi - Kata sandi tidak valid atau basis data tidak dienkripsi - Input tidak valid - Urutkan Naik - Urutkan Turun - Urutkan catatan berdasarkan waktu pembuatannya - Pilih Catatan - Ukuran Gambar Tidak Didukung (maks 15mb) - Vault - Aktifkan vault catatan terenkripsi - lanjutkan - Ubah bahasa aplikasi - Bahasa Sistem - Perilaku - Prioritaskan Mode Edit - Masuk ke mode edit pada catatan yang ada - Kata Sandi Tidak Valid - Donasi - Terjemahan - Kontributor - Menghapus gambar dari penyimpanan aplikasi saat dihapus dari galeri - Sinkronisasi Galeri - Tampilkan judul saja - Tampilkan judul saja di pratinjau - 1. Penerimaan Ketentuan - Dengan menekan "Setuju" dan menggunakan Easy Notes, Anda menerima Ketentuan Layanan ini. Jika Anda tidak setuju, jangan gunakan aplikasi ini. - 2. Lisensi - AirNote (Easy Notes) didistribusikan di bawah **Lisensi Publik Umum GNU (GPL) versi 3**. Ini berarti:\n\n- Anda bebas menggunakan, memodifikasi, dan membagikan aplikasi ini.\n- Jika Anda mendistribusikan aplikasi atau versi yang dimodifikasi, Anda juga harus mendistribusikan kode sumber dan mempertahankan lisensi GPL. - 3. Penggunaan Aplikasi - Anda setuju untuk menggunakan AirNote (Easy Notes) hanya untuk tujuan yang sah dan sesuai dengan Ketentuan ini. Anda **tidak boleh**:\n\n- _Menggunakan aplikasi untuk tujuan yang melanggar hukum_\n- _Melanggar hak orang lain_\n- _Mengganggu fungsionalitas atau keamanan aplikasi_ - 4. Kekayaan Intelektual - Semua hak kekayaan intelektual di AirNote (Easy Notes) dimiliki oleh kami atau pemberi lisensi kami. Penggunaan aplikasi oleh Anda tidak memberi Anda kepemilikan atas hak kekayaan intelektual apa pun. - 5. Penafian Jaminan - Easy Notes disediakan "sebagaimana adanya," tanpa jaminan apa pun. Kami tidak menjamin bahwa aplikasi akan bebas dari kesalahan, aman, atau tidak terganggu. - 6. Batasan Tanggung Jawab - Kami tidak bertanggung jawab atas segala kerusakan yang diakibatkan oleh penggunaan Anda atas AirNote (Easy Notes), termasuk kerusakan tidak langsung, insidental, atau konsekuensial. - 7. Penghentian - Kami dapat menghentikan atau menangguhkan akses Anda ke AirNote (Easy Notes) jika Anda melanggar Ketentuan ini. Setelah penghentian, hak Anda untuk menggunakan aplikasi akan segera berakhir. - 8. Perubahan pada Ketentuan - Kami dapat memperbarui Ketentuan Layanan ini dari waktu ke waktu. Penggunaan Anda yang berkelanjutan atas AirNote (Easy Notes) setelah ada perubahan merupakan penerimaan terhadap ketentuan yang direvisi. - 9. Hubungi Kami - Untuk pertanyaan apa pun tentang Ketentuan Layanan ini atau AirNote (Easy Notes), silakan hubungi kami di - 10. Kebijakan Privasi - AirNote (Easy Notes) tidak mengumpulkan data pribadi apa pun. Untuk informasi lebih lanjut, silakan merujuk pada Kebijakan Privasi kami yang tersedia di - Tanggal Efektif - Setuju - Ketentuan Layanan - Tidak dapat mengimpor - Impor beberapa file gagal - Berhasil diimpor - Impor - Impor file teks lain sebagai catatan - Gunakan font monospace saat mengedit catatan - Font monospace - Apakah Anda yakin ingin menghapus catatan yang dipilih - Ya + Impor playlist (m3u) + Gagal mengimpor playlist + Playlist yang diimpor + Pencadangan berhasil dibuat + Tidak dapat membuat cadangan + Gagal memulihkan cadangan + + Integrasi Discord + Loudly menggunakan pustaka KizzyRPC untuk mengatur status akun Discord Anda. Ini melibatkan penggunaan koneksi Discord Gateway, yang dapat dianggap sebagai pelanggaran TOS Discord. Namun, tidak ada kasus yang diketahui di mana akun pengguna ditangguhkan karena alasan ini. Gunakan dengan risiko Anda sendiri.\n\nLoudly hanya akan mengekstrak token Anda, dan semua yang lain disimpan secara lokal. + Tutup + Opsi + Pratinjau + Login gagal + Logout + Aktifkan Rich Presence + + Tentang + Versi aplikasi + Memeriksa pembaruan... + versi %1$s tersedia untuk diunduh. + Mengunduh... + Tingkatkan + Coba Lagi + Unduh Sekarang + Jenis Aplikasi + + Versi baru tersedia + Model Terjemahan + Hapus model terjemahan + Durasi riwayat + + 1 detik + %d detik + + Musik 3x2 + Musik 2x1 + Putar/Jeda + Versi · Pengembang · Lisensi + UI + Konten & Akun + Pemutar + Sistem + Koleksi + Integrasi + Integrasi Sistem + Total ruang yang digunakan + Saat ini digunakan + Hapus cache + Cadangkan · Pulihkan · Impor + Riwayat · Cari · Tangkapan Layar + Unduhan · Cache · Gambar + Suara · Antrean · Lirik + Bahasa · Lokasi · Proksi + Login · Spotify · Discord + Tema · Slider · Pemutar + + Gambar Kartu Profil + Kustomisasi gambar pada kartu profil + Pilih gambar profil + Gambar siap pakai + Pilih dari galeri + Edit Foto Batal - Kunci aplikasi - Kunci aplikasi menggunakan kata sandi - Siapkan kode sandi - Masukkan Kode Sandi - Pin (6 angka) - Kode sandi - Sidik jari - Otentikasi diperlukan - terkunci - UID - Minta sidik jari saat memulai - Minta kode sandi saat memulai - Pola - Minta pola saat memulai - Siapkan pola - Masukkan pola - Kunci Segera - Kunci secara instan saat aplikasi dilanjutkan - Nonaktifkan geser - "Nonaktifkan geser untuk pratinjau dalam mode edit" - Skalakan Lebar Bilah Pencarian - Buat lebar bilah pencarian cocok dengan lebar catatan - Ukuran Font - Ubah ukuran teks di catatan - Ukuran Font: %1$d - Pratinjau - Beginilah tampilan catatan Anda dengan ukuran font yang dipilih. - Pendiri Babel Software dan pengembang Android - Tambah folder baru - Nama folder - Warna folder - Tambah - Pindah ke Folder - Tidak ada folder yang tersedia - Hapus folder - Apakah Anda yakin ingin menghapus folder ‘%1$s’? Catatan di dalamnya akan dipindahkan ke bagian ‘Semua Catatan’. - Edit folder - Simpan - Tag folder - Ini menunjukkan label folder tempat catatan berada. - Pengaturan AirNote AI - Konfigurasi AI · Pengaturan API - Gunakan AirNote API - Gunakan fitur AI dengan API bawaan aplikasi (Disarankan) - Kunci API Anda (BETA) - Periksa - Model yang Digunakan - Pilihan Model (BETA) - Saran AI + + Kirim ke Pengembang Tutup - Semua Catatan - Tanya, ngobrol, temukan dengan AI... - Bagaimana Cara Mendapatkan Kunci API Anda Sendiri? - Dari Google AI Studio, Anda dapat membuat kunci API pribadi dengan batas penggunaan gratis yang besar. Kunci ini hanya milik Anda dan Anda memiliki kendali penuh. - 1. Buka situs web Google AI Studio dengan mengklik tombol di bawah ini. - 2. Masuk dengan akun Google Anda. - 3. Di layar yang terbuka, klik tombol \"Buat kunci API\". - 4. Anda mungkin diminta untuk membuat proyek baru, ikuti langkah-langkah untuk membuatnya. - 5. Salin string karakter panjang yang dihasilkan. - 6. Tempel kunci yang disalin ke dalam kotak teks di bawah ini dan tekan tombol \"Periksa\". - Buka Google AI Studio - Harap masukkan kunci API terlebih dahulu. - Kunci API valid dan berhasil terdaftar! ✅ - Kunci API tidak valid. Silakan periksa dan coba lagi. - Ajukan pertanyaan - Buat catatan tentang suatu topik - Hasilkan ide - Beri saya 5 ide menarik tentang topik acak - Versi Baru - Perbarui - Pembaruan baru tersedia! - Perbarui Aplikasi - Log perubahan tidak dapat diambil. - Gagal menerima log perubahan: %1$s - Memeriksa pembaruan... - Pembaruan Tersedia - Versi Baru Tersedia! - Versi %1$s tersedia untuk diunduh. - Perbarui Sekarang - Unduh - Mengunduh... %1$d%% - Instal - Instal - Pembaruan Gagal - Kesalahan - Coba Lagi - Coba Lagi - Aplikasi Anda Sudah Terbaru - Diperbarui - Pembaruan memerlukan izin untuk menginstal dari sumber yang tidak dikenal. - Tautan unduhan tidak ditemukan. - Inovasi - Simpan Catatan - Membangun kembali - - Optimalkan Tulisan - Ringkas - Buat Lebih Pendek - Buat Lebih Panjang - Ubah Nada - - Formal - Seimbang - Ramah - Kunci API pengguna tidak ditemukan. Silakan periksa di pengaturan. - Permintaan API gagal: %1$s - Halo! Ada yang bisa saya bantu? - SARAN - Sebutkan Pro dan Kontra - Buat Daftar Tugas - Jelaskan Lebih Sederhana - Direkomendasikan untuk file yang diimpor - Sarankan Judul - - Bagus, kita akan membuat draf tentang apa? Silakan tulis topiknya. - - Perbaiki kesalahan tata bahasa dan ekspresi dalam teks di bawah ini agar lebih lancar. Jawab hanya dengan teks yang sudah diperbaiki:\n\n---\n\n%1$s - Ringkas teks di bawah ini dalam satu atau dua kalimat, dengan tetap mempertahankan ide utamanya. Jawab hanya dengan teks ringkasan:\n\n---\n\n%1$s - Persingkat teks berikut secara signifikan sambil mempertahankan maknanya. Balas hanya dengan teks yang dipersingkat:\n\n---\n\n%1$s - Tambahkan detail kreatif ke teks di bawah ini untuk membuatnya lebih panjang. Balas hanya dengan teks yang diperpanjang:\n\n---\n\n%1$s - Server AI sedang sibuk. Silakan coba lagi dalam beberapa menit. - - %1$s\n\n---\n\n%2$s - Tulis ulang teks di bawah ini dalam bahasa yang profesional dan formal. Pertahankan maknanya, hanya ubah nadanya. Balas hanya dengan teks akhir: - Tulis ulang teks di bawah ini dalam bahasa yang seimbang, tidak terlalu formal maupun terlalu tulus. Pertahankan maknanya. Balas hanya dengan kesimpulan: - Tulis ulang teks di bawah ini dengan cara yang lebih tulus, hangat, komunikatif, dan ramah. Pertahankan maknanya. Balas hanya dengan kesimpulan: - - Seorang pengguna aplikasi pencatat sedang mengerjakan catatan berjudul \"%1$s\". Sarankan beberapa ide kreatif dan inspiratif atau titik awal yang sesuai dengan judul ini. Buat jawaban Anda singkat dan dalam format daftar. - Lanjutkan teks di bawah ini, dengan menjaga integritas makna dan gaya penulis. Balas hanya dengan teks baru, jangan ulangi teks yang sudah ada:\n\n---\n\n\nJudul: %1$s\n\nIsi:\n%2$s - Analisis teks di bawah ini dan tulis sebuah paragraf tentang cara mendekati topik ini dari perspektif yang sama sekali berbeda. Jangan meringkas teks yang sudah ada, tawarkan perspektif alternatif:\n\n---\n\n\nJudul: %1$s\n\nIsi:\n%2$s - Analisis teks di bawah ini dan daftarkan poin positif dan negatif di bawah judul \'### Pro\' dan \'### Kontra\'. Jawab hanya dengan dua judul dan item ini:\n\n\n---\n\n\n\n%1$s - Analisis teks di bawah ini. Jika teks menyebutkan tugas yang harus dilakukan di siang hari, ubah menjadi daftar tugas dengan meletakkan \'[] \' di awal setiap baris. Jika teks tidak menyebutkan tugas, jawab HANYA \'NO_TASKS\' tanpa mengetik apa pun. Deteksi bahasa teks dan buat daftar dalam bahasa itu:\n\n---\n\n%1$s - Terjemahkan teks kompleks di bawah ini ke dalam bahasa yang sederhana dan lancar yang dapat dipahami semua orang. Pertahankan ide utama dan detail penting dari teks tersebut. Jawab hanya dengan teks yang disederhanakan:\n\n---\n\n%1$s - Sarankan 5 judul kreatif dan menarik yang paling sesuai dengan isi teks di bawah ini. Berikan jawaban Anda sebagai daftar bernomor:\n\n---\n\n%1$s - - Seorang pengguna ingin membuat draf catatan tentang topik \"%1$s\". Anda HARUS memberikan jawaban Anda dalam format berikut: JUDUL: [masukkan judul di sini]\n\nISI: [masukkan isi catatan di sini] + Tampilkan Detail Kesalahan + Unduhan Gagal + Tampilkan Kesalahan + Pembaruan Manual + Periksa halaman setelan untuk detailnya + Perangkat Lunak Terbaru + + UI Aplikasi + Pilih salah satu dari 3 desain yang berbeda + Pemilihan Versi UI + Desain aplikasi versi lama. (Aplikasi, Pemutar, MiniPlayer) + Desain ringkas yang dibuat dengan pendekatan yang lebih modern. (Aplikasi, Pemutar, MiniPlayer) + Desain paling populer. (Aplikasi, Pemutar, MiniPlayer) + Buat desain UI aplikasi Anda sendiri. (Aplikasi, Pemutar, MiniPlayer) + Kustomisasi UI + + Setelan lainnya + Klik untuk berbagai setelan lainnya + + Secara otomatis menyesuaikan kualitas antara 45 dan 512 Kbps + Menawarkan kualitas tertinggi hingga 512 Kbps + Menetapkan kualitas untuk kecepatan internet rata-rata antara 128 dan 256 Kbps. + Membatasi kualitas antara 45 dan 52 Kbps untuk menghemat data (Penghemat Data) + + Tidak dapat mengimpor + + Perataan Teks + Atur posisi teks pada kartu + Intensitas Gradien + Sudut Bulat + Gambar Profil + Kartu Profil + Hapus Gambar Profil + Tampilkan Teks + Tampilkan teks sapaan dan nama pengguna pada kartu profil + Ketuk untuk mengubah + + Terjemahkan (BETA) + Salin + + Kesalahan Jaringan + Periksa koneksi internet Anda dan coba lagi. + Konten Tidak Didukung + Format lagu ini tidak didukung di perangkat Anda. + Kesalahan Siaran Langsung + Ada masalah dengan siaran langsung. Silakan mulai ulang. + Pemutaran Gagal + Terjadi kesalahan yang tidak diketahui. Silakan coba lagi. + Mencoba lagi… + Menggunakan VPN dapat memengaruhi kualitas koneksi Anda. Jika Anda mengalami masalah jaringan, putuskan koneksi VPN Anda atau gunakan proksi. Anda dapat mengonfigurasinya di “Setelan >> Konten >> Proksi.” + + Sinkronisasi lagu lokal berhasil + Pemindai lokal + + Pindai secara otomatis + Pindai lagu secara otomatis saat membuka aplikasi + + Setelan pemindai tambahan + Nama file yang ketat + Saat diaktifkan, ekstensi TIDAK akan diabaikan. Cth. "Song.ogg" akan menjadi lagu yang berbeda dari "Song.flac" + Konfigurasi sensitivitas pemindai + Cocokkan judul + Cocokkan judul dan artis + Cocokkan judul, artis, album + + Folder + Ratakan subfolder + Nonaktifkan untuk mempertahankan struktur file seperti di disk + + Tips & Fitur + Tips · Fitur + Buat cerita Anda + Bagikan lagu yang sedang Anda dengarkan di media sosial dengan desain gaya yang dibuat dengan warna dominan sampul album. + Setelan yang Dapat Disesuaikan + Jadikan aplikasi milik Anda dengan mengganti latar belakang kartu judul di layar setelan dengan gambar dari galeri Anda. + Daftar Lagu Baru + Lihat playlist Anda saat ini dan beralih antar lagu dengan mudah dengan menggeser ke atas dari layar pemutar (desain baru eksklusif untuk UI Pemutar 2.0) + Desain Setelan yang Diperbarui + Temukan apa yang Anda cari dengan mudah dengan desain setelan berbasis kartu baru yang lebih modern dan nyaman. + Widget Musik Klasik + Akses semua kontrol dan informasi lagu dengan cepat dari layar beranda Anda. + Widget Musik Minimalis + Widget musik “Quik” yang terinspirasi dari desain tombol Pengaturan Cepat Android 16 + Bagikan di Story + Salin tautan lagu + Tautan disalin ke papan klip! + Distreaming di Loudly + + Pilih Semua + Mengambil semua playlist… + Impor Item yang Dipilih + Terjadi kesalahan. Kami sama bingungnya dengan Anda. + Yah, itu memalukan. Kami juga tidak tahu apa yang terjadi. + Petunjuk untuk mendapatkan kredensial yang diperlukan + 3. Pastikan untuk mengklik "Tambah" setelah Anda memasukkan path lokal di atas. + 5. Terima persyaratan layanan dan klik "Simpan". + Anda akan diarahkan ke halaman baru. Klik Pengaturan, lalu salin ID Klien dan Rahasia Klien, dan tempelkan ke kolom teks di bawah ini di aplikasi ini. + 2. Masukkan detail yang diperlukan dan gunakan http://127.0.0.1:45454 sebagai URI Pengalihan. + 4. Alihkan API Web. Kotak centang sekarang seharusnya memiliki tanda centang. + Sekarang, klik tombol Otorisasi dan Lanjutkan di bawah ini dan login dengan akun Anda yang ingin Anda impor, dan Otorisasi dan Lanjutkan, yang akan mengarahkan Anda ke situs yang seharusnya dimulai dari http://127.0.0.1:45454/?code=\n\nTempelkan seluruh URL tersebut di kolom teks di bawah yang bertuliskan Kode Otorisasi dan klik tombol Otentikasi + 1. Kunjungi dasbor pengembang Spotify dan klik "Buat aplikasi". + Login dengan Kredensial API Spotify + Otorisasi dan Lanjutkan + Kode Otorisasi + Otentikasi + Rahasia klien + ID Klien + Impor sedang berlangsung… + Jangan tutup aplikasi atau kembali. Operasi ini tidak berjalan di latar belakang, jadi tetaplah di sini sampai selesai!\nJANGAN PANIK JIKA TERLIHAT MACET; terkadang pengambilan mungkin memakan waktu. + Pilih Tujuan "Lagu yang Disukai" + Ke mana lagu yang disukai harus diimpor? + Playlist baru bernama "Lagu yang Disukai" + Di "Lagu yang Disukai" default \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 74b018f..137d029 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,146 +1,636 @@ - AirNote - Note - Nuova nota - Nessuna nota creata. - Nessuna nota trovata. - Data creazione - Parole - Caratteri - Nome - Descrizione + + Benvenuto in Loudly + Iniziamo a personalizzare la tua esperienza musicale. + YouTube Music a portata di mano + Sincronizzazione account YT Music + Riproduzione senza PUBBLICITÀ + Esperienza musicale offline + Supporto per Android Auto + Supporto stato Discord + Completamente open source + Seleziona la tua interfaccia del lettore + Seleziona la visualizzazione del lettore in-app. Puoi modificare questa impostazione in seguito da “Impostazioni > Aspetto”. + Configurazione completata! + Sei pronto per esplorare Loudly. + Fine + Indietro + Avanti + Precedente + + Home + Esplora + Brani + Artisti + Album + Playlist + + Musica che ti piace + Mi piace + Musica offline + La mia musica top + Musica in cache + Musica locale + + Filtro contenuti + + Sempre + Ultime 24 ore + Ultima settimana + Ultimo mese + Ultimo anno + + + %d selezionato + %d selezionati + + + Buongiorno + Buon pomeriggio + Buonasera + Buonanotte + Attività recente + Cronologia + La tua cronologia apparirà qui + Le tue statistiche appariranno qui + Statistiche + Mostra meno + Mostra di più + Atmosfere e generi + Account + Scelte rapide + Ascolta brani per generare le tue scelte rapide + Preferiti dimenticati + Continua ad ascoltare + Le tue playlist di YouTube + Simile a + Album di nuova uscita + Nessuna connessione Internet + Sembra che tu non abbia una connessione Internet. Controlla la tua rete. + In primo piano per te + Consigliati per te oggi + + Oggi + Ieri + Questa settimana + La settimana scorsa + Locale + Remota + Cronologia locale + Cronologia online + + Brani più riprodotti + Artisti più riprodotti + Album più riprodotti + Cerca - Impostazioni - Schermo + Cerca in Loudly + Cerca nella libreria… + Cronologia ricerche + Suggerimenti + Risultati della ricerca + Libreria + Preferiti + Scaricati + Tutti + Brani + Video + Album + Artisti + Playlist + Playlist della community + Playlist in primo piano + Aggiunti ai preferiti + Nessun risultato trovato + + La tua libreria apparirà qui + I brani della libreria appariranno qui + Gli artisti della libreria appariranno qui + Gli album della libreria appariranno qui + Le tue playlist appariranno qui + + Dalla tua libreria + + Altre versioni + Preferito + Brani più ascoltati + + Brani che ti piacciono + Brani scaricati + La playlist è vuota + Vuoi davvero rimuovere tutti i brani della playlist \"%s\" dalla memoria dei brani scaricati? + Vuoi davvero eliminare la playlist \"%s\"? + + Disattivato + + Riprova + Radio + Casuale + Ripristina + + Dettagli + Modifica + Avvia radio + Riproduci + Riproduci dopo + Aggiungi alla coda + Aggiungi alla libreria + Aggiungi tutto alla libreria + Rimuovi dalla libreria + Rimuovi tutto dalla libreria + Scarica + Download in corso + Rimuovi download + Importa playlist + Aggiungi a playlist + Vedi artista + Vedi album + Ricarica + Condividi + Elimina + Rimuovi dalla cronologia + Rimuovi dalla playlist + Rimuovi dalla coda + Rimuovi dalla cache + Esporta come file M3U + Cerca online + Sincronizza + Avanzate + Tempo e Tonalità + + Data di aggiunta + Data di aggiornamento + Nome + Artista + Anno + Numero di brani + Durata + Tempo di riproduzione + Ordine personalizzato + + ID media + Tipo MIME + Codec + Bitrate + Frequenza di campionamento + Volume percepito + Volume + Dimensione file + Sconosciuto + Copiato negli appunti + + Modifica testo + Cerca testo + Vuoi davvero eliminare il testo salvato per \"%s\"? + + Modifica brano + Titolo del brano + Artisti del brano + Il titolo del brano non può essere vuoto. + L\'artista del brano non può essere vuoto. + Salva + + Sincronizza Playlist + Scegli playlist + Modifica playlist + Crea playlist + Nome playlist + Il nome della playlist non può essere vuoto. + + Modifica artista + Nome artista + Il nome dell\'artista non può essere vuoto. + + Duplicati + Salta duplicati + Aggiungi comunque + Il brano è già nella tua playlist + %d brani sono già nella tua playlist + Nota: questo permette la sincronizzazione con YouTube Music + + + %d brano + %d brani + + + %d artista + %d artisti + + + %d album + %d album + + + %d playlist + %d playlist + + + %d settimana + %d settimane + + + %d mese + %d mesi + + + %d anno + %d anni + + + Playlist importata + Rimosso \"%s\" dalla playlist + Playlist sincronizzata + Annulla + + In riproduzione + Testo non trovato + Timer di spegnimento + Fine del brano + + 1 minuto + %d minuti + + Nessuno streaming disponibile + Nessuna connessione di rete + Timeout + Errore sconosciuto + Menu + Condividi lista + + 5 secondi indietro + 5 secondi avanti + + Mi piace + Mi piace a tutti + Rimuovi Mi piace + Rimuovi tutti i Mi piace + Riproduzione casuale attiva + Riproduzione casuale disattivata + Modalità di ripetizione disattivata + Ripeti brano corrente + Ripeti coda + + Tutti i brani + Brani cercati + + Lettore musicale + + Notifiche + Impedisci al sistema di chiudere l\'app + Usa una notifica in primo piano per impedire che Loudly venga chiuso casualmente dal sistema. + È richiesta l\'autorizzazione per le notifiche + + Impostazioni + Aspetto + Tema + Proxy + Abilita tema dinamico Tema scuro - Colori dinamici - Colori amoled - Database + Attivo + Disattivato + Segui il sistema + Nero puro + Design dell\'app + Interfaccia App 1.0 + Interfaccia App 1.5 + Ascolta su YouTube Music + Visita Loudly + Artista + Ascolta Loudly + Abilita lo scorrimento sulla miniatura per cambiare brano + Personalizza schede di navigazione + Lettore + Allineamento testo del lettore + Posizione testo del brano + Laterale + Sinistra + Centro + Stile del lettore + Interfaccia Lettore Vecchia + Interfaccia Lettore 1.0 + Interfaccia Lettore 2.0 + Segui il tema + Gradiente + Sfocatura + Sfocatura (Movimento) + Stile sfondo del lettore + Destra + Stile della barra di scorrimento + Predefinito + Ondulato + Compose + Configura raggio angoli miniatura + Mostra filtro contenuti + Varie + Principale + Personalizzazione playlist automatiche + Mostra playlist automatica dei preferiti + Mostra playlist automatica dei download + Mostra playlist automatica dei top + Mostra playlist automatica della cache + Mostra playlist automatica locale + Scorri a destra per il brano successivo o a sinistra per aggiungere alla coda + Scheda predefinita all\'apertura + Filtri predefiniti della libreria + Dimensione celle della griglia + Mostra etichette della barra di navigazione inferiore + Piccolo + Grande + Changelog + Nessuna modifica recente trovata + Modifiche recenti: + Chiudi + Nuova versione disponibile + Ciao di nuovo + Dona + Canale ufficiale + Sito web + Sito web ufficiale ❯ + Codice sorgente + Accedi al codice sorgente dell\'app + Problemi + Segnalazione bug e altro + Aiuto + Se vuoi contribuire, clicca qui ❯ + Sviluppatore | @RRechz + + Contenuti + Accedi + Accedi alle tue playlist di YT Music e altro + Tocca per mostrare il token + Tocca di nuovo per copiare o modificare + Questo è un metodo di accesso AVANZATO. In alternativa al portale web, puoi inserire o aggiornare direttamente il tuo token di accesso qui. Ad esempio, questo può velocizzare l\'accesso su più dispositivi. Tieni presente che eventuali formati di token non validi che l\'app non riesce ad analizzare non saranno accettati. + Importa da Spotify + Spotify + Discord + Scarica automaticamente i brani che ti piacciono + Sincronizza automaticamente con il tuo account YouTube Music + Sincronizza le tue playlist e altro con il tuo account + Solo Wi-Fi + Accesso non effettuato + Lingua dei contenuti predefinita + Paese dei contenuti predefinito + Predefinito di sistema + Abilita proxy + Tipo di proxy + URL del proxy + Riavvia per rendere effettive le modifiche + Lingua dell\'app + Configura le lingue dell\'app + Impossibile trovare le impostazioni della lingua dell\'app, configurala manualmente + Apri i link supportati per impostazione predefinita + Configura i link supportati + Impossibile trovare le impostazioni dei link supportati, configurali manualmente + + Lettore e Audio + Qualità audio + Massima + Automatica + Alta + Bassa + Durata minima di riproduzione + La durata minima di un brano che deve essere riprodotta prima che venga considerato \"riprodotto\" + Coda + Coda persistente + Ripristina la tua ultima coda all\'avvio dell\'app + Carica automaticamente altri brani + Aggiungi automaticamente altri brani quando si raggiunge la fine della coda, se possibile + Salta il silenzio + Normalizzazione audio + Continua la riproduzione quando il dispositivo Bluetooth è connesso + Interrompi la riproduzione del brano al volume minimo + Aggiunta dei brani riprodotti alla cronologia di YouTube Music + Passa automaticamente al brano successivo in caso di errore + Assicura la tua esperienza di riproduzione continua + Interrompi la musica alla chiusura delle attività + Equalizzatore + Testi + Testi + Impostazioni del lettore locale + Archiviazione + Cache + Cache immagini + Cache brani + Dimensione massima della cache + Illimitata + Abilita offload + Utilizza il percorso audio offload per la riproduzione audio. La disattivazione può aumentare il consumo energetico ma può essere utile in caso di problemi con la riproduzione o l\'elaborazione audio. + Cancella tutti i download + Dimensione massima della cache immagini + Svuota la cache delle immagini + Dimensione massima della cache dei brani + Svuota la cache dei brani + utilizzati + Sei sicuro di voler svuotare tutta la cache? + Sei sicuro di voler cancellare tutti i download? + Sei sicuro di voler svuotare tutti i brani in cache? + Sei sicuro di voler svuotare tutte le immagini in cache? + Sei sicuro di voler svuotare tutti i modelli di traduzione in cache? + + Privacy + Cronologia di ascolto + Metti in pausa la cronologia di ascolto + Cancella la cronologia di ascolto + Sei sicuro di voler cancellare tutta la cronologia di ascolto? + Metti in pausa la cronologia delle ricerche + Cancella la cronologia delle ricerche + Sei sicuro di voler cancellare tutta la cronologia delle ricerche? + Cronologia ricerche + Usa l\'accesso per sfogliare i contenuti + Questo può influenzare i contenuti che vedi e, ad esempio, mostrare album solo per account Premium se hai effettuato l\'accesso con un account Premium. + Disabilita screenshot + Quando questa opzione è attiva, gli screenshot e la visualizzazione dell\'app in Recenti sono disabilitati. + Abilita il fornitore di testi LrcLib + Abilita il fornitore di testi KuGou + Fornitore di testi predefinito + Leggi testi su più righe + Considera tutte le righe tra i punti di sincronizzazione come un unico testo + Rimuovi spazi intorno ai testi + Dimensione carattere + Nascondi contenuti espliciti + Abilita notifiche + + Backup e ripristino Backup - Ancora non supportato + Ripristino + Importa una playlist (m3u) + Impossibile importare la playlist + Playlist importata + Backup creato con successo + Impossibile creare il backup + Impossibile ripristinare il backup + + Integrazione Discord + Loudly utilizza la libreria KizzyRPC per impostare lo stato del tuo account Discord. Ciò comporta l\'uso della connessione Discord Gateway, che potrebbe essere considerata una violazione dei Termini di Servizio di Discord. Tuttavia, non ci sono casi noti di sospensione di account utente per questo motivo. Usare a proprio rischio.\n\nLoudly estrarrà solo il tuo token, e tutto il resto viene memorizzato localmente. + Ignora + Opzioni + Anteprima + Accesso fallito + Esci + Abilita Rich Presence + Informazioni - Versione - Ultimo rilascio - Supporta il progetto - Donami :) - Ripristina - Note - Tema del sistema - Cloud - Mostra il tuo amore - Visualizzazione griglia - Vista colonna - Informazione - Markdown - Lingua - Supporto - Colori e stili - Strumenti - Cronologia - Widget - Temi • Dimensioni • Caratteri - Creato • Elimina tutto - Cronologia • Ripristina - Versione • Sviluppatori • Licenza - Widget • Markdown - Supporto • Immagini - Caratteri • Lingua - NextCloud • locale • Crittografia - Font - Raggio - Seleziona il raggio - Modalità minimalismo - Colori Amoled estremi - Seleziona tutto - Elimina - Comportamento - Entra in modalità modifica nelle note esistenti - Priorità alla modalità modifica - Password non valida - Cryptovaluta - Privacy - Protezione Schermo - Supporto immagini - Instabile - Adatta il tema dell\'app secondo le impostazioni del sistema - Alterna tra colori chiari e scuri - Utilizza il colore nero come sfondo - Seleziona colori a seconda dello sfondo - Ottimizzazione estrema della batteria - Personalizza il raggio dell\'angolo - Salva tutte le note in un file - Rimuovi i bottoni superiori dalla schermata di modifica - Codice sorgente - Elenco dei sostenitori - Tipo di build - Email - Discord - Richiedi aggiunte - In evidenza - Cifra il database - Inserisci password - La password non è valida o il database non è cifrato - Input invalido - Ordine ascendente - Ordine decrescente - Ordina note per data di creazione - Cambia la lingua dell\'app - Linga del sistema + Versione dell\'app + Verifica aggiornamenti... + versione %1$s disponibile per il download. + Download in corso... + Aggiorna + Riprova + Scarica ora + Tipo di app + + Nuova versione disponibile + Modelli di traduzione + Cancella modelli di traduzione + Durata della cronologia + + 1 secondo + %d secondi + + Musica 3x2 + Musica 2x1 + Riproduci/Pausa + Versione · Sviluppatori · Licenza + Interfaccia + Contenuti e Account + Lettore + Sistema + Libreria + Integrazioni + Integrazione di sistema + Spazio totale utilizzato + Attualmente in uso + Svuota cache + Backup · Ripristino · Importazione + Cronologia · Ricerca · Screenshot + Download · Cache · Immagini + Suono · Coda · Testi + Lingua · Posizione · Proxy + Accedi · Spotify · Discord + Tema · Slider · Lettore + + Immagine della scheda del profilo + Personalizza l\'immagine sulla scheda del profilo + Scegli immagine del profilo + Immagini predefinite + Seleziona dalla galleria + Modifica foto + Annulla + + Invia allo sviluppatore + Chiudi + Mostra dettagli errore + Download fallito + Mostra errore + Aggiornamento manuale + Controlla la pagina delle impostazioni per i dettagli + Software aggiornato + + Interfaccia dell\'app + Seleziona uno dei 3 diversi design + Selezione della versione dell\'interfaccia + Design della vecchia versione dell\'applicazione. (App, Lettore, MiniLettore) + Design compatto creato con un approccio più moderno. (App, Lettore, MiniLettore) + Design più popolare. (App, Lettore, MiniLettore) + Crea il tuo design dell\'interfaccia dell\'app. (App, Lettore, MiniLettore) + Personalizza l\'interfaccia + + Altre impostazioni + Clicca per altre varie impostazioni + + Regola automaticamente la qualità tra 45 e 512 Kbps + Offre la massima qualità fino a 512 Kbps + Imposta la qualità per velocità internet medie tra 128 e 256 Kbps. + Limita la qualità tra 45 e 52 Kbps per risparmiare dati (Risparmio dati) + + Impossibile importare + + Allineamento del testo + Imposta la posizione del testo sulla scheda + Intensità del gradiente + Arrotondamento degli angoli + Immagine del profilo + Scheda del profilo + Rimuovi immagine del profilo + Mostra testi + Visualizza il testo di saluto e il nome utente sulla scheda del profilo + Tocca per modificare + + Traduci (BETA) Copia - Seleziona lo stile di visualizzazione - Cifra il database durante il backup - Ripristina le note da un file di backup - Attiva il formato markdown nelle note - Nascondi la schermata nel menù \"recenti\" e disabilita gli screenshots - Altri - Seleziona nota - Dimensioni dell\'immagine non supportata (massimo 15mb ) - Cassaforte - Attiva cifratura delle note nel vault - continua - 1. Accettazione dei Termini - Premendo Accetto e utilizzando Easy Notes, si accettano i presenti Termini di servizio. Se non si accettano, non utilizzare l\'applicazione. - 2. Licenza - 3. Utilizzo dell\'applicazione - 4. Proprietà Intellettuale - 5. Esclusione di Garanzia - Easy Notes viene fornita così com\'è, senza alcuna garanzia. Non garantiamo che l\'applicazione sia priva di errori, sicura o ininterrotta. - 6. Limiti di responsabilità - Non siamo responsabili di alcun danno che risulti dall\'uso di Easy Notes, inclusi danni diretti, incidentali o consequenziali. - 7. Terminazione - Possiamo aggiornare i presenti Termini di servizio di tanto in tanto. L\'uso continuato di Easy Notes dopo eventuali modifiche costituisce l\'accettazione dei termini rivisti. - 9. Contattaci - 10. Informativa sulla Privacy - Data Effettiva - D\'accordo - Termini di Servizio - Impossibile importare - Importazione di alcuni documenti fallita - Importato correttamente - Importa - Importa altri documenti di testo come note - Easy Notes è distribuito secondo la **GNU General Public License (GPL) versione 3**. Ciò significa che: -\n -\n- L\'utente è libero di utilizzare, modificare e condividere l\'applicazione. -\n- Se si distribuisce l\'applicazione o qualsiasi versione modificata, è necessario distribuire anche il codice sorgente e mantenere la licenza GPL. - L\'utente accetta di utilizzare Easy Notes solo per scopi leciti e in conformità alle presenti Condizioni. L\'utente **non deve **: -\n -\n- _Utilizzare l\'applicazione per qualsiasi scopo illegale_. -\n- Violare i diritti degli altri -\n- _Interrompere la funzionalità o la sicurezza dell\'applicazione_ - Tutti i diritti di proprietà intellettuale di Easy Notes sono di nostra proprietà o dei nostri licenziatari. L\'uso dell\'applicazione non conferisce all\'utente la proprietà di alcun diritto di proprietà intellettuale. - Potremmo terminare o sospendere l\'accesso a Easy Notes in caso di infrazione di questi Termini. In caso di terminazione, il diritto di utilizzo dell\'app cesserà con effetto immediato. - 8. Cambiamenti dei Termini - Per qualsiasi domanda sui Termini di Servizio o Easy Notes, contattaci a - Easy Notes non conserva alcun dato personale. Per maggiori informazioni, fare riferimento alla nostra Informativa sulla Privacy disponibile a - Dona - Traduzione - Contributo - Elimina le immagini dalla memoria dell\'app quando vengono rimosse dalla galleria - Sincronizzazione della galleria - Mostra solo il titolo - Mostra il titolo solo nell\'anteprima - Utilizzare il carattere monospazio durante la modifica della nota - Carattere monospazio + + Errore di rete + Controlla la tua connessione internet e riprova. + Contenuto non supportato + Questo formato di brano non è supportato sul tuo dispositivo. + Errore trasmissione in diretta + C\'è un problema con lo streaming in diretta. Riavvia. + Riproduzione fallita + Si è verificato un errore sconosciuto. Riprova. + Nuovo tentativo… + L\'uso di una VPN può influire sulla qualità della connessione. Se riscontri problemi di rete, disconnetti la VPN o usa un proxy. Puoi configurarlo in “Impostazioni >> Contenuti >> Proxy.” + + Brani locali sincronizzati con successo + Scanner locale + + Scansiona automaticamente + Scansiona automaticamente i brani all\'apertura dell\'app + + Impostazioni aggiuntive dello scanner + Nomi dei file rigorosi + Quando attivato, le estensioni NON verranno ignorate. Es. "Song.ogg" sarà un brano diverso da "Song.flac" + Configura la sensibilità dello scanner + Corrispondenza titolo + Corrispondenza titolo e artisti + Corrispondenza titolo, artisti, album + + Cartelle + Appiattisci sottocartelle + Disabilita per preservare la struttura dei file come sul disco + + Suggerimenti e Funzionalità + Suggerimenti · Funzionalità + Crea la tua storia + Condividi il brano che stai ascoltando sui social media con un design elegante creato con i colori dominanti della copertina dell\'album. + Impostazioni personalizzabili + Rendi l\'app tua sostituendo lo sfondo della scheda del titolo nella schermata delle impostazioni con un\'immagine dalla tua galleria. + Nuova lista di brani + Visualizza la tua playlist attuale e passa facilmente da un brano all\'altro scorrendo verso l\'alto dalla schermata del lettore (nuovo design esclusivo per l\'Interfaccia Lettore 2.0) + Design delle Impostazioni Rinnovato + Trova facilmente ciò che cerchi con un nuovo design delle impostazioni basato su schede, più moderno e comodo. + Widget Musica Classico + Accedi rapidamente a tutti i controlli e alle informazioni sui brani dalla tua schermata iniziale. + Widget Musica Minimalista + Widget musicale “Quik” ispirato al design del pulsante Impostazioni Rapide di Android 16 + Condividi sulla storia + Copia link del brano + Link copiato negli appunti! + In streaming su Loudly + + Seleziona tutto + Recupero di tutte le playlist… + Importa elementi selezionati + Qualcosa è andato storto. Siamo confusi quanto te. + Beh, è imbarazzante. Non abbiamo idea di cosa sia successo neanche noi. + Istruzioni per ottenere le credenziali richieste + 3. Assicurati di cliccare su \"Aggiungi\" dopo aver inserito il percorso locale sopra. + 5. Accetta i termini di servizio e clicca su \"Salva\". + Verrai reindirizzato a una nuova pagina. Clicca su Impostazioni, quindi copia l\'ID Cliente e il Segreto Cliente e incollali nei campi di testo sottostanti in questa app. + 2. Inserisci i dettagli necessari e usa http://127.0.0.1:45454 come URI di reindirizzamento. + 4. Attiva l\'API Web. La casella di controllo dovrebbe ora avere un segno di spunta. + Ora, clicca sul pulsante Autorizza e Continua qui sotto e accedi con l\'account da cui vuoi importare, e Autorizza e Continua, che ti reindirizzerà a un sito che dovrebbe iniziare con http://127.0.0.1:45454/?code=\n\nIncolla l\'intero URL nel campo di testo sottostante che dice Codice di Autorizzazione e clicca sul pulsante Autentica. + 1. Visita la dashboard per sviluppatori di Spotify e clicca su \"Crea un\'app\". + Accedi con le credenziali API di Spotify + Autorizza e Continua + Codice di Autorizzazione + Autentica + Segreto cliente + ID Cliente + Importazione in corso… + Non chiudere l\'app o tornare indietro. Questa operazione non viene eseguita in background, quindi attendi fino al termine!\nNON ANDARE IN PANICO SE SEMBRA BLOCCATO; a volte il recupero può richiedere del tempo. + Scegli la destinazione per i \"Brani che ti piacciono\" + Dove dovrebbero essere importati i brani che ti piacciono? + Una nuova playlist chiamata \"Brani che ti piacciono\" + Nei \"Brani che ti piacciono\" predefiniti \ No newline at end of file diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml new file mode 100644 index 0000000..eecf6a0 --- /dev/null +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -0,0 +1,626 @@ + + + + Loudly へようこそ + 音楽体験のパーソナライズを始めましょう。 + YouTube Music を指先ひとつで + YT Music アカウントの同期 + 広告なしの再生 + オフラインでの音楽体験 + Android Auto をサポート + Discord ステータスをサポート + 完全オープンソース + プレーヤーのUIを選択 + アプリ内のプレーヤー表示を選択します。この設定は後で「設定 > 外観」で変更できます。 + 設定が完了しました! + Loudly を探索する準備ができました。 + 完了 + 戻る + 次へ + 前へ + + ホーム + 探索 + + アーティスト + アルバム + 再生リスト + + 高く評価した音楽 + 高く評価 + オフライン音楽 + トップミュージック + キャッシュされた音楽 + ローカルの音楽 + + コンテンツフィルター + + 全期間 + 過去24時間 + 過去1週間 + 過去1か月 + 過去1年 + + + %d 件を選択中 + + + おはようございます + こんにちは + こんばんは + おやすみなさい + 最近のアクティビティ + 履歴 + 再生履歴はここに表示されます + 統計はここに表示されます + 統計 + 少なく表示 + もっと表示 + 気分とジャンル + アカウント + クイックピック + 曲を聴いてクイックピックを生成しましょう + 忘れていたお気に入り + 続きを聴く + あなたのYouTube再生リスト + 次と類似 + ニューリリースアルバム + インターネット接続がありません + インターネットに接続されていないようです。ネットワークを確認してください。 + あなたへのおすすめ + 今日のおすすめ + + 今日 + 昨日 + 今週 + 先週 + ローカル + リモート + ローカル履歴 + リモート履歴 + + 最も再生された曲 + 最も再生されたアーティスト + 最も再生されたアルバム + + 検索 + Loudlyで検索 + ライブラリを検索… + 検索履歴 + 候補 + 検索結果 + ライブラリ + 高く評価 + ダウンロード済み + すべて + + 動画 + アルバム + アーティスト + 再生リスト + コミュニティの再生リスト + おすすめの再生リスト + ブックマーク済み + 結果が見つかりませんでした + + ライブラリはここに表示されます + ライブラリの曲はここに表示されます + ライブラリのアーティストはここに表示されます + ライブラリのアルバムはここに表示されます + あなたの再生リストはここに表示されます + + ライブラリから + + 他のバージョン + お気に入り + トップソング + + 高く評価した曲 + ダウンロード済みの曲 + 再生リストは空です + 本当に「%s」の再生リストの全曲をダウンロード済みストレージから削除しますか? + 本当に「%s」の再生リストを削除しますか? + + 無効 + + 再試行 + ラジオ + シャッフル + リセット + + 詳細 + 編集 + ラジオを開始 + 再生 + 次に再生 + キューに追加 + ライブラリに追加 + すべてライブラリに追加 + ライブラリから削除 + すべてライブラリから削除 + ダウンロード + ダウンロード中 + ダウンロードを削除 + 再生リストをインポート + 再生リストに追加 + アーティストを表示 + アルバムを表示 + 再取得 + 共有 + 削除 + 履歴から削除 + 再生リストから削除 + キューから削除 + キャッシュから削除 + M3Uファイルとしてエクスポート + オンラインで検索 + 同期 + 詳細設定 + テンポとピッチ + + 追加日 + 更新日 + 名前 + アーティスト + + 曲数 + 長さ + 再生時間 + カスタム順 + + メディアID + MIMEタイプ + コーデック + ビットレート + サンプルレート + ラウドネス + 音量 + ファイルサイズ + 不明 + クリップボードにコピーしました + + 歌詞を編集 + 歌詞を検索 + 本当に「%s」の保存された歌詞を削除しますか? + + 曲を編集 + 曲名 + 曲のアーティスト + 曲名は空にできません。 + 曲のアーティストは空にできません。 + 保存 + + 再生リストを同期 + 再生リストを選択 + 再生リストを編集 + 再生リストを作成 + 再生リスト名 + 再生リスト名は空にできません。 + + アーティストを編集 + アーティスト名 + アーティスト名は空にできません。 + + 重複 + 重複をスキップ + それでも追加 + この曲はすでに再生リストにあります + %d 曲はすでに再生リストにあります + 注:これによりYouTube Musicとの同期が可能になります + + + %d 曲 + + + %d 人のアーティスト + + + %d 枚のアルバム + + + %d 個の再生リスト + + + %d 週間 + + + %d か月 + + + %d 年 + + + 再生リストがインポートされました + 「%s」を再生リストから削除しました + 再生リストが同期されました + 元に戻す + + 再生中 + 歌詞が見つかりません + スリープタイマー + 曲の終わり + + %d 分 + + 利用可能なストリームがありません + ネットワーク接続がありません + タイムアウト + 不明なエラー + メニュー + リストを共有 + + 5秒戻る + 5秒進む + + 高く評価 + すべてを高く評価 + 高評価を削除 + すべての高評価を削除 + シャッフル オン + シャッフル オフ + リピート オフ + 現在の曲をリピート + キューをリピート + + すべての曲 + 検索された曲 + + 音楽プレーヤー + + 通知 + システムによるアプリの強制終了を防ぐ + フォアグラウンド通知を使用して、Loudlyがシステムによってランダムに終了されるのを防ぎます。 + 通知の権限が必要です + + 設定 + 外観 + テーマ + プロキシ + ダイナミックテーマを有効にする + ダークテーマ + オン + オフ + システムに従う + 純粋な黒 + アプリのデザイン + アプリUI 1.0 + アプリUI 1.5 + YouTube Musicで聴く + Loudlyにアクセス + アーティスト + Loudlyを聴く + サムネイルのスワイプで曲を変更する + ナビゲーションタブをカスタマイズ + プレーヤー + プレーヤーのテキスト配置 + 歌詞のテキスト位置 + + + 中央 + プレーヤーのスタイル + 旧UIのプレーヤー + プレーヤーUI 1.0 + プレーヤーUI 2.0 + テーマに従う + グラデーション + ぼかし + ぼかし(モーション) + プレーヤーの背景スタイル + + スライダーのスタイル + デフォルト + 波線 + Compose + サムネイルの角の丸みを設定 + コンテンツフィルターを表示 + その他 + メイン + 自動再生リストのカスタマイズ + 「高く評価」の自動再生リストを表示 + 「ダウンロード」の自動再生リストを表示 + 「トップ」の自動再生リストを表示 + 「キャッシュ済み」の自動再生リストを表示 + 「ローカル」の自動再生リストを表示 + 右にスワイプで次の曲、左にスワイプでキューに追加 + デフォルトで開くタブ + デフォルトのライブラリフィルター + グリッドのセルサイズ + 下部ナビゲーションバーのラベルを表示 + + + 変更履歴 + 最近の変更は見つかりませんでした + 最近の変更: + 閉じる + 新しいバージョンが利用可能です + こんにちは + 寄付 + 公式チャンネル + ウェブサイト + 公式サイト ❯ + ソースコード + アプリのソースコードにアクセス + 問題 + バグ報告など + ヘルプ + 貢献したい方はこちらをクリック ❯ + 開発者 | @RRechz + + コンテンツ + ログイン + YT Musicの再生リストなどにサインイン + タップしてトークンを表示 + もう一度タップしてコピーまたは編集 + これは高度なログイン方法です。ウェブポータルの代わりに、ここで直接ログイン トークンを入力または更新できます。これにより、複数のデバイスでのログインが高速化される場合があります。アプリが解析できない無効なトークン形式は受け付けられないことに注意してください。 + Spotifyからインポート + Spotify + Discord + 高く評価した曲を自動でダウンロード + YouTube Musicアカウントと自動で同期 + 再生リストなどをアカウントと同期 + Wi-Fiのみ + ログインしていません + デフォルトのコンテンツ言語 + デフォルトのコンテンツ国 + システムのデフォルト + プロキシを有効にする + プロキシの種類 + プロキシのURL + 有効にするには再起動してください + アプリの言語 + アプリの言語を設定 + アプリの言語設定が見つかりませんでした。手動で設定してください。 + サポートされているリンクをデフォルトで開く + サポートされているリンクを設定 + サポートされているリンクの設定が見つかりませんでした。手動で設定してください。 + + プレーヤーとオーディオ + 音質 + 最大 + 自動 + + + 最小再生時間 + 「再生済み」と見なされるまでに再生する必要がある曲の最小時間 + キュー + 永続的なキュー + アプリの起動時に最後のキューを復元 + さらに曲を自動で読み込む + 可能であれば、キューの終わりに達したときに自動的に曲を追加します + 無音部分をスキップ + オーディオの正規化 + Bluetoothデバイス接続時に再生を続ける + 最小音量で再生を停止 + 再生した曲をYouTube Musicの履歴に追加 + エラー発生時に自動で次の曲にスキップ + 途切れない再生体験を保証します + タスククリア時に音楽を停止 + イコライザー + 歌詞 + 歌詞 + ローカルプレーヤーの設定 + ストレージ + キャッシュ + 画像キャッシュ + 曲のキャッシュ + 最大キャッシュサイズ + 無制限 + オフロードを有効にする + オーディオ再生にオフロードパスを使用します。無効にすると電力消費が増える可能性がありますが、再生や後処理に問題がある場合に役立つことがあります。 + すべてのダウンロードをクリア + 最大画像キャッシュサイズ + 画像キャッシュをクリア + 最大曲キャッシュサイズ + 曲のキャッシュをクリア + 使用済み + すべてのキャッシュをクリアしてもよろしいですか? + すべてのダウンロードをクリアしてもよろしいですか? + すべてのキャッシュされた曲をクリアしてもよろしいですか? + すべてのキャッシュされた画像をクリアしてもよろしいですか? + すべてのキャッシュされた翻訳モデルをクリアしてもよろしいですか? + + プライバシー + 再生履歴 + 再生履歴を一時停止 + 再生履歴をクリア + すべての再生履歴をクリアしてもよろしいですか? + 検索履歴を一時停止 + 検索履歴をクリア + すべての検索履歴をクリアしてもよろしいですか? + 検索履歴 + ログインしてコンテンツを閲覧 + これにより、表示されるコンテンツが影響を受け、プレミアムアカウントでログインしている場合はプレミアム限定のアルバムなどが表示されます。 + スクリーンショットを無効にする + このオプションをオンにすると、スクリーンショットと「最近使ったアプリ」でのアプリ表示が無効になります。 + LrcLib歌詞プロバイダを有効にする + KuGou歌詞プロバイダを有効にする + デフォルトの歌詞プロバイダ + 複数行の歌詞を読む + 同期ポイント間のすべての行を1つの歌詞テキストとして扱います + 歌詞の周りのスペースを削除 + フォントサイズ + 露骨なコンテンツを非表示 + 通知を有効にする + + バックアップと復元 + バックアップ + 復元 + 再生リストをインポート (m3u) + 再生リストのインポートに失敗しました + インポートされた再生リスト + バックアップが正常に作成されました + バックアップの作成に失敗しました + バックアップの復元に失敗しました + + Discord連携 + LoudlyはKizzyRPCライブラリを使用してDiscordアカウントのステータスを設定します。これにはDiscord Gateway接続の使用が含まれ、Discordの利用規約に違反する可能性があります。ただし、この理由でユーザーアカウントが停止された既知のケースはありません。自己責任で使用してください。\n\nLoudlyはトークンのみを抽出し、それ以外はすべてローカルに保存されます。 + 閉じる + オプション + プレビュー + ログインに失敗しました + ログアウト + リッチプレゼンスを有効にする + + 概要 + アプリのバージョン + アップデートを確認中... + バージョン %1$s がダウンロード可能です。 + ダウンロード中... + アップグレード + 再試行 + 今すぐダウンロード + アプリの種類 + + 新しいバージョンが利用可能です + 翻訳モデル + 翻訳モデルをクリア + 履歴の期間 + + %d 秒 + + 音楽 3x2 + 音楽 2x1 + 再生/一時停止 + バージョン・開発者・ライセンス + UI + コンテンツとアカウント + プレーヤー + システム + ライブラリ + 連携 + システム連携 + 合計使用容量 + 現在使用中 + キャッシュをクリア + バックアップ・復元・インポート + 履歴・検索・スクリーンショット + ダウンロード・キャッシュ・画像 + サウンド・キュー・歌詞 + 言語・地域・プロキシ + ログイン・Spotify・Discord + テーマ・スライダー・プレーヤー + + プロフィールカードの画像 + プロフィールカードの画像をカスタマイズ + プロフィール画像を選択 + 既製の画像 + ギャラリーから選択 + 写真を編集 + キャンセル + + 開発者に送信 + 閉じる + エラーの詳細を表示 + ダウンロードに失敗しました + エラーを表示 + 手動アップデート + 詳細は設定ページを確認してください + ソフトウェアは最新です + + アプリUI + 3つの異なるデザインから1つを選択 + UIバージョンの選択 + アプリケーションの旧バージョンのデザイン。(アプリ、プレーヤー、ミニプレーヤー) + よりモダンなアプローチで作成されたコンパクトなデザイン。(アプリ、プレーヤー、ミニプレーヤー) + 最も人気のあるデザイン。(アプリ、プレーヤー、ミニプレーヤー) + 独自のアプリUIデザインを作成。(アプリ、プレーヤー、ミニプレーヤー) + UIをカスタマイズ + + その他の設定 + その他のさまざまな設定はこちらをクリック + + 品質を45〜512 Kbpsの間で自動調整 + 最大512 Kbpsの最高品質を提供 + 平均的なインターネット速度に合わせて128〜256 Kbpsの品質を設定 + データ節約のため品質を45〜52 Kbpsに制限(データセーバー) + + インポートできませんでした + + テキストの配置 + カード上のテキストの位置を設定 + グラデーションの強度 + 角の丸み + プロフィール写真 + プロフィールカード + プロフィール写真を削除 + テキストを表示 + プロフィールカードに挨拶とユーザー名を表示 + タップして変更 + + 翻訳(ベータ版) + コピー + + ネットワークエラー + インターネット接続を確認して、もう一度お試しください。 + サポートされていないコンテンツ + この曲の形式はお使いのデバイスではサポートされていません。 + ライブ配信エラー + ライブストリームに問題が発生しました。再起動してください。 + 再生に失敗しました + 不明なエラーが発生しました。もう一度お試しください。 + 再試行中… + VPNの使用は接続品質に影響を与える可能性があります。ネットワークに問題がある場合は、VPN接続を切断するか、プロキシを使用してください。これは「設定 >> コンテンツ >> プロキシ」で設定できます。 + + ローカルの曲を正常に同期しました + ローカルスキャナー + + 自動でスキャン + アプリを開いたときに自動で曲をスキャン + + 追加のスキャナー設定 + 厳密なファイル名 + 有効にすると、拡張子は無視されません。例:「Song.ogg」は「Song.flac」とは別の曲になります + スキャナーの感度を設定 + タイトルを照合 + タイトルとアーティストを照合 + タイトル、アーティスト、アルバムを照合 + + フォルダ + サブフォルダをフラット化 + ディスク上のファイル構造を維持するには無効にします + + ヒントと機能 + ヒント・機能 + ストーリーを作成 + 聴いている曲を、アルバムカバーの基調色で作成されたスタイリッシュなデザインでSNSにシェアしましょう。 + カスタマイズ可能な設定 + 設定画面のタイトルカードの背景をギャラリーの画像に置き換えて、アプリを自分だけのものにしましょう。 + 新しい曲リスト + 現在の再生リストを表示し、プレーヤー画面から上にスワイプするだけで簡単に曲を切り替えられます(プレーヤーUI 2.0専用の新デザイン) + リマスターされた設定デザイン + よりモダンで便利な新しいカードベースの設定デザインで、探しているものを簡単に見つけましょう。 + クラシック音楽ウィジェット + ホーム画面からすべてのコントロールと曲情報にすばやくアクセス。 + ミニマリスト音楽ウィジェット + Android 16のクイック設定ボタンのデザインに触発された「Quik」音楽ウィジェット + ストーリーで共有 + 曲のリンクをコピー + リンクをクリップボードにコピーしました! + Loudlyでストリーミング + + すべて選択 + すべての再生リストを取得中… + 選択した項目をインポート + 問題が発生しました。私たちもあなたと同じように困惑しています。 + これは恥ずかしい。何が起こったのか、私たちにも見当がつきません。 + 必要な資格情報の取得方法 + 3. 上記のローカルパスを入力したら、必ず「追加」をクリックしてください。 + 5. 利用規約に同意し、「保存」をクリックします。 + 新しいページにリダイレクトされます。設定をクリックし、クライアントIDとクライアントシークレットをコピーして、このアプリの下のテキストフィールドに貼り付けます。 + 2. 必要な詳細を入力し、リダイレクトURIとして http://127.0.0.1:45454 を使用します。 + 4. Web APIを切り替えます。チェックボックスにチェックマークが付いているはずです。 + 下の「承認して続行」ボタンをクリックし、インポートしたいアカウントでログインして、承認して続行します。すると、http://127.0.0.1:45454/?code= で始まるサイトにリダイレクトされます。\n\nそのURL全体を下の「認証コード」というテキストフィールドに貼り付けて、「認証」ボタンをクリックしてください。 + 1. Spotify for Developersダッシュボードにアクセスし、「アプリを作成」をクリックします。 + Spotify API資格情報でログイン + 承認して続行 + 認証コード + 認証 + クライアントシークレット + クライアントID + インポート中… + アプリを閉じたり、戻ったりしないでください。この操作はバックグラウンドでは実行されませんので、完了するまでお待ちください!\n固まっているように見えても慌てないでください。取得に時間がかかることがあります。 + 「高く評価した曲」のインポート先を選択 + 高く評価した曲はどこにインポートしますか? + 「高く評価した曲」という名前の新しい再生リスト + デフォルトの「高く評価した曲」へ + \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml deleted file mode 100644 index f1fb352..0000000 --- a/app/src/main/res/values-ja/strings.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - AirNote - ノート - 新しいノート - 作成したノートがありません。 - ノートが見つかりませんでした。 - 作成時間 - 単語 - 名前 - 説明 - 検索 - 設定 - 画面 - ダークテーマ - ダイナミックカラー - アモレッドカラー - データベース - バックアップ - サポートされていません - 詳細 - バージョン - 最新のリリース - プロジェクトにスターを付ける - 寄付してください :) - 復元 - ノート - ツール - グリッドビュー - コラムビュー - すべて選択 - 削除 - システムテーマ - 情報 - サポート - カラーとスタイル - 言語 - マークダウン - 文字 - 1. 利用規約の受諾 - 「同意」を押してEasy Notesを使用することで、これらの利用規約に同意したことになります。もし同意しない場合は、アプリを使用しないでください。 - 2. ライセンス - 3. アプリケーションの使用 - Easy Notesを合法的な目的でのみ使用し、これらの利用規約に従うことに同意します。次の行為は**禁止**されています: -\n -\n- _アプリを違法な目的で使用すること_ -\n- _他者の権利を侵害すること_ -\n- _アプリの機能やセキュリティを妨害すること_ - Easy Notesに関するすべての知的財産権は、当社または当社のライセンサーに帰属します。アプリの使用は、いかなる知的財産権の所有権も付与しません。 - 5. 保証の否認 - Easy Notesは現状のままで提供されており、いかなる保証もありません。アプリがエラーがないこと、安全であること、または中断なく動作することを保証するものではありません。 - 6. 責任の制限 - これらの利用規約に違反した場合、当社はEasy Notesへのアクセスを終了または一時停止することがあります。契約解除時には、アプリの使用権が即座に終了します。 - 9. お問い合わせ - Easy Notesは個人データを収集しません。詳細については、以下のプライバシーポリシーをご覧ください - 発効日 - 同意 - 利用規約 - 10. プライバシーポリシー - ソースコード - サポーターリスト - データベースを暗号化する - ノートを選択 - 翻訳 - Easy Notesは**GNU一般公衆ライセンス(GPL)バージョン3**のもとで配布されています。これは次のことを意味します: -\n -\n- アプリケーションを自由に使用、変更、共有することができます。 -\n- アプリケーションやその変更版を配布する場合は、ソースコードも配布し、GPLライセンスを保持する必要があります。 - 4. 知的財産権 - ギャラリーから削除された画像をアプリのストレージからも削除する - Easy Notesの使用に起因するいかなる損害についても、当社は責任を負いません。これには、間接的、付随的、または結果的な損害が含まれます。 - 7. 契約の解除 - 8. 利用規約の変更 - 不安定 - 暗号通貨 - 当社は、時折これらの利用規約を更新することがあります。変更後もEasy Notesを引き続き使用することで、改訂された規約を受け入れたことになります。 - これらの利用規約やEasy Notesに関する質問がある場合は、以下の連絡先までお問い合わせください - 正常にインポートされました - 他のテキストファイルをノートとしてインポートする - 挙動 - 編集モードを優先する - 既存のノートで編集モードに入る - インポートできません - 一部のファイルのインポートが失敗しました - インポート - 歴史 - ウィジェット - テーマ • サイズ • フォント - クラウド - フォント • 言語 - フォント - 半径 - 半径を選択 - ミニマリストモード - サポート • イメージ - 極限のアモレットカラー - NextCloud • ローカル • 暗号化 - バージョン • 開発者 • ライセンス - ウィジェット • マークダウン - 歴史 • 反転 - 作成 • すべて削除 - 愛を示しましょう - 無効なパスワード - プライバシー - 画面保護 - 画像サポート - システム設定に基づいてアプリのテーマを自動的に調整する - ダークモードとライトモードを切り替える - 壁紙に合わせて色を調整する - 背景に黒色を使用する - 極限のバッテリー最適化 - 境界半径をカスタマイズ - ビュースタイルを選択 - 編集画面から上部のボタンを削除する - すべてのノートをファイルに保存する - バックアップファイルからノートを復元する - ノートでマークダウンを有効にする - 最近使ったアプリに画面を表示せず、スクリーンショットを無効にする - ビルドタイプ - 電子メール - ディスコード - 機能要求 - 寄付 - 貢献 - ギャラリー同期 - タイトルのみ表示 - プレビューにタイトルのみ表示 - ピン留め - その他 - コピー - バックアップ中にデータベースを暗号化する - パスワードを入力してください - パスワードが無効であるか、データベースが暗号化されていません - 無効な入力 - 昇順に並べ替え - 降順に並べ替え - ノートを作成時間で並べ替え - ノートを編集する際は、等幅フォントを使用する - 等幅フォント - サポートされていない画像サイズ(最大15MB) - 保管庫 - 暗号化されたノート保管庫を有効にする - 続行 - アプリの言語を変更する - システム言語 - \ No newline at end of file diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml new file mode 100644 index 0000000..f438c3d --- /dev/null +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -0,0 +1,626 @@ + + + + Loudly에 오신 것을 환영합니다 + 나만의 음악 경험을 만들어 보세요. + 손끝에서 즐기는 YouTube Music + YT Music 계정 동기화 + 광고 없는 재생 + 오프라인 음악 감상 + Android Auto 지원 + Discord 상태 지원 + 완전한 오픈 소스 + 플레이어 UI 선택 + 앱 내 플레이어 화면을 선택하세요. 이 설정은 나중에 ‘설정 > 모양’에서 변경할 수 있습니다. + 설정 완료! + 이제 Loudly를 탐색할 준비가 되었습니다. + 완료 + 뒤로 + 다음 + 이전 + + + 탐색 + 노래 + 아티스트 + 앨범 + 재생목록 + + 좋아요 표시한 음악 + 좋아요 + 오프라인 음악 + 나의 인기 음악 + 캐시된 음악 + 로컬 음악 + + 콘텐츠 필터 + + 전체 기간 + 지난 24시간 + 지난 주 + 지난 달 + 지난 해 + + + %d개 선택됨 + + + 좋은 아침입니다 + 좋은 오후입니다 + 좋은 저녁입니다 + 안녕히 주무세요 + 최근 활동 + 기록 + 감상 기록이 여기에 표시됩니다 + 통계가 여기에 표시됩니다 + 통계 + 간략히 + 더보기 + 분위기 및 장르 + 계정 + 빠른 선곡 + 노래를 감상하여 빠른 선곡을 생성하세요 + 잊혀진 즐겨찾기 + 계속 듣기 + 내 YouTube 재생목록 + 비슷한 항목 + 새 앨범 + 인터넷에 연결되지 않음 + 인터넷에 연결되지 않은 것 같습니다. 네트워크를 확인해주세요. + 추천 콘텐츠 + 오늘의 추천 + + 오늘 + 어제 + 이번 주 + 지난 주 + 로컬 + 원격 + 로컬 기록 + 원격 기록 + + 가장 많이 재생한 노래 + 가장 많이 재생한 아티스트 + 가장 많이 재생한 앨범 + + 검색 + Loudly에서 검색 + 보관함 검색… + 검색 기록 + 추천 검색어 + 검색 결과 + 보관함 + 좋아요 표시 + 다운로드됨 + 전체 + 노래 + 동영상 + 앨범 + 아티스트 + 재생목록 + 커뮤니티 재생목록 + 추천 재생목록 + 북마크됨 + 결과를 찾을 수 없습니다 + + 보관함이 여기에 표시됩니다 + 보관함의 노래가 여기에 표시됩니다 + 보관함의 아티스트가 여기에 표시됩니다 + 보관함의 앨범이 여기에 표시됩니다 + 내 재생목록이 여기에 표시됩니다 + + 내 보관함에서 + + 다른 버전 + 즐겨찾기 + 인기곡 + + 좋아요 표시한 노래 + 다운로드한 노래 + 재생목록이 비어 있습니다 + 정말로 ‘%s’ 재생목록의 모든 노래를 다운로드한 노래 저장소에서 삭제하시겠습니까? + 정말로 ‘%s’ 재생목록을 삭제하시겠습니까? + + 끄기 + + 재시도 + 라디오 + 셔플 + 초기화 + + 세부정보 + 수정 + 라디오 시작 + 재생 + 다음에 재생 + 대기열에 추가 + 보관함에 추가 + 모두 보관함에 추가 + 보관함에서 삭제 + 모두 보관함에서 삭제 + 다운로드 + 다운로드 중 + 다운로드 삭제 + 재생목록 가져오기 + 재생목록에 추가 + 아티스트 보기 + 앨범 보기 + 새로고침 + 공유 + 삭제 + 기록에서 삭제 + 재생목록에서 삭제 + 대기열에서 삭제 + 캐시에서 삭제 + M3U 파일로 내보내기 + 온라인에서 검색 + 동기화 + 고급 + 템포 및 음높이 + + 추가된 날짜 + 업데이트된 날짜 + 이름 + 아티스트 + 연도 + 곡 수 + 길이 + 재생 시간 + 사용자 지정 순서 + + 미디어 ID + MIME 유형 + 코덱 + 비트레이트 + 샘플링 레이트 + 음량 + 볼륨 + 파일 크기 + 알 수 없음 + 클립보드에 복사됨 + + 가사 수정 + 가사 검색 + 정말로 ‘%s’에 저장된 가사를 삭제하시겠습니까? + + 노래 수정 + 노래 제목 + 노래 아티스트 + 노래 제목은 비워둘 수 없습니다. + 노래 아티스트는 비워둘 수 없습니다. + 저장 + + 재생목록 동기화 + 재생목록 선택 + 재생목록 수정 + 재생목록 만들기 + 재생목록 이름 + 재생목록 이름은 비워둘 수 없습니다. + + 아티스트 수정 + 아티스트 이름 + 아티스트 이름은 비워둘 수 없습니다. + + 중복 항목 + 중복 항목 건너뛰기 + 그래도 추가 + 이 노래는 이미 재생목록에 있습니다 + %d곡이 이미 재생목록에 있습니다 + 참고: YouTube Music과 동기화할 수 있습니다 + + + %d곡 + + + 아티스트 %d명 + + + 앨범 %d장 + + + 재생목록 %d개 + + + %d주 + + + %d개월 + + + %d년 + + + 재생목록을 가져왔습니다 + ‘%s’을(를) 재생목록에서 삭제했습니다 + 재생목록이 동기화되었습니다 + 실행 취소 + + 현재 재생 중 + 가사를 찾을 수 없습니다 + 취침 타이머 + 노래 끝 + + %d분 + + 스트림을 사용할 수 없습니다 + 네트워크에 연결되지 않음 + 시간 초과 + 알 수 없는 오류 + 메뉴 + 목록 공유 + + 5초 뒤로 + 5초 앞으로 + + 좋아요 + 모두 좋아요 표시 + 좋아요 취소 + 모두 좋아요 취소 + 셔플 켜기 + 셔플 끄기 + 반복 모드 끄기 + 현재 곡 반복 + 대기열 반복 + + 모든 노래 + 검색된 노래 + + 음악 플레이어 + + 알림 + 시스템이 앱을 종료하지 못하도록 방지 + Loudly가 시스템에 의해 무작위로 닫히는 것을 방지하기 위해 포그라운드 알림을 사용합니다. + 알림 권한이 필요합니다 + + 설정 + 모양 + 테마 + 프록시 + 동적 테마 사용 + 어두운 테마 + 켜기 + 끄기 + 시스템 설정 따름 + 완전한 검정 + 앱 디자인 + 앱 UI 1.0 + 앱 UI 1.5 + YouTube Music에서 듣기 + Loudly 방문하기 + 아티스트 + Loudly 듣기 + 썸네일 스와이프로 노래 변경 + 탐색 탭 맞춤설정 + 플레이어 + 플레이어 텍스트 정렬 + 가사 텍스트 위치 + 측면 + 왼쪽 + 가운데 + 플레이어 스타일 + 이전 플레이어 UI + 플레이어 UI 1.0 + 플레이어 UI 2.0 + 테마 따름 + 그라데이션 + 블러 + 블러 (모션) + 플레이어 배경 스타일 + 오른쪽 + 슬라이더 스타일 + 기본값 + 물결 모양 + Compose + 썸네일 모서리 반경 설정 + 콘텐츠 필터 표시 + 기타 + 메인 + 자동 재생목록 맞춤설정 + ‘좋아요’ 자동 재생목록 표시 + ‘다운로드’ 자동 재생목록 표시 + ‘인기’ 자동 재생목록 표시 + ‘캐시됨’ 자동 재생목록 표시 + ‘로컬’ 자동 재생목록 표시 + 오른쪽으로 스와이프하여 다음 곡 재생, 왼쪽으로 스와이프하여 대기열에 추가 + 기본으로 열 탭 + 기본 보관함 필터 + 그리드 셀 크기 + 하단 탐색 메뉴 라벨 표시 + 작게 + 크게 + 변경 로그 + 최근 변경 사항 없음 + 최근 변경 사항: + 닫기 + 새 버전 사용 가능 + 안녕하세요 + 기부하기 + 공식 채널 + 웹사이트 + 공식 웹사이트 ❯ + 소스 코드 + 앱의 소스 코드에 접근 + 문제점 + 버그 신고 등 + 도움말 + 기여하고 싶으시면 여기를 클릭하세요 ❯ + 개발자 | @RRechz + + 콘텐츠 + 로그인 + YT Music 재생목록 등에 로그인하세요 + 탭하여 토큰 표시 + 다시 탭하여 복사 또는 수정 + 이것은 고급 로그인 방법입니다. 웹 포털 대신 여기에서 직접 로그인 토큰을 입력하거나 업데이트할 수 있습니다. 예를 들어, 여러 기기에서 더 빠르게 로그인할 수 있습니다. 앱이 파싱할 수 없는 잘못된 토큰 형식은 허용되지 않습니다. + Spotify에서 가져오기 + Spotify + Discord + 좋아요 표시한 노래 자동 다운로드 + YouTube Music 계정과 자동 동기화 + 재생목록 등을 계정과 동기화 + Wi-Fi 전용 + 로그인되지 않음 + 기본 콘텐츠 언어 + 기본 콘텐츠 국가 + 시스템 기본값 + 프록시 사용 + 프록시 유형 + 프록시 URL + 적용하려면 다시 시작해야 합니다 + 앱 언어 + 앱 언어 설정 + 앱 언어 설정을 찾을 수 없습니다. 수동으로 설정해주세요. + 지원되는 링크 기본으로 열기 + 지원되는 링크 설정 + 지원되는 링크 설정을 찾을 수 없습니다. 수동으로 설정해주세요. + + 플레이어 및 오디오 + 음질 + 최대 + 자동 + 높음 + 낮음 + 최소 재생 시간 + ‘재생됨’으로 간주되기 위해 재생해야 하는 최소 시간 + 대기열 + 영구 대기열 + 앱 시작 시 마지막 대기열 복원 + 자동으로 더 많은 노래 로드 + 가능한 경우 대기열 끝에 도달하면 자동으로 더 많은 노래를 추가합니다 + 무음 건너뛰기 + 오디오 평준화 + 블루투스 기기 연결 시 재생 계속 + 최소 볼륨에서 재생 중지 + 재생한 노래를 YouTube Music 기록에 추가 + 오류 발생 시 다음 곡으로 자동 건너뛰기 + 끊김 없는 재생 경험을 보장합니다 + 작업 지울 때 음악 중지 + 이퀄라이저 + 가사 + 가사 + 로컬 플레이어 설정 + 저장 공간 + 캐시 + 이미지 캐시 + 노래 캐시 + 최대 캐시 크기 + 무제한 + 오디오 오프로드 사용 + 오디오 재생에 오디오 오프로드 경로를 사용합니다. 비활성화하면 전력 사용량이 늘어날 수 있지만 오디오 재생이나 후처리에 문제가 있을 때 유용할 수 있습니다. + 모든 다운로드 삭제 + 최대 이미지 캐시 크기 + 이미지 캐시 삭제 + 최대 노래 캐시 크기 + 노래 캐시 삭제 + 사용됨 + 모든 캐시를 삭제하시겠습니까? + 모든 다운로드를 삭제하시겠습니까? + 모든 캐시된 노래를 삭제하시겠습니까? + 모든 캐시된 이미지를 삭제하시겠습니까? + 모든 캐시된 번역 모델을 삭제하시겠습니까? + + 개인정보 보호 + 감상 기록 + 감상 기록 일시중지 + 감상 기록 삭제 + 모든 감상 기록을 삭제하시겠습니까? + 검색 기록 일시중지 + 검색 기록 삭제 + 모든 검색 기록을 삭제하시겠습니까? + 검색 기록 + 로그인하여 콘텐츠 탐색 + 이것은 표시되는 콘텐츠에 영향을 줄 수 있으며, 프리미엄 계정으로 로그인한 경우 프리미엄 전용 앨범을 표시할 수 있습니다. + 스크린샷 비활성화 + 이 옵션을 켜면 스크린샷과 최근 앱 보기에서 앱 화면이 비활성화됩니다. + LrcLib 가사 제공자 사용 + KuGou 가사 제공자 사용 + 기본 가사 제공자 + 여러 줄 가사 읽기 + 동기화 지점 사이의 모든 줄을 하나의 가사 텍스트로 처리합니다 + 가사 주변 공백 제거 + 글꼴 크기 + 성인용 콘텐츠 숨기기 + 알림 사용 + + 백업 및 복원 + 백업 + 복원 + 재생목록 가져오기 (m3u) + 재생목록 가져오기 실패 + 가져온 재생목록 + 백업 생성 성공 + 백업 생성 실패 + 백업 복원 실패 + + Discord 연동 + Loudly는 KizzyRPC 라이브러리를 사용하여 Discord 계정의 상태를 설정합니다. 여기에는 Discord 게이트웨이 연결 사용이 포함되며, 이는 Discord 서비스 약관 위반으로 간주될 수 있습니다. 그러나 이로 인해 사용자 계정이 정지된 사례는 알려진 바 없습니다. 위험은 본인이 감수해야 합니다.\n\nLoudly는 토큰만 추출하며, 그 외 모든 것은 로컬에 저장됩니다. + 닫기 + 옵션 + 미리보기 + 로그인 실패 + 로그아웃 + 리치 프레즌스 사용 + + 정보 + 앱 버전 + 업데이트 확인 중... + 버전 %1$s 다운로드 가능. + 다운로드 중... + 업그레이드 + 재시도 + 지금 다운로드 + 앱 유형 + + 새 버전 사용 가능 + 번역 모델 + 번역 모델 삭제 + 기록 기간 + + %d초 + + 음악 3x2 + 음악 2x1 + 재생/일시정지 + 버전 · 개발자 · 라이선스 + UI + 콘텐츠 및 계정 + 플레이어 + 시스템 + 보관함 + 연동 + 시스템 연동 + 총 사용 공간 + 현재 사용 중 + 캐시 삭제 + 백업 · 복원 · 가져오기 + 기록 · 검색 · 스크린샷 + 다운로드 · 캐시 · 이미지 + 사운드 · 대기열 · 가사 + 언어 · 위치 · 프록시 + 로그인 · Spotify · Discord + 테마 · 슬라이더 · 플레이어 + + 프로필 카드 사진 + 프로필 카드의 사진을 맞춤설정하세요 + 프로필 이미지 선택 + 기본 이미지 + 갤러리에서 선택 + 사진 수정 + 취소 + + 개발자에게 보내기 + 닫기 + 오류 세부정보 표시 + 다운로드 실패 + 오류 표시 + 수동 업데이트 + 자세한 내용은 설정 페이지를 확인하세요 + 소프트웨어가 최신 버전입니다 + + 앱 UI + 3가지 다른 디자인 중 하나를 선택하세요 + UI 버전 선택 + 앱의 이전 버전 디자인. (앱, 플레이어, 미니플레이어) + 더 현대적인 접근 방식으로 만든 컴팩트한 디자인. (앱, 플레이어, 미니플레이어) + 가장 인기 있는 디자인. (앱, 플레이어, 미니플레이어) + 나만의 앱 UI 디자인 만들기. (앱, 플레이어, 미니플레이어) + UI 맞춤설정 + + 기타 설정 + 다양한 기타 설정을 보려면 클릭하세요 + + 45~512 Kbps 사이에서 품질을 자동으로 조절합니다 + 최대 512 Kbps의 최고 품질을 제공합니다 + 평균 인터넷 속도에 맞춰 128~256 Kbps의 품질을 설정합니다. + 데이터 절약을 위해 품질을 45~52 Kbps로 제한합니다 (데이터 절약 모드) + + 가져올 수 없습니다 + + 텍스트 정렬 + 카드에 텍스트 위치 설정 + 그라데이션 강도 + 모서리 둥글게 + 프로필 사진 + 프로필 카드 + 프로필 사진 삭제 + 텍스트 표시 + 프로필 카드에 인사말과 사용자 이름 표시 + 탭하여 변경 + + 번역 (베타) + 복사 + + 네트워크 오류 + 인터넷 연결을 확인하고 다시 시도하세요. + 지원되지 않는 콘텐츠 + 이 노래 형식은 기기에서 지원되지 않습니다. + 라이브 방송 오류 + 라이브 스트림에 문제가 있습니다. 다시 시작해주세요. + 재생 실패 + 알 수 없는 오류가 발생했습니다. 다시 시도해주세요. + 다시 시도 중… + VPN 사용은 연결 품질에 영향을 줄 수 있습니다. 네트워크 문제가 있는 경우 VPN 연결을 끊거나 프록시를 사용하세요. ‘설정 >> 콘텐츠 >> 프록시’에서 설정할 수 있습니다. + + 로컬 노래를 성공적으로 동기화했습니다 + 로컬 스캐너 + + 자동으로 스캔 + 앱 열 때 자동으로 노래 스캔 + + 추가 스캐너 설정 + 엄격한 파일 이름 + 활성화하면 확장자를 무시하지 않습니다. 예: "Song.ogg"는 "Song.flac"과 다른 노래가 됩니다 + 스캐너 감도 설정 + 제목 일치 + 제목 및 아티스트 일치 + 제목, 아티스트, 앨범 일치 + + 폴더 + 하위 폴더 평면화 + 디스크의 파일 구조를 유지하려면 비활성화하세요 + + 팁 및 기능 + 팁 · 기능 + 내 스토리 만들기 + 앨범 커버의 주 색상으로 만든 세련된 디자인으로 듣고 있는 노래를 소셜 미디어에 공유하세요. + 맞춤설정 가능한 설정 + 설정 화면의 제목 카드 배경을 갤러리 이미지로 교체하여 앱을 나만의 것으로 만드세요. + 새 노래 목록 + 현재 재생목록을 보고 플레이어 화면에서 위로 스와이프하여 쉽게 노래를 전환하세요 (플레이어 UI 2.0 전용 새로운 디자인) + 새롭게 디자인된 설정 + 더 현대적이고 편리한 새로운 카드 기반 설정 디자인으로 원하는 것을 쉽게 찾으세요. + 클래식 음악 위젯 + 홈 화면에서 모든 컨트롤과 노래 정보에 빠르게 접근하세요. + 미니멀리스트 음악 위젯 + Android 16의 빠른 설정 버튼 디자인에서 영감을 받은 ‘Quik’ 음악 위젯 + 스토리에 공유 + 노래 링크 복사 + 링크가 클립보드에 복사되었습니다! + Loudly에서 스트리밍됨 + + 전체 선택 + 모든 재생목록을 가져오는 중… + 선택한 항목 가져오기 + 문제가 발생했습니다. 저희도 여러분만큼 당황스럽습니다. + 음, 당황스럽네요. 저희도 무슨 일이 일어났는지 모르겠습니다. + 필요한 자격 증명 얻는 방법 + 3. 위의 로컬 경로를 입력한 후 ‘추가’를 클릭하세요. + 5. 서비스 약관에 동의하고 ‘저장’을 클릭하세요. + 새 페이지로 리디렉션됩니다. 설정을 클릭한 다음 클라이언트 ID와 클라이언트 시크릿을 복사하여 이 앱의 아래 텍스트 필드에 붙여넣으세요. + 2. 필요한 세부 정보를 입력하고 리디렉션 URI로 http://127.0.0.1:45454를 사용하세요. + 4. 웹 API를 켜세요. 이제 체크박스에 체크 표시가 있어야 합니다. + 이제 아래의 인증 및 계속 버튼을 클릭하고 가져오려는 계정으로 로그인한 다음 인증 및 계속을 진행하면 http://127.0.0.1:45454/?code=로 시작하는 사이트로 리디렉션됩니다.\n\n해당 전체 URL을 아래 ‘인증 코드’ 텍스트 필드에 붙여넣고 인증 버튼을 클릭하세요. + 1. Spotify 개발자 대시보드를 방문하여 ‘앱 만들기’를 클릭하세요. + Spotify API 자격 증명으로 로그인 + 인증 및 계속 + 인증 코드 + 인증 + 클라이언트 시크릿 + 클라이언트 ID + 가져오는 중… + 앱을 닫거나 뒤로 가지 마세요. 이 작업은 백그라운드에서 실행되지 않으므로 끝날 때까지 기다려주세요!\n멈춘 것처럼 보여도 당황하지 마세요. 때때로 검색에 시간이 걸릴 수 있습니다. + ‘좋아요 표시한 노래’ 대상 선택 + 좋아요 표시한 노래를 어디로 가져와야 하나요? + ‘좋아요 표시한 노래’라는 이름의 새 재생목록 + 기본 ‘좋아요 표시한 노래’로 + \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml deleted file mode 100644 index 11f69f3..0000000 --- a/app/src/main/res/values-ko/strings.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - 새 노트 - 작성한 노트가 없어요. - 노트가 발견되지 않았어요. - 작성 시간 - 열 뷰 - 단어 - 문자 - 이름 - 설명 - 검색 - 설정 - 화면 - 시스템 테마 - 어두운 테마 - 동적 색상 - 아몰레드 색상 - 데이터베이스 - 백업 - 후원을 부탁드립니다 :) - 복원 - 노트 - 지원 - 마크다운 - 도구 - 역사 - 테마 • 크기 • 글꼴 - 클라우드 - 글꼴 • 언어 - NextCloud • 로컬 • 암호화 - 버전 • 개발자 • 라이선스 - 위젯 • 마크다운 - 역사 • 뒤로가기 - 지원 • 이미지 - 관심을 보여주세요 - 글꼴 - 반경 - 극단적 아몰레드 색상 - 개인정보 보호 - 화면 보호 - 이미지 지원 - 불안정 - 암호화폐 - 배경화면에 맞춰서 색상 조정 - 배경에 검은색 사용 - 극단적 배터리 최적화 - 경계 반경 사용자 정의 - 뷰 스타일 선택 - 편집 화면에서 상부 버튼 제거 - 모든 노트를 파일에 저장 - 백업 파일에서 노트 복원 - 노트에 마크다운 활성화 - 빌드 형태 - 이메일 - 고정됨 - 기타 - 복사 - 데이터베이스 암호화 - 백업 도중 데이터베이스 암호화 - 비밀번호 입력 - 잘못된 입력 - 오름차순 정렬 - 내림차순 정렬 - 노트를 작성시간에 따라 정렬 - 노트 선택 - 보관고 - 시스템 언어 - 행동 - 편집 모드 우선 - 기존 노트로 편집 모드 진입 - 잘못된 비밀번호 - 후원 - 번역 - 기여 - 갤러리에서 삭제된 이미지를 앱 저장소에서도 삭제 - 갤러리 동기화 - 기여자 목록 - 2. 라이선스 - 3. 애플리케이션 사용 - Easy Notes를 합법적인 목적에만 사용하고, 이 이용 약관에 따를 것에 동의합니다. 다음 행위는 **금지**됩니다: -\n -\n- _앱을 불법적인 목적으로 사용하는 행위_ -\n- _타인의 권리를 침해하는 행위_ -\n- _앱의 기능이나 보안을 방해하는 행위_ - 4. 지적 재산권 - Easy Notes에 관한 모든 지적 재산권은 당사 또는 당사의 라이센서에게 귀속됩니다. 애플리케이션의 사용은 어떤 지적 재산권의 소유권도 부여하지 않습니다. - Easy Notes는 현 상태 그대로 제공되며, 어떠한 보증도 없습니다. 애플리케이션이 오류가 없거나 안전하거나, 중단 없이 작동할 것이라는 보장을 하지 않습니다. - 5. 보증 부인 - 6. 책임 제한 - Easy Notes의 사용으로 인해 발생하는 어떤 손해에 대해서도 당사는 책임을 지지 않습니다. 여기에는 간접적, 부수적, 또는 결과적인 손해가 포함됩니다. - 이용 약관을 위반한 경우, 당사는 Easy Notes에 대한 접근을 종료하거나 일시 중지할 수 있습니다. 계약 해지 시, 애플리케이션의 사용 권한은 즉시 종료됩니다. - 7. 계약 해지 - 8. 이용 약관 변경 - 당사는 수시로 이 이용 약관을 갱신할 수 있습니다. 변경 후에도 Easy Notes를 계속 사용함으로써, 개정된 약관을 수락한 것으로 간주됩니다. - 10. 개인정보처리방침 - Easy Notes는 개인정보를 일체 수집하지 않습니다. 자세한 내용은 아래의 개인정보처리방침을 참조해 주시기 바랍니다 - 발효일 - 동의 - 이용 약관 - 가져오지 못함 - 일부 파일 가져오기에 실패함 - 성공적으로 가져옴 - 가져오기 - 다른 텍스트 파일을 노트로 가져오기 - 노트 편집 시 고정 폭 글꼴 사용 - 고정 폭 글꼴 - AİrNote - 그리드 뷰 - 모두 선택 - 삭제 - 정보 - 노트 - 아직 지원되지 않음 - 정보 - 버전 - 최신 배포 - 프로젝트에 별점 주기 - 색상 및 스타일 - 언어 - 위젯 - 반경 선택 - 미니멀리스트 모드 - 어두운 모드와 밝은 모드 전환 - 시스템 설정에 기반하여 앱 테마 자동 조정 - 작성됨 • 모두 선택 - 최근 사용한 앱에서 화면 숨기기 및 스크린샷 비활성화 - 기능 요구 - 소스 코드 - 디스코드 - 암호화된 노트 보관고 활성화 - 제목만 표시 - 비밀번호가 잘못되었거나 데이터베이스가 암호화되지 않음 - 지원되지 않는 이미지 크기 (최대 15MB) - 다음 - 앱 언어 번경 - 미리보기에서 제목만 표시 - \'동의\' 버튼을 눌러 Easy Notes를 사용함으로써, 이 이용 약관에 동의한 것으로 간주됩니다. 동의하지 않으실 경우, 앱을 사용하지 마십시오. - Easy Notes는 **GNU 일반 공중 사용 허가서(GPL) 버전 3** 하에 배포됩니다. 이는 다음을 의미합니다: -\n -\n- 애플리케이션을 자유롭게 사용하고, 변경하고, 공유할 수 있습니다. -\n- 애플리케이션이나 그 변경판을 배포할 경우, 소스 코드도 함께 배포하며, GPL 라이센스를 유지해야 합니다. - 1. 이용 약관 수락 - 9. 문의 사항 - 이 이용 약관이나 Easy Notes에 대해 질문이 있는 경우, 아래 연락처로 문의해 주시기 바랍니다 - \ No newline at end of file diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml new file mode 100644 index 0000000..0f83b43 --- /dev/null +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -0,0 +1,636 @@ + + + + Loudly-ലേക്ക് സ്വാഗതം + നിങ്ങളുടെ സംഗീതാനുഭവം വ്യക്തിഗതമാക്കാൻ ആരംഭിക്കാം. + വിരൽത്തുമ്പിൽ YouTube Music + YT Music അക്കൗണ്ട് സിൻക്രൊണൈസേഷൻ + പരസ്യമില്ലാത്ത പ്ലേബാക്ക് + ഓഫ്‌ലൈൻ സംഗീതാനുഭവം + Android Auto പിന്തുണ + Discord സ്റ്റാറ്റസ് പിന്തുണ + പൂർണ്ണമായും ഓപ്പൺ സോഴ്സ് + നിങ്ങളുടെ പ്ലെയർ UI തിരഞ്ഞെടുക്കുക + ആപ്പിലെ പ്ലെയർ കാഴ്ച തിരഞ്ഞെടുക്കുക. ഈ ക്രമീകരണം പിന്നീട് “ക്രമീകരണങ്ങൾ > രൂപഭാവം” എന്നതിൽ മാറ്റാവുന്നതാണ്. + സജ്ജീകരണം പൂർത്തിയായി! + നിങ്ങൾ Loudly പര്യവേക്ഷണം ചെയ്യാൻ തയ്യാറാണ്. + പൂർത്തിയാക്കുക + പുറകോട്ട് + അടുത്തത് + മുമ്പത്തേത് + + ഹോം + പര്യവേക്ഷണം + ഗാനങ്ങൾ + കലാകാരന്മാർ + ആൽബങ്ങൾ + പ്ലേലിസ്റ്റുകൾ + + ഇഷ്ടപ്പെട്ട സംഗീതം + ഇഷ്ടം + ഓഫ്‌ലൈൻ സംഗീതം + എൻ്റെ മികച്ച സംഗീതം + കാഷെ ചെയ്ത സംഗീതം + പ്രാദേശിക സംഗീതം + + ഉള്ളടക്ക ഫിൽട്ടർ + + എല്ലാ കാലവും + കഴിഞ്ഞ 24 മണിക്കൂർ + കഴിഞ്ഞ ആഴ്ച + കഴിഞ്ഞ മാസം + കഴിഞ്ഞ വർഷം + + + %d എണ്ണം തിരഞ്ഞെടുത്തു + %d എണ്ണം തിരഞ്ഞെടുത്തു + + + സുപ്രഭാതം + ശുഭദിനം + ശുഭ സായാഹ്നം + ശുഭരാത്രി + സമീപകാല പ്രവർത്തനം + ചരിത്രം + നിങ്ങളുടെ ചരിത്രം ഇവിടെ ദൃശ്യമാകും + നിങ്ങളുടെ സ്ഥിതിവിവരക്കണക്കുകൾ ഇവിടെ ദൃശ്യമാകും + സ്ഥിതിവിവരക്കണക്കുകൾ + ചുരുക്കി കാണിക്കുക + കൂടുതൽ കാണിക്കുക + മാനസികാവസ്ഥയും ശൈലികളും + അക്കൗണ്ട് + വേഗത്തിലുള്ള തിരഞ്ഞെടുപ്പുകൾ + നിങ്ങളുടെ വേഗത്തിലുള്ള തിരഞ്ഞെടുപ്പുകൾക്കായി ഗാനങ്ങൾ കേൾക്കുക + മറന്നുപോയ ഇഷ്ടങ്ങൾ + കേൾക്കുന്നത് തുടരുക + നിങ്ങളുടെ YouTube പ്ലേലിസ്റ്റുകൾ + സമാനമായവ + പുതിയ ആൽബങ്ങൾ + ഇൻ്റർനെറ്റ് കണക്ഷൻ ഇല്ല + നിങ്ങൾക്ക് ഇൻ്റർനെറ്റ് കണക്ഷൻ ഇല്ലെന്ന് തോന്നുന്നു. ദയവായി നിങ്ങളുടെ നെറ്റ്‌വർക്ക് പരിശോധിക്കുക. + നിങ്ങൾക്കായി തിരഞ്ഞെടുത്തവ + ഇന്ന് നിങ്ങൾക്കായി ശുപാർശ ചെയ്യുന്നത് + + ഇന്ന് + ഇന്നലെ + ഈ ആഴ്ച + കഴിഞ്ഞ ആഴ്ച + പ്രാദേശികം + വിദൂരം + പ്രാദേശിക ചരിത്രം + ഓൺലൈൻ ചരിത്രം + + ഏറ്റവും കൂടുതൽ കേട്ട ഗാനങ്ങൾ + ഏറ്റവും കൂടുതൽ കേട്ട കലാകാരന്മാർ + ഏറ്റവും കൂടുതൽ കേട്ട ആൽബങ്ങൾ + + തിരയുക + Loudly-ൽ തിരയുക + ലൈബ്രറിയിൽ തിരയുക... + തിരയൽ ചരിത്രം + നിർദ്ദേശങ്ങൾ + തിരയൽ ഫലങ്ങൾ + ലൈബ്രറി + ഇഷ്ടപ്പെട്ടവ + ഡൗൺലോഡ് ചെയ്തവ + എല്ലാം + ഗാനങ്ങൾ + വീഡിയോകൾ + ആൽബങ്ങൾ + കലാകാരന്മാർ + പ്ലേലിസ്റ്റുകൾ + കമ്മ്യൂണിറ്റി പ്ലേലിസ്റ്റുകൾ + തിരഞ്ഞെടുത്ത പ്ലേലിസ്റ്റുകൾ + ബുക്ക്മാർക്ക് ചെയ്തവ + ഫലങ്ങളൊന്നും കണ്ടെത്തിയില്ല + + നിങ്ങളുടെ ലൈബ്രറി ഇവിടെ ദൃശ്യമാകും + ലൈബ്രറിയിലെ ഗാനങ്ങൾ ഇവിടെ ദൃശ്യമാകും + ലൈബ്രറിയിലെ കലാകാരന്മാർ ഇവിടെ ദൃശ്യമാകും + ലൈബ്രറിയിലെ ആൽബങ്ങൾ ഇവിടെ ദൃശ്യമാകും + നിങ്ങളുടെ പ്ലേലിസ്റ്റുകൾ ഇവിടെ ദൃശ്യമാകും + + നിങ്ങളുടെ ലൈബ്രറിയിൽ നിന്ന് + + മറ്റ് പതിപ്പുകൾ + പ്രിയപ്പെട്ടത് + മികച്ച ഗാനങ്ങൾ + + ഇഷ്ടപ്പെട്ട ഗാനങ്ങൾ + ഡൗൺലോഡ് ചെയ്ത ഗാനങ്ങൾ + പ്ലേലിസ്റ്റ് ശൂന്യമാണ് + "%s" എന്ന പ്ലേലിസ്റ്റിലെ എല്ലാ ഗാനങ്ങളും ഡൗൺലോഡ് ചെയ്ത ഗാനങ്ങളുടെ സംഭരണത്തിൽ നിന്ന് നീക്കം ചെയ്യണോ? + "%s" എന്ന പ്ലേലിസ്റ്റ് ഇല്ലാതാക്കണോ? + + ഓഫ് + + വീണ്ടും ശ്രമിക്കുക + റേഡിയോ + ഷഫിൾ + പുനഃക്രമീകരിക്കുക + + വിശദാംശങ്ങൾ + എഡിറ്റ് ചെയ്യുക + റേഡിയോ ആരംഭിക്കുക + പ്ലേ ചെയ്യുക + അടുത്തതായി പ്ലേ ചെയ്യുക + ക്യൂവിലേക്ക് ചേർക്കുക + ലൈബ്രറിയിലേക്ക് ചേർക്കുക + എല്ലാം ലൈബ്രറിയിലേക്ക് ചേർക്കുക + ലൈബ്രറിയിൽ നിന്ന് നീക്കം ചെയ്യുക + എല്ലാം ലൈബ്രറിയിൽ നിന്ന് നീക്കം ചെയ്യുക + ഡൗൺലോഡ് ചെയ്യുക + ഡൗൺലോഡ് ചെയ്യുന്നു + ഡൗൺലോഡ് നീക്കം ചെയ്യുക + പ്ലേലിസ്റ്റ് ഇമ്പോർട്ട് ചെയ്യുക + പ്ലേലിസ്റ്റിലേക്ക് ചേർക്കുക + കലാകാരനെ കാണുക + ആൽബം കാണുക + വീണ്ടും ലഭ്യമാക്കുക + പങ്കിടുക + ഇല്ലാതാക്കുക + ചരിത്രത്തിൽ നിന്ന് നീക്കം ചെയ്യുക + പ്ലേലിസ്റ്റിൽ നിന്ന് നീക്കം ചെയ്യുക + ക്യൂവിൽ നിന്ന് നീക്കം ചെയ്യുക + കാഷെയിൽ നിന്ന് നീക്കം ചെയ്യുക + M3U ഫയലായി എക്സ്പോർട്ട് ചെയ്യുക + ഓൺലൈനായി തിരയുക + സിൻക്രൊണൈസ് ചെയ്യുക + വിപുലമായത് + ടെമ്പോയും പിച്ചും + + ചേർത്ത തീയതി + അപ്ഡേറ്റ് ചെയ്ത തീയതി + പേര് + കലാകാരൻ + വർഷം + ഗാനങ്ങളുടെ എണ്ണം + ദൈർഘ്യം + പ്ലേ ചെയ്ത സമയം + ഇഷ്ടാനുസൃത ക്രമം + + മീഡിയ ഐഡി + MIME തരം + കോഡെക്കുകൾ + ബിറ്റ്റേറ്റ് + സാമ്പിൾ റേറ്റ് + ശബ്ദതീവ്രത + ശബ്ദം + ഫയൽ വലുപ്പം + അജ്ഞാതം + ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി + + വരികൾ എഡിറ്റ് ചെയ്യുക + വരികൾ തിരയുക + "%s" എന്നതിനായി സംഭരിച്ച വരികൾ ഇല്ലാതാക്കണോ? + + ഗാനം എഡിറ്റ് ചെയ്യുക + ഗാനത്തിൻ്റെ പേര് + ഗാനത്തിലെ കലാകാരന്മാർ + ഗാനത്തിൻ്റെ പേര് ശൂന്യമായിരിക്കരുത്. + ഗാനത്തിലെ കലാകാരൻ്റെ പേര് ശൂന്യമായിരിക്കരുത്. + സേവ് ചെയ്യുക + + പ്ലേലിസ്റ്റ് സിൻക്രൊണൈസ് ചെയ്യുക + പ്ലേലിസ്റ്റ് തിരഞ്ഞെടുക്കുക + പ്ലേലിസ്റ്റ് എഡിറ്റ് ചെയ്യുക + പ്ലേലിസ്റ്റ് നിർമ്മിക്കുക + പ്ലേലിസ്റ്റിൻ്റെ പേര് + പ്ലേലിസ്റ്റിൻ്റെ പേര് ശൂന്യമായിരിക്കരുത്. + + കലാകാരനെ എഡിറ്റ് ചെയ്യുക + കലാകാരൻ്റെ പേര് + കലാകാരൻ്റെ പേര് ശൂന്യമായിരിക്കരുത്. + + പകർപ്പുകൾ + പകർപ്പുകൾ ഒഴിവാക്കുക + എങ്കിലും ചേർക്കുക + ഗാനം ഇതിനകം നിങ്ങളുടെ പ്ലേലിസ്റ്റിലുണ്ട് + നിങ്ങളുടെ പ്ലേലിസ്റ്റിൽ ഇതിനകം %d ഗാനങ്ങളുണ്ട് + കുറിപ്പ്: ഇത് YouTube Music-മായി സിൻക്രൊണൈസ് ചെയ്യാൻ അനുവദിക്കുന്നു + + + %d ഗാനം + %d ഗാനങ്ങൾ + + + %d കലാകാരൻ + %d കലാകാരന്മാർ + + + %d ആൽബം + %d ആൽബങ്ങൾ + + + %d പ്ലേലിസ്റ്റ് + %d പ്ലേലിസ്റ്റുകൾ + + + %d ആഴ്ച + %d ആഴ്ചകൾ + + + %d മാസം + %d മാസങ്ങൾ + + + %d വർഷം + %d വർഷങ്ങൾ + + + പ്ലേലിസ്റ്റ് ഇമ്പോർട്ട് ചെയ്തു + "%s" പ്ലേലിസ്റ്റിൽ നിന്ന് നീക്കം ചെയ്തു + പ്ലേലിസ്റ്റ് സിൻക്രൊണൈസ് ചെയ്തു + പഴയപടിയാക്കുക + + ഇപ്പോൾ പ്ലേ ചെയ്യുന്നു + വരികൾ കണ്ടെത്തിയില്ല + സ്ലീപ്പ് ടൈമർ + ഗാനത്തിൻ്റെ അവസാനം + + 1 മിനിറ്റ് + %d മിനിറ്റ് + + സ്ട്രീം ലഭ്യമല്ല + നെറ്റ്‌വർക്ക് കണക്ഷനില്ല + സമയം കഴിഞ്ഞു + അജ്ഞാതമായ പിശക് + മെനു + ലിസ്റ്റ് പങ്കിടുക + + 5 സെക്കൻഡ് പിന്നോട്ട് + 5 സെക്കൻഡ് മുന്നോട്ട് + + ഇഷ്ടം + എല്ലാം ഇഷ്ടപ്പെടുക + ഇഷ്ടം നീക്കം ചെയ്യുക + എല്ലാ ഇഷ്ടങ്ങളും നീക്കം ചെയ്യുക + ഷഫിൾ ഓൺ + ഷഫിൾ ഓഫ് + ആവർത്തന മോഡ് ഓഫ് + നിലവിലെ ഗാനം ആവർത്തിക്കുക + ക്യൂ ആവർത്തിക്കുക + + എല്ലാ ഗാനങ്ങളും + തിരഞ്ഞ ഗാനങ്ങൾ + + മ്യൂസിക് പ്ലെയർ + + അറിയിപ്പുകൾ + സിസ്റ്റം ആപ്പ് അടയ്ക്കുന്നത് തടയുക + സിസ്റ്റം അപ്രതീക്ഷിതമായി Loudly അടയ്ക്കുന്നത് തടയാൻ ഒരു ഫോർഗ്രൗണ്ട് അറിയിപ്പ് ഉപയോഗിക്കുക. + അറിയിപ്പ് അനുമതി ആവശ്യമാണ് + + ക്രമീകരണങ്ങൾ + രൂപഭാവം + തീം + പ്രോക്സി + ഡൈനാമിക് തീം പ്രവർത്തനക്ഷമമാക്കുക + ഇരുണ്ട തീം + ഓൺ + ഓഫ് + സിസ്റ്റം ക്രമീകരണം പിന്തുടരുക + ശുദ്ധമായ കറുപ്പ് + ആപ്പ് ഡിസൈൻ + ആപ്പ് UI 1.0 + ആപ്പ് UI 1.5 + YouTube Music-ൽ കേൾക്കുക + Loudly സന്ദർശിക്കുക + കലാകാരൻ + Loudly കേൾക്കുക + ഗാനം മാറ്റാൻ തമ്പ്‌നെയിലിൽ സ്വൈപ്പ് ചെയ്യുക + നാവിഗേഷൻ ടാബുകൾ ഇഷ്ടാനുസൃതമാക്കുക + പ്ലെയർ + പ്ലെയർ ടെക്സ്റ്റ് അലൈൻമെൻ്റ് + വരികളുടെ ടെക്സ്റ്റ് സ്ഥാനം + വശത്ത് + ഇടത് + മധ്യം + പ്ലെയർ ശൈലി + പഴയ പ്ലെയർ UI + പ്ലെയർ UI 1.0 + പ്ലെയർ UI 2.0 + തീം പിന്തുടരുക + ഗ്രേഡിയൻ്റ് + മങ്ങൽ + മങ്ങൽ (ചലനം) + പ്ലെയർ പശ്ചാത്തല ശൈലി + വലത് + സ്ലൈഡർ ശൈലി + ഡിഫോൾട്ട് + വളഞ്ഞ വര + കോമ്പോസ് + തമ്പ്‌നെയിൽ കോർണർ റേഡിയസ് ക്രമീകരിക്കുക + ഉള്ളടക്ക ഫിൽട്ടർ കാണിക്കുക + പലവക + പ്രധാനം + ഓട്ടോമാറ്റിക് പ്ലേലിസ്റ്റ് ഇഷ്ടാനുസൃതമാക്കൽ + ഇഷ്ടപ്പെട്ടവയുടെ ഓട്ടോ-പ്ലേലിസ്റ്റ് കാണിക്കുക + ഡൗൺലോഡ് ചെയ്തവയുടെ ഓട്ടോ-പ്ലേലിസ്റ്റ് കാണിക്കുക + മികച്ചവയുടെ ഓട്ടോ-പ്ലേലിസ്റ്റ് കാണിക്കുക + കാഷെ ചെയ്തവയുടെ ഓട്ടോ-പ്ലേലിസ്റ്റ് കാണിക്കുക + പ്രാദേശികമായവയുടെ ഓട്ടോ-പ്ലേലിസ്റ്റ് കാണിക്കുക + അടുത്ത ഗാനത്തിനായി വലത്തേക്ക് അല്ലെങ്കിൽ ക്യൂവിലേക്ക് ചേർക്കാൻ ഇടത്തേക്ക് സ്വൈപ്പ് ചെയ്യുക + ഡിഫോൾട്ട് ഓപ്പൺ ടാബ് + ഡിഫോൾട്ട് ലൈബ്രറി ചിപ്പുകൾ + ഗ്രിഡ് സെൽ വലുപ്പം + താഴെയുള്ള നാവിഗേഷൻ ബാർ ലേബലുകൾ കാണിക്കുക + ചെറുത് + വലുത് + മാറ്റങ്ങളുടെ പട്ടിക + പുതിയ മാറ്റങ്ങളൊന്നും കണ്ടെത്തിയില്ല + സമീപകാല മാറ്റങ്ങൾ: + അടയ്ക്കുക + പുതിയ പതിപ്പ് ലഭ്യമാണ് + വീണ്ടും സ്വാഗതം + സംഭാവന ചെയ്യുക + ഔദ്യോഗിക ചാനൽ + വെബ്സൈറ്റ് + ഔദ്യോഗിക വെബ്സൈറ്റ് ❯ + സോഴ്സ് കോഡ് + ആപ്പിൻ്റെ സോഴ്സ് കോഡ് ആക്സസ് ചെയ്യുക + പ്രശ്നങ്ങൾ + ബഗ് റിപ്പോർട്ടുകളും മറ്റും + സഹായം + നിങ്ങൾ സംഭാവന ചെയ്യാൻ ആഗ്രഹിക്കുന്നുവെങ്കിൽ, ഇവിടെ ക്ലിക്ക് ചെയ്യുക ❯ + ഡെവലപ്പർ | @RRechz + + ഉള്ളടക്കം + ലോഗിൻ ചെയ്യുക + നിങ്ങളുടെ YT Music പ്ലേലിസ്റ്റുകളിലേക്കും മറ്റും പ്രവേശിക്കുക + ടോക്കൺ കാണിക്കാൻ ടാപ്പ് ചെയ്യുക + പകർത്താനോ എഡിറ്റ് ചെയ്യാനോ വീണ്ടും ടാപ്പ് ചെയ്യുക + ഇതൊരു വിപുലമായ ലോഗിൻ രീതിയാണ്. വെബ് പോർട്ടലിന് പകരമായി, നിങ്ങൾക്ക് ഇവിടെ നേരിട്ട് ലോഗിൻ ടോക്കൺ നൽകുകയോ അപ്ഡേറ്റ് ചെയ്യുകയോ ചെയ്യാം. ഉദാഹരണത്തിന്, ഇത് ഒന്നിലധികം ഉപകരണങ്ങളിൽ ലോഗിൻ ചെയ്യുന്നത് വേഗത്തിലാക്കാൻ സഹായിക്കും. ആപ്പിന് പാഴ്സ് ചെയ്യാൻ കഴിയാത്ത ഏതെങ്കിലും അസാധുവായ ടോക്കൺ ഫോർമാറ്റുകൾ സ്വീകരിക്കുന്നതല്ല. + Spotify-ൽ നിന്ന് ഇമ്പോർട്ട് ചെയ്യുക + Spotify + Discord + ഇഷ്ടപ്പെട്ട ഗാനങ്ങൾ യാന്ത്രികമായി ഡൗൺലോഡ് ചെയ്യുക + നിങ്ങളുടെ YouTube Music അക്കൗണ്ടുമായി യാന്ത്രികമായി സിൻക്രൊണൈസ് ചെയ്യുക + നിങ്ങളുടെ പ്ലേലിസ്റ്റുകളും മറ്റും അക്കൗണ്ടുമായി സിൻക്രൊണൈസ് ചെയ്യുക + Wi-Fi മാത്രം + ലോഗിൻ ചെയ്തിട്ടില്ല + ഡിഫോൾട്ട് ഉള്ളടക്ക ഭാഷ + ഡിഫോൾട്ട് ഉള്ളടക്ക രാജ്യം + സിസ്റ്റം ഡിഫോൾട്ട് + പ്രോക്സി പ്രവർത്തനക്ഷമമാക്കുക + പ്രോക്സി തരം + പ്രോക്സി URL + മാറ്റങ്ങൾ പ്രാവർത്തികമാക്കാൻ റീസ്റ്റാർട്ട് ചെയ്യുക + ആപ്പ് ഭാഷ + ആപ്പ് ഭാഷകൾ ക്രമീകരിക്കുക + ആപ്പ് ഭാഷാ ക്രമീകരണങ്ങൾ കണ്ടെത്താനായില്ല, ദയവായി സ്വമേധയാ ക്രമീകരിക്കുക + പിന്തുണയ്ക്കുന്ന ലിങ്കുകൾ ഡിഫോൾട്ടായി തുറക്കുക + പിന്തുണയ്ക്കുന്ന ലിങ്കുകൾ ക്രമീകരിക്കുക + പിന്തുണയ്ക്കുന്ന ലിങ്ക് ക്രമീകരണങ്ങൾ കണ്ടെത്താനായില്ല, ദയവായി സ്വമേധയാ ക്രമീകരിക്കുക + + പ്ലെയറും ഓഡിയോയും + ഓഡിയോ നിലവാരം + പരമാവധി + ഓട്ടോമാറ്റിക് + ഉയർന്നത് + കുറഞ്ഞത് + കുറഞ്ഞ പ്ലേബാക്ക് ദൈർഘ്യം + ഒരു ഗാനം "പ്ലേ ചെയ്തു" എന്ന് കണക്കാക്കുന്നതിന് മുമ്പ് പ്ലേ ചെയ്യേണ്ട കുറഞ്ഞ സമയം + ക്യൂ + സ്ഥിരമായ ക്യൂ + ആപ്പ് ആരംഭിക്കുമ്പോൾ നിങ്ങളുടെ അവസാനത്തെ ക്യൂ പുനഃസ്ഥാപിക്കുക + കൂടുതൽ ഗാനങ്ങൾ യാന്ത്രികമായി ലോഡ് ചെയ്യുക + ക്യൂവിൻ്റെ അവസാനമെത്തുമ്പോൾ, സാധ്യമെങ്കിൽ കൂടുതൽ ഗാനങ്ങൾ യാന്ത്രികമായി ചേർക്കുക + നിശബ്ദത ഒഴിവാക്കുക + ഓഡിയോ നോർമലൈസേഷൻ + ബ്ലൂടൂത്ത് ഉപകരണം ബന്ധിപ്പിക്കുമ്പോൾ പ്ലേബാക്ക് തുടരുക + ഗാനത്തിൻ്റെ ശബ്ദം കുറഞ്ഞ അളവിലാകുമ്പോൾ പ്ലേ ചെയ്യുന്നത് നിർത്തുക + പ്ലേ ചെയ്ത ഗാനങ്ങൾ YouTube Music ചരിത്രത്തിലേക്ക് ചേർക്കുന്നു + പിശക് സംഭവിക്കുമ്പോൾ അടുത്ത ഗാനത്തിലേക്ക് യാന്ത്രികമായി പോകുക + നിങ്ങളുടെ തുടർച്ചയായ പ്ലേബാക്ക് അനുഭവം ഉറപ്പാക്കുക + ടാസ്ക് ക്ലിയർ ചെയ്യുമ്പോൾ സംഗീതം നിർത്തുക + ഇക്വലൈസർ + വരികൾ + വരികൾ + പ്രാദേശിക പ്ലെയർ ക്രമീകരണങ്ങൾ + സംഭരണം + കാഷെ + ചിത്രങ്ങളുടെ കാഷെ + ഗാനങ്ങളുടെ കാഷെ + പരമാവധി കാഷെ വലുപ്പം + അപരിമിതം + ഓഫ്‌ലോഡ് പ്രവർത്തനക്ഷമമാക്കുക + ഓഡിയോ പ്ലേബാക്കിനായി ഓഫ്‌ലോഡ് ഓഡിയോ പാത്ത് ഉപയോഗിക്കുക. ഇത് പ്രവർത്തനരഹിതമാക്കുന്നത് വൈദ്യുതി ഉപഭോഗം വർദ്ധിപ്പിക്കുമെങ്കിലും, ഓഡിയോ പ്ലേബാക്കിലോ പോസ്റ്റ്-പ്രോസസ്സിംഗിലോ പ്രശ്നങ്ങൾ നേരിടുന്നുണ്ടെങ്കിൽ ഇത് ഉപയോഗപ്രദമാകും. + എല്ലാ ഡൗൺലോഡുകളും മായ്‌ക്കുക + ചിത്രങ്ങളുടെ കാഷെയുടെ പരമാവധി വലുപ്പം + ചിത്രങ്ങളുടെ കാഷെ മായ്‌ക്കുക + ഗാനങ്ങളുടെ കാഷെയുടെ പരമാവധി വലുപ്പം + ഗാനങ്ങളുടെ കാഷെ മായ്‌ക്കുക + ഉപയോഗിച്ചത് + എല്ലാ കാഷെയും മായ്‌ക്കണോ? + എല്ലാ ഡൗൺലോഡുകളും മായ്‌ക്കണോ? + കാഷെ ചെയ്ത എല്ലാ ഗാനങ്ങളും മായ്‌ക്കണോ? + കാഷെ ചെയ്ത എല്ലാ ചിത്രങ്ങളും മായ്‌ക്കണോ? + കാഷെ ചെയ്ത എല്ലാ വിവർത്തന മോഡലുകളും മായ്‌ക്കണോ? + + സ്വകാര്യത + കേട്ടതിൻ്റെ ചരിത്രം + കേട്ടതിൻ്റെ ചരിത്രം താൽക്കാലികമായി നിർത്തുക + കേട്ടതിൻ്റെ ചരിത്രം മായ്‌ക്കുക + കേട്ടതിൻ്റെ എല്ലാ ചരിത്രവും മായ്‌ക്കണോ? + തിരയൽ ചരിത്രം താൽക്കാലികമായി നിർത്തുക + തിരയൽ ചരിത്രം മായ്‌ക്കുക + എല്ലാ തിരയൽ ചരിത്രവും മായ്‌ക്കണോ? + തിരയൽ ചരിത്രം + ഉള്ളടക്കം ബ്രൗസ് ചെയ്യാൻ ലോഗിൻ ഉപയോഗിക്കുക + ഇത് നിങ്ങൾ കാണുന്ന ഉള്ളടക്കത്തെ സ്വാധീനിച്ചേക്കാം. ഉദാഹരണത്തിന്, പ്രീമിയം അക്കൗണ്ടിൽ ലോഗിൻ ചെയ്തിട്ടുണ്ടെങ്കിൽ പ്രീമിയം-മാത്രം ആൽബങ്ങൾ കാണിക്കും. + സ്ക്രീൻഷോട്ട് പ്രവർത്തനരഹിതമാക്കുക + ഈ ഓപ്ഷൻ ഓണായിരിക്കുമ്പോൾ, സമീപകാല ആപ്പുകളിലെ സ്ക്രീൻഷോട്ടുകളും ആപ്പ് കാഴ്ചയും പ്രവർത്തനരഹിതമാകും. + LrcLib വരികൾ ദാതാവിനെ പ്രവർത്തനക്ഷമമാക്കുക + KuGou വരികൾ ദാതാവിനെ പ്രവർത്തനക്ഷമമാക്കുക + ഡിഫോൾട്ട് വരികൾ ദാതാവ് + ഒന്നിലധികം വരികളുള്ള വരികൾ വായിക്കുക + സിൻക്രൊണൈസേഷൻ പോയിൻ്റുകൾക്കിടയിലുള്ള എല്ലാ വരികളെയും ഒരൊറ്റ വരിയായി പരിഗണിക്കുക + വരികൾക്ക് ചുറ്റുമുള്ള സ്പെയ്സുകൾ നീക്കം ചെയ്യുക + അക്ഷര വലുപ്പം + അശ്ലീല ഉള്ളടക്കം മറയ്ക്കുക + അറിയിപ്പുകൾ പ്രവർത്തനക്ഷമമാക്കുക + + ബാക്കപ്പും പുനഃസ്ഥാപിക്കലും + ബാക്കപ്പ് + പുനഃസ്ഥാപിക്കുക + പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്യുക (m3u) + പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു + ഇറക്കുമതി ചെയ്ത പ്ലേലിസ്റ്റ് + ബാക്കപ്പ് വിജയകരമായി നിർമ്മിച്ചു + ബാക്കപ്പ് നിർമ്മിക്കാൻ കഴിഞ്ഞില്ല + ബാക്കപ്പ് പുനഃസ്ഥാപിക്കുന്നതിൽ പരാജയപ്പെട്ടു + + Discord സംയോജനം + നിങ്ങളുടെ Discord അക്കൗണ്ടിൻ്റെ സ്റ്റാറ്റസ് സജ്ജീകരിക്കാൻ Loudly KizzyRPC ലൈബ്രറി ഉപയോഗിക്കുന്നു. ഇതിൽ Discord ഗേറ്റ്‌വേ കണക്ഷൻ ഉപയോഗിക്കുന്നത് ഉൾപ്പെടുന്നു, ഇത് Discord-ൻ്റെ സേവന നിബന്ധനകളുടെ ലംഘനമായി കണക്കാക്കാം. എന്നിരുന്നാലും, ഈ കാരണത്താൽ ഉപയോക്തൃ അക്കൗണ്ടുകൾ സസ്പെൻഡ് ചെയ്തതായി അറിയപ്പെടുന്ന കേസുകളൊന്നുമില്ല. സ്വന്തം ഉത്തരവാദിത്തത്തിൽ ഉപയോഗിക്കുക.\n\nLoudly നിങ്ങളുടെ ടോക്കൺ മാത്രം എക്‌സ്‌ട്രാക്റ്റുചെയ്യും, മറ്റെല്ലാം പ്രാദേശികമായി സംഭരിക്കപ്പെടും. + തള്ളിക്കളയുക + ഓപ്ഷനുകൾ + പ്രിവ്യൂ + ലോഗിൻ പരാജയപ്പെട്ടു + ലോഗ് ഔട്ട് + റിച്ച് പ്രെസൻസ് പ്രവർത്തനക്ഷമമാക്കുക + + വിവരം + ആപ്പ് പതിപ്പ് + അപ്‌ഡേറ്റുകൾക്കായി പരിശോധിക്കുന്നു... + പതിപ്പ് %1$s ഡൗൺലോഡ് ചെയ്യാൻ ലഭ്യമാണ്. + ഡൗൺലോഡ് ചെയ്യുന്നു... + നവീകരിക്കുക + വീണ്ടും ശ്രമിക്കുക + ഇപ്പോൾ ഡൗൺലോഡ് ചെയ്യുക + ആപ്പ് തരം + + പുതിയ പതിപ്പ് ലഭ്യമാണ് + വിവർത്തന മാതൃകകൾ + വിവർത്തന മാതൃകകൾ മായ്‌ക്കുക + ചരിത്രത്തിൻ്റെ ദൈർഘ്യം + + 1 സെക്കൻഡ് + %d സെക്കൻഡ് + + സംഗീതം 3x2 + സംഗീതം 2x1 + പ്ലേ/താൽക്കാലികമായി നിർത്തുക + പതിപ്പ് · ഡെവലപ്പർമാർ · ലൈസൻസ് + UI + ഉള്ളടക്കവും അക്കൗണ്ടും + പ്ലെയർ + സിസ്റ്റം + ലൈബ്രറി + സംയോജനങ്ങൾ + സിസ്റ്റം സംയോജനം + ഉപയോഗിച്ച മൊത്തം സ്ഥലം + നിലവിൽ ഉപയോഗിക്കുന്നത് + കാഷെ മായ്‌ക്കുക + ബാക്കപ്പ് · പുനഃസ്ഥാപിക്കൽ · ഇറക്കുമതി + ചരിത്രം · തിരയൽ · സ്ക്രീൻഷോട്ട് + ഡൗൺലോഡുകൾ · കാഷെ · ചിത്രങ്ങൾ + ശബ്ദം · ക്യൂ · വരികൾ + ഭാഷ · സ്ഥാനം · പ്രോക്സി + ലോഗിൻ · Spotify · Discord + തീം · സ്ലൈഡർ · പ്ലെയർ + + പ്രൊഫൈൽ കാർഡ് ചിത്രം + പ്രൊഫൈൽ കാർഡിലെ ചിത്രം ഇഷ്ടാനുസൃതമാക്കുക + പ്രൊഫൈൽ ചിത്രം തിരഞ്ഞെടുക്കുക + തയ്യാറാക്കിയ ചിത്രങ്ങൾ + ഗാലറിയിൽ നിന്ന് തിരഞ്ഞെടുക്കുക + ഫോട്ടോ എഡിറ്റ് ചെയ്യുക + റദ്ദാക്കുക + + ഡെവലപ്പർക്ക് അയയ്ക്കുക + അടയ്ക്കുക + പിശകിൻ്റെ വിശദാംശങ്ങൾ കാണിക്കുക + ഡൗൺലോഡ് പരാജയപ്പെട്ടു + പിശക് കാണിക്കുക + മാനുവൽ അപ്ഡേറ്റ് + വിശദാംശങ്ങൾക്കായി ക്രമീകരണങ്ങൾ പേജ് പരിശോധിക്കുക + സോഫ്റ്റ്‌വെയർ ഏറ്റവും പുതിയതാണ് + + ആപ്പ് UI + 3 വ്യത്യസ്ത ഡിസൈനുകളിൽ ഒന്ന് തിരഞ്ഞെടുക്കുക + UI പതിപ്പ് തിരഞ്ഞെടുക്കൽ + ആപ്ലിക്കേഷൻ്റെ പഴയ പതിപ്പ് ഡിസൈൻ. (ആപ്പ്, പ്ലെയർ, മിനിപ്ലേയർ) + കൂടുതൽ ആധുനികമായ സമീപനത്തോടെ നിർമ്മിച്ച ഒതുക്കമുള്ള ഡിസൈൻ. (ആപ്പ്, പ്ലെയർ, മിനിപ്ലേയർ) + ഏറ്റവും പ്രചാരമുള്ള ഡിസൈൻ. (ആപ്പ്, പ്ലെയർ, മിനിപ്ലേയർ) + നിങ്ങളുടെ സ്വന്തം ആപ്പ് UI ഡിസൈൻ നിർമ്മിക്കുക. (ആപ്പ്, പ്ലെയർ, മിനിപ്ലേയർ) + UI ഇഷ്ടാനുസൃതമാക്കുക + + മറ്റ് ക്രമീകരണങ്ങൾ + മറ്റ് വിവിധ ക്രമീകരണങ്ങൾക്കായി ക്ലിക്ക് ചെയ്യുക + + 45-നും 512 Kbps-നും ഇടയിൽ നിലവാരം യാന്ത്രികമായി ക്രമീകരിക്കുന്നു + 512 Kbps വരെ ഉയർന്ന നിലവാരം നൽകുന്നു + ശരാശരി ഇൻ്റർനെറ്റ് വേഗതയ്ക്കായി 128-നും 256 Kbps-നും ഇടയിൽ നിലവാരം സജ്ജമാക്കുന്നു. + ഡാറ്റ ലാഭിക്കാൻ 45-നും 52 Kbps-നും ഇടയിൽ നിലവാരം പരിമിതപ്പെടുത്തുന്നു (ഡാറ്റ സേവർ) + + ഇറക്കുമതി ചെയ്യാൻ കഴിഞ്ഞില്ല + + ടെക്സ്റ്റ് അലൈൻമെൻ്റ് + കാർഡിലെ ടെക്സ്റ്റിൻ്റെ സ്ഥാനം സജ്ജമാക്കുക + ഗ്രേഡിയൻ്റിൻ്റെ തീവ്രത + കോർണർ റൗണ്ട് + പ്രൊഫൈൽ ചിത്രം + പ്രൊഫൈൽ കാർഡ് + പ്രൊഫൈൽ ചിത്രം നീക്കം ചെയ്യുക + ടെക്സ്റ്റുകൾ കാണിക്കുക + പ്രൊഫൈൽ കാർഡിൽ ആശംസയും ഉപയോക്തൃനാമവും പ്രദർശിപ്പിക്കുക + മാറ്റാൻ ടാപ്പ് ചെയ്യുക + + വിവർത്തനം ചെയ്യുക (ബീറ്റ) + പകർത്തുക + + നെറ്റ്‌വർക്ക് പിശക് + നിങ്ങളുടെ ഇൻ്റർനെറ്റ് കണക്ഷൻ പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക. + പിന്തുണയ്ക്കാത്ത ഉള്ളടക്കം + ഈ ഗാനത്തിൻ്റെ ഫോർമാറ്റ് നിങ്ങളുടെ ഉപകരണത്തിൽ പിന്തുണയ്ക്കുന്നില്ല. + ലൈവ് ബ്രോഡ്കാസ്റ്റ് പിശക് + ലൈവ് സ്ട്രീമിൽ ഒരു പ്രശ്നമുണ്ട്. ദയവായി പുനരാരംഭിക്കുക. + പ്ലേബാക്ക് പരാജയപ്പെട്ടു + അജ്ഞാതമായ ഒരു പിശക് സംഭവിച്ചു. ദയവായി വീണ്ടും ശ്രമിക്കുക. + വീണ്ടും ശ്രമിക്കുന്നു… + ഒരു VPN ഉപയോഗിക്കുന്നത് നിങ്ങളുടെ കണക്ഷൻ്റെ ഗുണനിലവാരത്തെ ബാധിച്ചേക്കാം. നിങ്ങൾക്ക് നെറ്റ്‌വർക്ക് പ്രശ്‌നങ്ങൾ അനുഭവപ്പെടുന്നുണ്ടെങ്കിൽ, നിങ്ങളുടെ VPN കണക്ഷൻ വിച്ഛേദിക്കുകയോ ഒരു പ്രോക്‌സി ഉപയോഗിക്കുകയോ ചെയ്യുക. ഇത് “ക്രമീകരണങ്ങൾ >> ഉള്ളടക്കം >> പ്രോക്‌സി” എന്നതിൽ ക്രമീകരിക്കാവുന്നതാണ്. + + പ്രാദേശിക ഗാനങ്ങൾ വിജയകരമായി സിൻക്രൊണൈസ് ചെയ്തു + പ്രാദേശിക സ്കാനർ + + യാന്ത്രികമായി സ്കാൻ ചെയ്യുക + ആപ്പ് തുറക്കുമ്പോൾ ഗാനങ്ങൾക്കായി യാന്ത്രികമായി സ്കാൻ ചെയ്യുക + + അധിക സ്കാനർ ക്രമീകരണങ്ങൾ + കർശനമായ ഫയൽ നാമങ്ങൾ + പ്രവർത്തനക്ഷമമാക്കുമ്പോൾ എക്സ്റ്റൻഷനുകൾ അവഗണിക്കില്ല. ഉദാഹരണത്തിന്, "Song.ogg" എന്നത് "Song.flac" ൽ നിന്ന് വ്യത്യസ്തമായ ഒരു ഗാനമായിരിക്കും. + സ്കാനർ സെൻസിറ്റിവിറ്റി ക്രമീകരിക്കുക + തലക്കെട്ടുമായി പൊരുത്തപ്പെടുത്തുക + തലക്കെട്ടും കലാകാരന്മാരുമായി പൊരുത്തപ്പെടുത്തുക + തലക്കെട്ട്, കലാകാരന്മാർ, ആൽബങ്ങൾ എന്നിവയുമായി പൊരുത്തപ്പെടുത്തുക + + ഫോൾഡറുകൾ + ഉപഫോൾഡറുകൾ നിരപ്പാക്കുക + ഡിസ്കിലെ ഫയൽ ഘടന നിലനിർത്താൻ പ്രവർത്തനരഹിതമാക്കുക + + നുറുങ്ങുകളും സവിശേഷതകളും + നുറുങ്ങുകൾ · സവിശേഷതകൾ + നിങ്ങളുടെ സ്റ്റോറി സൃഷ്ടിക്കുക + ആൽബം കവറിലെ പ്രധാന നിറങ്ങൾ ഉപയോഗിച്ച് നിർമ്മിച്ച ഒരു സ്റ്റൈലിഷ് ഡിസൈനോടെ നിങ്ങൾ കേൾക്കുന്ന ഗാനം സോഷ്യൽ മീഡിയയിൽ പങ്കിടുക. + ഇഷ്ടാനുസൃതമാക്കാവുന്ന ക്രമീകരണങ്ങൾ + ക്രമീകരണങ്ങൾ സ്ക്രീനിലെ ടൈറ്റിൽ കാർഡിൻ്റെ പശ്ചാത്തലം നിങ്ങളുടെ ഗാലറിയിൽ നിന്നുള്ള ഒരു ചിത്രം ഉപയോഗിച്ച് മാറ്റി ആപ്പ് നിങ്ങളുടേതാക്കുക. + പുതിയ ഗാനങ്ങളുടെ ലിസ്റ്റ് + നിങ്ങളുടെ നിലവിലെ പ്ലേലിസ്റ്റ് കാണുകയും പ്ലെയർ സ്ക്രീനിൽ നിന്ന് മുകളിലേക്ക് സ്വൈപ്പ് ചെയ്ത് ഗാനങ്ങൾക്കിടയിൽ എളുപ്പത്തിൽ മാറുകയും ചെയ്യുക (പ്ലെയർ UI 2.0-ന് മാത്രമുള്ള പുതിയ ഡിസൈൻ) + പുതിയ ക്രമീകരണങ്ങളുടെ ഡിസൈൻ + കൂടുതൽ ആധുനികവും സൗകര്യപ്രദവുമായ പുതിയ കാർഡ് അടിസ്ഥാനമാക്കിയുള്ള ക്രമീകരണങ്ങളുടെ ഡിസൈൻ ഉപയോഗിച്ച് നിങ്ങൾ തിരയുന്നത് എളുപ്പത്തിൽ കണ്ടെത്തുക. + ക്ലാസിക് മ്യൂസിക് വിഡ്ജറ്റ് + നിങ്ങളുടെ ഹോം സ്ക്രീനിൽ നിന്ന് എല്ലാ നിയന്ത്രണങ്ങളിലേക്കും ഗാന വിവരങ്ങളിലേക്കും വേഗത്തിൽ പ്രവേശിക്കുക. + മിനിമലിസ്റ്റ് മ്യൂസിക് വിഡ്ജറ്റ് + Android 16-ൻ്റെ ക്വിക്ക് സെറ്റിംഗ്സ് ബട്ടൺ ഡിസൈനിൽ നിന്ന് പ്രചോദനം ഉൾക്കൊണ്ട “ക്വിക്ക്” മ്യൂസിക് വിഡ്ജറ്റ് + സ്റ്റോറിയിൽ പങ്കിടുക + ഗാനത്തിൻ്റെ ലിങ്ക് പകർത്തുക + ലിങ്ക് ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി! + Loudly-ൽ സ്ട്രീം ചെയ്തു + + എല്ലാം തിരഞ്ഞെടുക്കുക + എല്ലാ പ്ലേലിസ്റ്റുകളും ലഭ്യമാക്കുന്നു… + തിരഞ്ഞെടുത്തവ ഇറക്കുമതി ചെയ്യുക + എന്തോ കുഴപ്പം സംഭവിച്ചു. നിങ്ങളെപ്പോലെ ഞങ്ങളും ആശയക്കുഴപ്പത്തിലാണ്. + അയ്യോ, ഇത് നാണക്കേടായി. എന്താണ് സംഭവിച്ചതെന്ന് ഞങ്ങൾക്കും ഒരു പിടിയുമില്ല. + ആവശ്യമായ ക്രെഡൻഷ്യലുകൾ ലഭിക്കുന്നതിനുള്ള നിർദ്ദേശങ്ങൾ + 3. മുകളിലെ ലോക്കൽ പാത്ത് നൽകിയ ശേഷം "ചേർക്കുക" ക്ലിക്ക് ചെയ്യുന്നത് ഉറപ്പാക്കുക. + 5. സേവന നിബന്ധനകൾ അംഗീകരിച്ച് "സേവ് ചെയ്യുക" ക്ലിക്ക് ചെയ്യുക. + നിങ്ങളെ ഒരു പുതിയ പേജിലേക്ക് റീഡയറക്ട് ചെയ്യും. ക്രമീകരണങ്ങളിൽ ക്ലിക്ക് ചെയ്യുക, തുടർന്ന് ക്ലയിൻ്റ് ഐഡിയും ക്ലയിൻ്റ് സീക്രട്ടും പകർത്തി ഈ ആപ്പിലെ താഴെയുള്ള ടെക്സ്റ്റ് ഫീൽഡുകളിൽ ഒട്ടിക്കുക. + 2. ആവശ്യമായ വിശദാംശങ്ങൾ നൽകി, റീഡയറക്ട് URI ആയി http://127.0.0.1:45454 ഉപയോഗിക്കുക. + 4. വെബ് എപിഐ ടോഗിൾ ചെയ്യുക. ചെക്ക്ബോക്സിൽ ഇപ്പോൾ ഒരു ടിക്ക് മാർക്ക് ഉണ്ടായിരിക്കണം. + ഇനി താഴെയുള്ള അംഗീകാരം നൽകി തുടരുക ബട്ടൺ ക്ലിക്ക് ചെയ്ത്, നിങ്ങൾ ഇറക്കുമതി ചെയ്യാൻ ആഗ്രഹിക്കുന്ന അക്കൗണ്ടിൽ ലോഗിൻ ചെയ്യുക, തുടർന്ന് അംഗീകാരം നൽകി തുടരുക, ഇത് നിങ്ങളെ http://127.0.0.1:45454/?code= എന്ന് തുടങ്ങുന്ന ഒരു സൈറ്റിലേക്ക് റീഡയറക്ട് ചെയ്യും\n\nആ മുഴുവൻ URL-ഉം താഴെയുള്ള "അംഗീകാര കോഡ്" എന്ന ടെക്സ്റ്റ് ഫീൽഡിൽ ഒട്ടിച്ച് "പ്രാമാണീകരിക്കുക" ബട്ടൺ ക്ലിക്ക് ചെയ്യുക. + 1. സ്പോട്ടിഫൈ ഡെവലപ്പർമാർക്കുള്ള ഡാഷ്ബോർഡ് സന്ദർശിച്ച് "ആപ്പ് സൃഷ്ടിക്കുക" ക്ലിക്ക് ചെയ്യുക. + Spotify API ക്രെഡൻഷ്യലുകൾ ഉപയോഗിച്ച് ലോഗിൻ ചെയ്യുക + അംഗീകാരം നൽകി തുടരുക + അംഗീകാര കോഡ് + പ്രാമാണീകരിക്കുക + ക്ലയിൻ്റ് സീക്രട്ട് + ക്ലയിൻ്റ് ഐഡി + ഇറക്കുമതി പുരോഗമിക്കുന്നു… + ആപ്പ് അടയ്ക്കുകയോ പിന്നോട്ട് പോകുകയോ ചെയ്യരുത്. ഈ പ്രവർത്തനം പശ്ചാത്തലത്തിൽ പ്രവർത്തിക്കുന്നില്ല, അതിനാൽ ഇത് പൂർത്തിയാകുന്നതുവരെ കാത്തിരിക്കുക!\nഇത് സ്തംഭിച്ചതായി തോന്നുന്നുവെങ്കിൽ പരിഭ്രാന്തരാകരുത്; ചിലപ്പോൾ വീണ്ടെടുക്കലിന് കുറച്ച് സമയമെടുത്തേക്കാം. + "ഇഷ്ടപ്പെട്ട ഗാനങ്ങൾ" എവിടെ സേവ് ചെയ്യണമെന്ന് തിരഞ്ഞെടുക്കുക + ഇഷ്ടപ്പെട്ട ഗാനങ്ങൾ എവിടെയാണ് ഇറക്കുമതി ചെയ്യേണ്ടത്? + "ഇഷ്ടപ്പെട്ട ഗാനങ്ങൾ" എന്ന് പേരിട്ട ഒരു പുതിയ പ്ലേലിസ്റ്റ് + ഡിഫോൾട്ട് "ഇഷ്ടപ്പെട്ട ഗാനങ്ങൾ" എന്നതിലേക്ക് + \ No newline at end of file diff --git a/app/src/main/res/values-ota/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml similarity index 100% rename from app/src/main/res/values-ota/strings.xml rename to app/src/main/res/values-nb-rNO/strings.xml diff --git a/app/src/main/res/values-night-v31/colors.xml b/app/src/main/res/values-night-v31/colors.xml deleted file mode 100644 index 1ef5bbe..0000000 --- a/app/src/main/res/values-night-v31/colors.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - #332f2f - #fddbd6 - @color/splash_bg_dark - #FFFFFF - #121212 - \ No newline at end of file diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml new file mode 100644 index 0000000..f34e4ec --- /dev/null +++ b/app/src/main/res/values-night-v31/themes.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml deleted file mode 100644 index 1ef5bbe..0000000 --- a/app/src/main/res/values-night/colors.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - #332f2f - #fddbd6 - @color/splash_bg_dark - #FFFFFF - #121212 - \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml deleted file mode 100644 index aba08cd..0000000 --- a/app/src/main/res/values-nl/strings.xml +++ /dev/null @@ -1,278 +0,0 @@ - - - AirNote - Notities - Nieuwe notitie - Geen notities aangemaakt. - Geen notities gevonden. - Aanmaaktijd - Rasterweergave - Kolomweergave - Woorden - Tekens - Naam - Beschrijving - Alles selecteren - Verwijderen - Informatie - Zoeken - Instellingen - Weergave - Systeemthema - Donker thema - Dynamische kleuren - Amoled-kleuren - Database - Back-up - Nog niet ondersteund - Over - Versie - Nieuwste uitgave - Geef project een ster - Doneer aan mij :) - Herstellen - Notities - Ondersteuning - - Taal - Markdown - Gereedschap - Geschiedenis - Widgets - Thema\'s • Grootte • Lettertype - Cloud - Lettertype • Taal - NextCloud • Lokaal • Versleuteling - Versie • Ontwikkelaars • Licentie - Widgets • Markdown - Geschiedenis • Terugdraaien - Aangemaakt • Alles verwijderen - Ondersteuning • Afbeeldingen - Toon je liefde - Lettertype - Radius - Selecteer radius - Minimalistische modus - Extreme Amoled-kleuren - Privacy - Schermbeveiliging - Afbeeldingsondersteuning - Instabiel - Cryptovaluta - Past het app-thema aan op basis van systeeminstellingen - Schakel tussen donkere en lichte kleurenschema\'s - Past kleuren aan om bij je achtergrond te passen - Gebruik zwarte kleur voor de achtergrond - Extreme batterij-optimalisatie - Pas de randradius aan - Selecteer weergavestijl - Verwijder de bovenste knoppen van het bewerkingsscherm - Sla alle notities op in een bestand - Herstel notities vanuit een back-upbestand - Schakel markdown in notities in - Verberg het scherm in recente apps en schakel schermafbeeldingen uit - Build-type - Broncode - Lijst van supporters - E-mail - Discord - Foutrapportage en meer - Vastgezet - Anderen - Kopiëren - Database versleutelen - Database versleutelen tijdens back-up - Voer wachtwoord in - Wachtwoord is ongeldig of de database is niet versleuteld - Ongeldige invoer - Sorteer oplopend - Sorteer aflopend - Sorteer notities op hun aanmaaktijd - Selecteer notitie - Niet-ondersteunde afbeeldingsgrootte (max. 15mb) - Kluis - Schakel versleutelde notitiekluis in - doorgaan - Verander de taal van de app - Systeemtaal - Gedrag - Geef prioriteit aan bewerkingsmodus - Ga naar de bewerkingsmodus bij bestaande notities - Ongeldig wachtwoord - Doneren - Vertaling - Bijdragers - Verwijdert afbeeldingen uit de app-opslag wanneer ze uit de galerij worden verwijderd - Galerij-synchronisatie - Toon alleen titel - Toon alleen de titel in de voorvertoning - 1. Acceptatie van de voorwaarden - Door op "Akkoord" te drukken en Easy Notes te gebruiken, accepteert u deze servicevoorwaarden. Als u niet akkoord gaat, gebruik de applicatie dan niet. - 2. Licentie - AirNote (Easy Notes) wordt gedistribueerd onder de **GNU General Public License (GPL) versie 3**. Dit betekent:\n\n- U bent vrij om de applicatie te gebruiken, aan te passen en te delen.\n- Als u de applicatie of gewijzigde versies distribueert, moet u ook de broncode distribueren en de GPL-licentie behouden. - 3. Gebruik van de applicatie - U stemt ermee in om AirNote (Easy Notes) alleen voor wettige doeleinden en in overeenstemming met deze voorwaarden te gebruiken. U mag **niet**:\n\n- _De app voor enig onwettig doel gebruiken_\n- _De rechten van anderen schenden_\n- _De functionaliteit of beveiliging van de app verstoren_ - 4. Intellectueel eigendom - Alle intellectuele eigendomsrechten in AirNote (Easy Notes) zijn eigendom van ons of onze licentiegevers. Uw gebruik van de app verleent u geen eigendom van intellectuele eigendomsrechten. - 5. Afwijzing van garanties - Easy Notes wordt "as is" geleverd, zonder enige garanties. Wij garanderen niet dat de app vrij zal zijn van fouten, veilig of ononderbroken. - 6. Beperking van aansprakelijkheid - Wij zijn niet aansprakelijk voor enige schade die voortvloeit uit uw gebruik van AirNote (Easy Notes), inclusief indirecte, incidentele of gevolgschade. - 7. Beëindiging - Wij kunnen uw toegang tot AirNote (Easy Notes) beëindigen of opschorten als u deze voorwaarden schendt. Bij beëindiging eindigt uw recht om de app te gebruiken onmiddellijk. - 8. Wijzigingen in de voorwaarden - Wij kunnen deze servicevoorwaarden van tijd tot tijd bijwerken. Uw voortgezet gebruik van AirNote (Easy Notes) na eventuele wijzigingen houdt acceptatie van de herziene voorwaarden in. - 9. Neem contact met ons op - Voor vragen over deze servicevoorwaarden of AirNote (Easy Notes), kunt u contact met ons opnemen via - 10. Privacybeleid - AirNote (Easy Notes) verzamelt geen persoonlijke gegevens. Raadpleeg voor meer informatie ons privacybeleid dat beschikbaar is op - Ingangsdatum - Akkoord - Servicevoorwaarden - Kon niet importeren - Import van sommige bestanden mislukt - Succesvol geïmporteerd - Importeren - Importeer andere tekstbestanden als notities - Gebruik monospace-lettertype bij het bewerken van een notitie - Monospace-lettertype - Weet u zeker dat u de geselecteerde notities wilt verwijderen - Ja - Annuleren - App-vergrendeling - Vergrendel de app met een wachtwoord - Stel toegangscode in - Voer toegangscode in - Pincode (6 cijfers) - Toegangscode - Vingerafdruk - Authenticatie vereist - is vergrendeld - UID - Vraag om vingerafdruk bij start - Vraag om toegangscode bij start - Patroon - Vraag om patroon bij start - Stel patroon in - Voer patroon in - Onmiddellijk vergrendelen - Onmiddellijk vergrendelen wanneer de app wordt hervat - Schakel vegen uit - "Schakel vegen voor voorvertoning uit in de bewerkingsmodus" - Schaal zoekbalkbreedte - Maak de breedte van de zoekbalk gelijk aan de breedte van de notities - Lettergrootte - Verander de grootte van de tekst in notities - Lettergrootte: %1$d - Voorvertoning - Zo zien je notities eruit met de geselecteerde lettergrootte. - Oprichter van Babel Software en Android-ontwikkelaar - Nieuwe map toevoegen - Mapnaam - Mapkleur - Toevoegen - Verplaatsen naar map - Geen mappen beschikbaar - Map verwijderen - Weet u zeker dat u de map ‘%1$s’ wilt verwijderen? De notities erin worden verplaatst naar de sectie ‘Alle notities’. - Map bewerken - Opslaan - Maplabel - Toont het label van de map waartoe de notities behoren. - AirNote AI Instellingen - Configureer AI · API-instellingen - Gebruik AirNote API - Gebruik AI-functies met de ingebouwde API van de app (Aanbevolen) - Uw API-sleutel (BETA) - Controleren - Te gebruiken model - Modelkeuze (BETA) - AI\'s suggestie - Sluiten - Alle notities - Vraag, chat, vind met AI... - Hoe krijg ik mijn eigen API-sleutel? - Vanuit Google AI Studio kunt u een persoonlijke API-sleutel genereren met een royale gratis gebruikslimiet. Deze sleutel is alleen van u en u heeft de volledige controle. - 1. Ga naar de Google AI Studio-website door op de onderstaande knop te klikken. - 2. Log in met uw Google-account. - 3. Klik in het scherm dat opent op de knop \"API-sleutel aanmaken\". - 4. U wordt mogelijk gevraagd een nieuw project aan te maken, volg de stappen om dit te doen. - 5. Kopieer de gegenereerde lange tekenreeks. - 6. Plak de gekopieerde sleutel in het onderstaande tekstvak en druk op de knop \"Controleren\". - Ga naar Google AI Studio - Voer eerst een API-sleutel in. - API-sleutel geldig en succesvol geregistreerd! ✅ - Ongeldige API-sleutel. Controleer en probeer opnieuw. - Stel een vraag - Maak een notitie over een onderwerp - Genereer ideeën - Geef me 5 interessante ideeën over een willekeurig onderwerp - Nieuwe versie - Bijwerken - Nieuwe update beschikbaar! - App bijwerken - Wijzigingslogboek kon niet worden opgehaald. - Kon wijzigingslogboek niet ontvangen: %1$s - Controleren op updates... - Update beschikbaar - Nieuwe versie beschikbaar! - Versie %1$s is beschikbaar om te downloaden. - Nu bijwerken - Downloaden - Downloaden... %1$d%% - Installeren - Installeren - Update mislukt - Fout - Probeer opnieuw - Probeer opnieuw - Je app is up-to-date - Bijgewerkt - De update vereist toestemming om te installeren vanuit onbekende bronnen. - Downloadlink niet gevonden. - Innovaties - Save Note - Re-Generate - - Schrijfstijl optimaliseren - Samenvatten - Maak het korter - Maak het langer - Toon wijzigen - - Formeel - Gebalanceerd - Vriendelijk - Gebruikers-API-sleutel niet gevonden. Controleer dit in de instellingen. - API-verzoek mislukt: %1$s - Hallo daar! Hoe kan ik je helpen? - SUGGESTIES - Lijst de voor- en nadelen op - Takenlijst maken - Vertel het eenvoudiger - Aanbevolen voor geïmporteerde bestanden - Titel voorstellen - - Geweldig, waarover moeten we een concept maken? Schrijf het onderwerp. - - Verbeter de grammatica- en uitdrukkingsfouten in de onderstaande tekst om deze vloeiender te maken. Antwoord alleen met de gecorrigeerde tekst:\n\n---\n\n%1$s - Vat de onderstaande tekst samen in één of twee zinnen, met behoud van de hoofdgedachte. Antwoord alleen met de samenvattingstekst:\n\n---\n\n%1$s - Kort de volgende tekst aanzienlijk in met behoud van de betekenis. Antwoord alleen met de ingekorte tekst:\n\n---\n\n%1$s - Voeg creatieve details toe aan de onderstaande tekst om deze langer te maken. Antwoord alleen met de uitgebreide tekst:\n\n---\n\n%1$s - AI-servers zijn momenteel bezet. Probeer het over een paar minuten opnieuw. - - %1$s\n\n---\n\n%2$s - Herschrijf de onderstaande tekst in een professionele en formele taal. Behoud de betekenis, verander alleen de toon. Antwoord alleen met de definitieve tekst: - Herschrijf de onderstaande tekst in een evenwichtige taal, niet te formeel en niet te joviaal. Behoud de betekenis. Antwoord alleen met de conclusie: - Herschrijf de onderstaande tekst op een meer oprechte, warme, gemoedelijke en vriendelijke manier. Behoud de betekenis. Antwoord alleen met de conclusie: - - Een gebruiker van een notitie-app werkt aan een notitie met de titel \"%1$s\". Stel enkele creatieve en inspirerende ideeën of uitgangspunten voor die bij deze titel passen. Houd je antwoord kort en in lijstformaat. - Ga verder met de onderstaande tekst, met behoud van de betekenisintegriteit en de stijl van de auteur. Antwoord alleen op de nieuwe tekst, herhaal de bestaande tekst niet:\n\n---\n\n\nTitel: %1$s\n\nInhoud:\n%2$s - Analyseer de onderstaande tekst en schrijf een paragraaf over hoe je dit onderwerp vanuit een compleet ander perspectief kunt benaderen. Vat de bestaande tekst niet samen, maar bied een alternatief perspectief:\n\n---\n\n\nTitel: %1$s\n\nInhoud:\n%2$s - Analyseer de onderstaande tekst en lijst de positieve en negatieve punten op onder de kopjes \'### Voordelen\' en \'### Nadelen\'. Antwoord alleen met deze twee kopjes en items:\n\n\n---\n\n\n\n%1$s - Analyseer de onderstaande tekst. Als de tekst taken vermeldt die gedurende de dag moeten worden uitgevoerd, verander ze dan in een takenlijst door \'[] \' aan het begin van elke regel te plaatsen. Als de tekst geen taken vermeldt, antwoord dan ALLEEN \'NO_TASKS\' zonder iets anders te typen. Detecteer de taal van de tekst en maak de lijst in die taal:\n\n---\n\n%1$s - Vertaal de complexe tekst hieronder in eenvoudige en vloeiende taal die iedereen kan begrijpen. Behoud de hoofdgedachte en belangrijke details van de tekst. Antwoord alleen met de vereenvoudigde tekst:\n\n---\n\n%1$s - Stel 5 creatieve en aandachttrekkende titels voor die het beste passen bij de inhoud van de onderstaande tekst. Geef je antwoord als een genummerde lijst:\n\n---\n\n%1$s - - Een gebruiker wil een conceptnotitie maken over het onderwerp \"%1$s\". U MOET uw antwoord in het volgende formaat geven: TITEL: [voer hier de titel in]\n\nINHOUD: [voer hier de notitie-inhoud in] - \ No newline at end of file diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml new file mode 100644 index 0000000..6c1648c --- /dev/null +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -0,0 +1,270 @@ + + + ଘର + ଗୀତ + କଳାକାର ମାନେ + ଆଲବମ୍ + ପ୍ଲେଲିଷ୍ଟଗୁଡିକ + + + + %d selected + %d selected + + + + History + Stats + Mood and Genres + Account + Quick picks + Listen to songs to generate your quick picks + New release albums + + + Today + Yesterday + This week + Last week + + + Most played songs + Most played artists + Most played albums + + + ସନ୍ଧାନ କରନ୍ତୁ + ୟୁଟ୍ୟୁବ୍ ମ୍ୟୁଜିକ୍ ଖୋଜ… + ଲାଇବ୍ରେରୀ ଖୋଜ… + Library + Liked + Downloaded + ସମସ୍ତ + ସଙ୍ଗୀତ ଗୁଡ଼ିକ + ଭିଡିଓ ଗୁଡ଼ିକ + ଆଲବମ୍ ଗୁଡ଼ିକ + କଳାକାର ମାନେ + ପ୍ଲେଲିଷ୍ଟ ଗୁଡ଼ିକ + ସମ୍ପ୍ରଦାୟ ପ୍ଲେଲିଷ୍ଟଗୁଡିକ + ବୈଶିଷ୍ଟ୍ୟଯୁକ୍ତ ତାଲିକାଗୁଡ଼ିକ + Bookmarked + No results found + + + ଆପଣଙ୍କ ଲାଇବ୍ରେରୀରୁ + + + ପସନ୍ଦିତ ଗୀତ ଗୁଡ଼ିକ + ସଞ୍ଚିତ ଗୀତ ଗୁଡ଼ିକ + The playlist is empty + + + ପୁନଃ ଚେଷ୍ଟା କରନ୍ତୁ + ରେଡିଓ + ଶଫଲ୍ କରନ୍ତୁ + Reset + + + ବିବରଣୀ + ସଂପାଦନା କରନ୍ତୁ + ରେଡିଓ ଆରମ୍ଭ କରନ୍ତୁ + ଚଲାନ୍ତୁ + ପରବର୍ତ୍ତୀ ରେ ଚଲାନ୍ତୁ + ଧାଡିରେ ଯୋଡନ୍ତୁ + ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ + ଲାଇବ୍ରେରୀରୁ ଅପସାରଣ କରନ୍ତୁ + ଡାଉନଲୋଡ୍ କରନ୍ତୁ + Downloading + ଡାଉନଲୋଡ୍ ଅପସାରଣ କରନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ଆମଦାନୀ କରନ୍ତୁ + ପ୍ଲେ ଲିଷ୍ଟରେ ଯୋଡନ୍ତୁ + କଳାକାର ଦେଖନ୍ତୁ + ଆଲବମ୍ ଦେଖନ୍ତୁ + ପୁନଃ ଆଣନ୍ତୁ + ଅଂଶୀଦାର କରନ୍ତୁ + ବିଲୋପ କରନ୍ତୁ + Remove from history + ଅନଲାଇନ୍ ସନ୍ଧାନ କରନ୍ତୁ + Sync + Advanced + + + ତାରିଖ ରେ ଯୋଡା ଯାଇଛି + ନାମ + କଳାକାର + ବର୍ଷ + ସଙ୍ଗୀତ ସଂଖ୍ୟା + ସମୟ + ଚାଲିଥିବା ସମୟ + Custom order + + + ମିଡିଆ id + MIME ପ୍ରକାର + କୋଡେକସ୍ + ବିଟ୍ରେଟ୍ + ନମୁନା ହାର + ସ୍ୱର ର ଉଚ୍ଚତା + ସ୍ୱର ର ତୀବ୍ରତା + ଫାଇଲ୍ ଆକାର + ଅଜ୍ଞାତ + କ୍ଲିପବୋର୍ଡରେ କପି କରାଯାଇଛି + + ଗୀତ ପାଠ୍ୟ ଗୁଡିକ ସଂପାଦନ କରନ୍ତୁ + ଗୀତ ପାଠ୍ୟ ଖୋଜ + + ଗୀତ ସଂପାଦନ କରନ୍ତୁ + ଗୀତର ଆଖ୍ୟା + ଗୀତ କଳାକାରମାନେ + ଗୀତର ଆଖ୍ୟା ଖାଲି ହୋଇପାରିବ ନାହିଁ । + ଗୀତ କଳାକାର ଖାଲି ହୋଇପାରିବ ନାହିଁ । + ସଞ୍ଚୟ କରନ୍ତୁ + + ପ୍ଲେଲିଷ୍ଟ ବାଛନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ସଂପାଦନ କରନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ସୃଷ୍ଟି କରନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ନାମ + ପ୍ଲେଲିଷ୍ଟ ନାମ ଖାଲି ହୋଇପାରିବ ନାହିଁ । + + କଳାକାର ସଂପାଦନ କରନ୍ତୁ + କଳାକାର ନାମ + କଳାକାରଙ୍କ ନାମ ଖାଲି ହୋଇପାରିବ ନାହିଁ । + + + + %d song + %d songs + + + %d artist + %d artists + + + %d album + %d albums + + + %d playlist + %d playlists + + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + + + + ପ୍ଲେଲିଷ୍ଟ ଆମଦାନୀ ହୋଇଛି + Removed \"%s\" from playlist + Playlist synced + Undo + + + ଗୀତ ର ପାଠ୍ୟ ମିଳିଲା ନାହିଁ + Sleep timer + End of song + + 1 minute + %d minutes + + କୌଣସି ଷ୍ଟ୍ରିମ୍ ଉପଲବ୍ଧ ନାହିଁ + କୌଣସି ନେଟୱର୍କ ସଂଯୋଗ ନାହିଁ + ସମୟ ଶେଷ + ଅଜ୍ଞାତ ତ୍ରୁଟି + + + ପସନ୍ଦ କରନ୍ତୁ + ପଶନ୍ଦ ଅପସାରଣ କରନ୍ତୁ + Shuffle on + Shuffle off + Repeat mode off + Repeat current song + Repeat queue + + + ସମସ୍ତ ଗୀତ + ଖୋଜାଜାଇଥିବା ଗୀତ ଗୁଡ଼ିକ + + + ସଙ୍ଗୀତ ଚାଳକ + + + ସେଟିଂ ସମୂହ + ଦୃଶ୍ୟତା + Enable dynamic theme + ଗାଢ଼ ଥିମ୍ + ଅନ୍ + ଅଫ୍ + ସିଷ୍ଟମ୍ ଅନୁସରଣ କରନ୍ତୁ + Pure black + ଡିଫଲ୍ଟ ଖୋଲା ଟ୍ୟାବ୍ + ନାଭିଗେସନ୍ ଟ୍ୟାବ୍ କଷ୍ଟୋମାଇଜ୍ କରନ୍ତୁ + ଗୀତ ପାଠ୍ୟ ଅବସ୍ଥାନ + ବାମ + କେନ୍ଦ୍ର + ଦକ୍ଷିଣ + + ବିଷୟବସ୍ତୁ + ଭିତରକୁ ଯାଅ + ଡିଫଲ୍ଟ ବିଷୟବସ୍ତୁ ଭାଷା + ଡିଫଲ୍ଟ ବିଷୟବସ୍ତୁ ଦେଶ + ସିଷ୍ଟମ୍ ଡିଫଲ୍ଟ + ପ୍ରକ୍ସି ସକ୍ଷମ କରନ୍ତୁ + ପ୍ରକ୍ସି ପ୍ରକାର + ପ୍ରକ୍ସି URL + କାର୍ଯ୍ୟକାରୀ ହେବାକୁ ପୁନଃ ଆରମ୍ଭ କରନ୍ତୁ + + ପ୍ଲେୟାର ଏବଂ ଅଡିଓ + ଅଡିଓ ଗୁଣବତ୍ତା + ସ୍ଵତଃ + ଅଧିକ + କମ୍ + ପୂର୍ବ ଗୀତ ଗୁଡ଼ିକୁ ଧାଡିରେ ରଖନ୍ତୁ + ନୀରବତା ଏଡାନ୍ତୁ + ଅଡିଓ ସାଧାରଣକରଣ + ସମାନତା + + ଭଣ୍ଡାର + କ୍ୟାଚ୍ + Image Cache + Song Cache + Max cache size + Unlimited + Clear all downloads + ସର୍ବାଧିକ ପ୍ରତିଛବି କ୍ୟାଚ୍ ଆକାର + ପ୍ରତିଛବି କ୍ୟାଚ୍ ସଫା କରନ୍ତୁ + ସର୍ବାଧିକ ଗୀତ କ୍ୟାଚ୍ ଆକାର + Clear song cache + ବ୍ୟବହୃତ + + ଗୋପନୀୟତା + Pause listen history + Clear listen history + Are you sure to clear all listen history? + ସନ୍ଧାନ ଇତିହାସକୁ ବିରତି ଦିଅ + ସନ୍ଧାନ ଇତିହାସ ସଫା କରନ୍ତୁ + ଆପଣ ସମସ୍ତ ସନ୍ଧାନ ଇତିହାସ ସଫା କରିବାକୁ ନିଶ୍ଚିତ କି? + KuGou ଗୀତ ପ୍ରଦାନକାରୀକୁ ସକ୍ଷମ କରନ୍ତୁ + + ନକଲ ସଂରକ୍ଷଣ ଏବଂ ପୁନରୁଦ୍ଧାର କରନ୍ତୁ + ନକଲ ସଂରକ୍ଷଣ + ପୁନରୁଦ୍ଧାର କରନ୍ତୁ + Imported playlist + ବ୍ୟାକଅପ୍ ସଫଳତାର ସହିତ ସୃଷ୍ଟି ହେଲା + ବ୍ୟାକଅପ୍ ସୃଷ୍ଟି କରିପାରିବ ନାହିଁ + ବ୍ୟାକଅପ୍ ପୁନରୁଦ୍ଧାର କରିବାରେ ବିଫଳ + + ବିଷୟରେ + ଆପ୍ ସଂସ୍କରଣ + + New version available + Translation Models + Clear translation models + diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml new file mode 100644 index 0000000..6ea85ab --- /dev/null +++ b/app/src/main/res/values-pa/strings.xml @@ -0,0 +1,270 @@ + + + ਘਰ + ਗੀਤ + ਕਲਾਕਾਰ + ਐਲਬਮ + ਪਲੇਲਿਸਟ + + + + %d ਚੁਣਿਆ ਗਿਆ + %d ਚੁਣੇ ਗਏ + + + + ਅਤੀਤ + ਅੰਕੜੇ + Mood and Genres + Account + ਉਂਗਲਾਂ ਤੇ ਚੁਣੇ ਗੀਤ + Listen to songs to generate your quick picks + ਨਵੀਆਂ ਜਾਰੀ ਐਲਬਮਾਂ + + + ਅੱਜ + ਕੱਲ੍ਹ + ਇਸ ਹਫਤੇ + ਪਿਛਲੇ ਹਫਤੇ + + + ਵੱਧ ਸੁਣੇ ਗਏ ਗੀਤ + Most played artists + Most played albums + + + ਖੋਜੋ + ਯੂਟਿਊਬ ਮਿਊਜ਼ਿਕ ਖੋਜੋ… + ਲਾਇਬ੍ਰੇਰੀ ਖੋਜੋ… + Library + Liked + Downloaded + ਸਾਰੇ + ਗੀਤ + ਵੀਡੀਓ + ਐਲਬਮ + ਕਲਾਕਾਰ + ਪਲੇਲਿਸਟਾਂ + ਕਮਿਊਨਿਟੀ ਪਲੇਲਿਸਟਾਂ + ਪ੍ਰਦਰਸ਼ਿਤ ਪਲੇਲਿਸਟਾਂ + Bookmarked + ਕੋਈ ਨਤੀਜੇ ਨਹੀਂ ਲੱਭੇ + + + ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ + + + ਪਸੰਦ ਕੀਤੇ ਗੀਤ + ਡਾਊਨਲੋਡ ਕੀਤੇ ਗੀਤ + ਪਲੇਲਿਸਟ ਖਾਲੀ ਹੈ + + + ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ + ਰੇਡੀਓ + ਸ਼ਫਲ ਕਰੋ + Reset + + + ਵੇਰਵੇ + ਸੰਪਾਦਿਤ ਕਰੋ + ਰੇਡੀਓ ਸ਼ੁਰੂ ਕਰੋ + ਚਲਾਓ + ਅਗਲਾ ਚਲਾਓ + ਕਤਾਰ ਵਿੱਚ ਜੋੜ੍ਹੋ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ + ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਓ + ਡਾਊਨਲੋਡ + ਡਾਊਨਲੋਡ ਹੋ ਰਿਹਾ + ਡਾਊਨਲੋਡ ਹਟਾਓ + ਪਲੇਲਿਸਟ ਅਯਾਤ ਕਰੋ + ਪਲੇਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ + ਕਲਾਕਾਰ ਵੇਖੋ + ਐਲਬਮ ਵੇਖੋ + ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ + ਸਾਂਝਾ ਕਰੋ + ਮਿਟਾਓ + ਅਤੀਤ ਵਿੱਚੋਂ ਹਟਾਓ + ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ + ਸਿੰਕਰੋਨਾਈਜ਼ ਕਰੋ + Advanced + + + ਮਿਤੀ + ਨਾਮ + ਕਲਾਕਾਰ + ਸਾਲ + ਗਿਣਤੀ + ਲੰਬਾਈ + ਚਲਾਉਣ ਦਾ ਸਮਾਂ + Custom order + + + ਮੀਡੀਆ ਆਈਡੀ + ਮਾਈਮ ਪ੍ਰਕਾਰ + ਕੋਡੈਕਸ + ਬਿੱਟਰੇਟ + ਸੈਂਪਲ ਰੇਟ + ਤੀਬਰਤਾ + ਆਵਾਜ਼ + ਫਾਈਲ ਆਕਾਰ + ਅਗਿਆਤ + ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ + + ਬੋਲਾਂ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰੋ + ਬੋਲ ਖੋਜੋ + + ਗੀਤ ਸੰਪਾਦਿਤ ਕਰੋ + ਗੀਤ ਦਾ ਸਿਰਲੇਖ + ਗੀਤ ਕਲਾਕਾਰ + ਗੀਤ ਦਾ ਸਿਰਲੇਖ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + ਗੀਤ ਕਲਾਕਾਰ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + ਸਾਂਭੋ + + ਪਲੇਲਿਸਟ ਚੁਣੋ + ਪਲੇਲਿਸਟ ਸੰਪਾਦਿਤ ਕਰੋ + ਪਲੇਲਿਸਟ ਬਣਾਓ + ਪਲੇਲਿਸਟ ਦਾ ਨਾਮ + ਪਲੇਲਿਸਟ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + + ਕਲਾਕਾਰ ਸੰਪਾਦਿਤ ਕਰੋ + ਕਲਾਕਾਰ ਦਾ ਨਾਮ + ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + + + + %d ਗੀਤ + %d ਗੀਤ + + + %d ਕਲਾਕਾਰ + %d ਕਲਾਕਾਰ + + + %d ਐਲਬਮ + %d ਐਲਬਮ + + + %d ਪਲੇਲਿਸਟ + %d ਪਲੇਲਿਸਟਾਂ + + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + + + + ਪਲੇਲਿਸਟ ਅਯਾਤ ਕੀਤੀ ਗਈ + Removed \"%s\" from playlist + Playlist synced + Undo + + + ਬੋਲ ਨਹੀਂ ਮਿਲੇ + ਸਲੀਪ ਟਾਈਮਰ + ਗੀਤ ਸਮਾਪਤ + + 1 ਮਿੰਟ + %d ਮਿੰਟ + + ਕੋਈ ਸਟ੍ਰੀਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ + ਕੋਈ ਨੈੱਟਵਰਕ ਕਨੈਕਸ਼ਨ ਨਹੀਂ + ਸਮਾਂ ਸਮਾਪਤ + ਅਗਿਆਤ ਤਰੁੱਟੀ + + + ਪਸੰਦ ਕਰੋ + ਪਸੰਦ ਹਟਾਓ + Shuffle on + Shuffle off + Repeat mode off + Repeat current song + Repeat queue + + + ਸਾਰੇ ਗੀਤ + ਖੋਜੇ ਗਏ ਗੀਤ + + + ਸੰਗੀਤ ਪਲੇਅਰ + + + ਸੈਟਿੰਗਾਂ + ਦਿੱਖ + ਡਾਇਨੈਮਿਕ ਥੀਮ ਚਾਲੂ ਕਰੋ + ਗੂੜ੍ਹਾ ਥੀਮ + ਚਾਲੂ + ਬੰਦ + ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ + ਸ਼ਾਹ ਕਾਲ੍ਹਾ + ਡੀਫ਼ਾਲਟ ਟੈਬ + ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਆਪਣੀ ਪਸੰਦ ਦਾ ਢਾਲੋ + ਬੋਲਾਂ ਦੀ ਸਥਿਤੀ + ਖੱਬੇ + ਵਿਚਕਾਰ + ਸੱਜੇ + + ਸਮੱਗਰੀ + ਲਾਗ-ਇਨ + ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਭਾਸ਼ਾ + ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਦੇਸ਼ + ਸਿਸਟਮ ਡੀਫ਼ਾਲਟ + ਪ੍ਰੌਕਸੀ ਚਾਲੂ ਕਰੋ + ਪ੍ਰੌਕਸੀ ਕਿਸਮ + ਪ੍ਰੌਕਸੀ URL + ਪ੍ਰਭਾਵੀ ਹੋਣ ਲਈ ਮੁੜ-ਚਾਲੂ ਕਰੋ + + ਪਲੇਅਰ ਅਤੇ ਆਡੀਓ + ਆਡੀਓ ਕੁਆਲਿਟੀ + ਆਟੋ + ਉੱਚ + ਘੱਟ + ਨਿਰੰਤਰ ਕਤਾਰ + ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ + ਆਡੀਓ ਨਾਰਮਲਾਈਜ਼ੇਸ਼ਨ + ਈਕੋਲਾਈਜ਼ਰ + + ਸਟੋਰੇਜ + ਕੈਸ਼ੇ + ਚਿੱਤਰ ਕੈਸ਼ੇ + ਗੀਤ ਕੈਸ਼ੇ + ਵੱਧੋ-ਵੱਧ ਕੈਸ਼ੇ ਆਕਾਰ + ਅਸੀਮਤ + ਸਭ ਡਾਊਨਲੋਡ ਸਾਫ਼ ਕਰੋ + ਅਧਿਕਤਮ ਚਿੱਤਰ ਕੈਸ਼ੇ ਆਕਾਰ + ਚਿੱਤਰ ਕੈਸ਼ੇ ਸਾਫ਼ ਕਰੋ + ਅਧਿਕਤਮ ਗੀਤ ਕੈਸ਼ੇ ਆਕਾਰ + ਗੀਤ ਕੈਸ਼ ਸਾਫ਼ ਕਰੋ + ਵਰਤਿਆ ਗਿਆ + + ਗੋਪਨੀਯਤਾ + ਸੁਣਨ ਦਾ ਅਤੀਤ ਰੋਕੋ + Clear listen history + Are you sure to clear all listen history? + ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ + ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ + ਕੀ ਤੁਸੀਂ ਸਾਰੇ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਸਾਫ਼ ਕਰਨ ਲਈ ਯਕੀਨੀ ਹੋ? + KuGou ਬੋਲ ਪ੍ਰਦਾਤਾ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ + + ਬੈਕਅੱਪ ਅਤੇ ਰੀਸਟੋਰ + ਬੈਕਅੱਪ + ਰੀਸਟੋਰ + Imported playlist + ਬੈਕਅੱਪ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਇਆ ਗਿਆ + ਬੈਕਅੱਪ ਨਹੀਂ ਬਣਾਇਆ ਜਾ ਸਕਿਆ + ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ + + ਦੇ ਬਾਰੇ + ਐਪ ਸੰਸਕਰਣ + + New version available + Translation Models + Clear translation models + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index fdde2f2..20b5411 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,164 +1,318 @@ - AirNote - Notatki - Nowa Notatka - Brak utworzonych notatek. - Brak znalezionych notatek. - Czas Utworzenia - Widok siatki - Widok kolumnowy - Słowa - Znaki - Nazwa - Opis - Zaznacz wszystkie - Usuń - Informacje - Szukaj - Ustawienia - Wyświetlacz - Motyw Systemowy - Ciemny Motyw - Dynamiczne Kolory - Kolory Amoled - Baza Danych - Kopia Zapasowa - Jeszcze nie wspierane - O programie - Wersja - Najnowsze Wydanie - Daj Projektowi Gwiazdkę - Wesprzyj Mnie :) - Przywróć - Notatki - Wsparcie - Kolory i Style - Język - Markdown - Narzędzia + + Główna + Utwory + Wykonawcy + Albumy + Playlisty + + + %d zaznaczony + %d zaznaczonych + + + + Historia - Widżety - Motywy • Rozmiar • Czcionka - Chmura - Czcionka • Język - NextCloud • Lokalna • Szyfrowanie - Wersja • Programiści • Licencja - Widżety • Markdown - Historia • Przywróć - Stworzone • Usuń Wszystko - Wsparcie • Obrazy - Okaż swoją miłość - Czcionka - Promień - Wybierz Promień - Tryb Minimalistyczny - Ekstremalne Kolory Amoled + Statystyki + Nastroje i gatunki + Konto + Szybki wybór + Słuchaj utworów, aby wygenerować szybkie wybory + Nowo wydane albumy + + Dzisiaj + Wczoraj + Ten tydzień + Ostatni tydzień + + Najczęściej słuchane utwory + Najczęściej słuchani wykonawcy + Najczęściej słuchane albumy + + Szukaj + Szukaj w Loudly + Szukaj w bibliotece… + Biblioteka + Polubione + Pobrane + Wszystko + Utwory + Filmy + Albumy + Wykonawcy + Playlisty + Playlisty tworzone przez społeczność + Polecane playlisty + Ulubione + Brak wyników + + Z twojej biblioteki + + Polubione utwory + Pobrane utwory + Playlista jest pusta + + Spróbuj ponownie + Radio + Losowo + Reset + + Szczegóły + Edytuj + Włącz radio + Odtwórz + Odtwórz jako następny + Dodaj do kolejki + Dodaj do biblioteki + Usuń z biblioteki + Pobierz + Pobieranie + Usuń z pobranych + Importuj playlistę + Dodaj do playlisty + Pokaż wykonawcę + Pokaż album + Odśwież + Udostępnij + Usuń + Usuń z historii + Szukaj online + Synchronizuj + Zaawansowany + + Data dodania + Nazwa + Wykonawca + Rok + Liczba utworów + Długość + Długość utworu + Niestandardowa kolejność + + ID media + Typ MIME + Kodeki + Przepływność + Częstotliwość próbkowania + Natężenie + Głośność + Rozmiar pliku + Nieznane + Skopiowano do schowka + Edytuj tekst + Szukaj tekstu + Edytuj utwór + Tytuł utworu + Wykonawcy utworu + Tytuł utworu nie może być pusty. + Wykonawca utworu nie może być pusty. + Zapisz + Wybierz playlistę + Edytuj playlistę + Utwórz playlistę + Nazwa playlisty + Nazwa playlisty nie może być pusta. + Edytuj wykonawcę + Nazwa wykonawcy + Nazwa wykonawcy nie może być pusta. + + + %d utwór + %d utworów + + + + + %d wykonawca + %d wykonawców + + + + + %d album + %d albumów + + + + + %d playlista + %d playlist + + + + + %d tydzień + %d tygodni + + + + + %d miesiąc + %d miesięcy + + + + + %d rok + %d lat + + + + + Playlista zaimportowana + Usunięto \"%s\" z playlisty + Playlista zsynchronizowana + Cofnij + + Nie znaleziono tekstu + Wyłącznik czasowy + Koniec utworu + + 1 minuta + %d minut + + + + Brak dostępnego źródła + Brak połączenia z internetem + Upłynął limit czasu + Nieznany błąd + + Polub + Usuń polubienie + Tasowanie włączone + Tasowanie wyłączone + Tryb powtarzania wyłączony + Powtórz bieżący utwór + Powtórz kolejkę + + Wszystkie utwory + Wyszukane utwory + + Odtwarzacz + + Ustawienia + Wygląd + Włącz motyw dynamiczny + Ciemny motyw + Włącz + Wyłącz + Zgodnie z systemem + Czysta czerń + Domyślnie otwarta zakładka + Modyfikuj zakładki nawigacji + Położenie tekstu utworu + Z lewej + Na środku + Z prawej + Zawartość + Zaloguj się + Domyślny język zawartości + Domyślny kraj zawartości + Domyślny systemowy + Włącz proxy + Typ proxy + URL proxy + Uruchom ponownie, aby zastosować zmiany + Odtwarzacz i audio + Jakość audio + Automatyczna + Wysoka + Niska + Trwała kolejka + Pomiń ciszę + Normalizacja audio + Korektor graficzny + Pamięć + Cache + Cache obrazów + Cache utworów + Maksymalny rozmiar cache + Nieskończony + Wyczyść pobrane + Maksymalny rozmiar cache obrazów + Wyczyść cache obrazów + Maksymalny rozmiar cache utworów + Wyczyść cache utworów + w użyciu Prywatność - Ochrona Ekranu - Wsparcie Obrazów - Niestabilne - Kryptowaluta - Dopasowuje motyw aplikacji na podstawie ustawień systemowych - Wybierz pomiędzy ciemnymi i jasnymi paletami kolorów - Dopasowuje kolory aby pasowały do twojej tapety - Użyj czarnego koloru jako tło - Ekstremalna optymalizacja baterii - Ustaw zaokroglęnie - Wybierz styl widoku - Usuń górne przyciski z ekranu edycji - Zapisz wszystkie notatki do pliku - Przywróć notatki z pliku kopii zapasowej - Włącz markdown w notatkach - Schowaj w ekranie Ostatnie i wyłącz zrzuty ekranu - Typ Kompilacji - Kod Źródłowy - Lista Wspierających - E-mail - Discord - Prośba o Funkcję - Przypięte - Inne - Kopiuj - Szyfruj Bazę Danych - Szyfruj bazę danych podczas tworzenia kopii zapasowej - Wprowadź Hasło - Hasło jest nieprawidłowe lub baza danych nie jest szyfrowana - Niewłaściwa wartość - Sortuj Rosnąco - Sortuj Malejąco - Sortuj notatki po dacie utworzenia - Wybierz Notatkę - Niewspierany Rozmiar Obrazu (15MB max) - Sejf - Włącz sejf szyfrowanych notatek - kontynuuj - Zmień język aplikacji - Język Systemowy - Zachowanie - Preferuj Tryb Edycji - Wyświetlaj istniejące notatki w trybie edycji - Nieprawidłowe Hasło - Wkład - Warunki użytkowania - Akceptuję - Pokazuj tylko tytuł w podglądzie - Dotacja - Tłumaczenie - Usuwa zdjęcia z pamięci aplikacji kiedy są usunięte z galerii - Synchronizacja z Galerią - Pokaż tylko tytuł - 2. Licencja - 10. Polityka prywatna - Nie można zaimportować - Import - 9. Skontaktuj się z nami - 3. Korzystanie z aplikacji - 4. Własność intelektualna - 5. Zrzeczenie się gwarancji - 6. Ograniczenie odpowiedzialności - 7. Zakończenie - 8. Zmiany w Warunkach - Możemy od czasu do czasu aktualizować niniejsze Warunki korzystania z usługi. Dalsze korzystanie z Easy Notes po wprowadzeniu jakichkolwiek zmian oznacza akceptację zmienionych warunków. - Easy Notes nie zbiera żadnych danych osobowych. Aby uzyskać więcej informacji, zapoznaj się z naszą Polityką prywatności dostępną pod adresem - Data wejścia w życie - Importowanie niektórych plików nie powiodło się - Pomyślnie zaimportowano - Importuj inne pliki tekstowe jako notatki - Zgadzasz się używać Easy Notes wyłącznie w celach zgodnych z prawem i zgodnie z niniejszymi Warunkami. **Nie wolno**:\n\n- _Używać aplikacji w celach niezgodnych z prawem_\n- _Naruszyć praw innych osób_\n- _Zakłócić funkcjonalności lub bezpieczeństwa aplikacji_ - Easy Notes jest dostarczany w stanie „jak jest”, bez żadnych gwarancji. Nie gwarantujemy, że aplikacja będzie wolna od błędów, bezpieczna lub nieprzerwana. - Wszystkie prawa własności intelektualnej w Easy Notes są naszą własnością lub własnością naszych licencjodawców. Korzystanie z aplikacji nie daje Ci prawa własności do żadnych praw własności intelektualnej. - W przypadku pytań dotyczących niniejszych Warunków korzystania z usługi lub Easy Notes prosimy o kontakt pod adresem - Easy Notes jest dystrybuowany na podstawie **GNU General Public License (GPL) w wersji 3**. Oznacza to:\n\n- Możesz swobodnie używać, modyfikować i udostępniać aplikację.\n- Jeśli dystrybuujesz aplikację lub jej zmodyfikowane wersje, musisz również dystrybuować kod źródłowy i zachować licencję GPL. - 1. Akceptacja warunków - Możemy wypowiedzieć lub zawiesić Twój dostęp do Easy Notes, jeśli naruszysz niniejsze Warunki. Po wypowiedzeniu Twoje prawo do korzystania z aplikacji wygaśnie natychmiast. - Nie ponosimy odpowiedzialności za jakiekolwiek szkody wynikające z korzystania z Easy Notes, w tym za szkody pośrednie, przypadkowe lub wtórne. - Naciskając Zgadzam się i korzystając z Easy Notes, akceptujesz niniejsze Warunki korzystania z usługi. Jeśli nie wyrażasz zgody, nie korzystaj z aplikacji. - Wyłącz przesuwanie - "Wyłącz przesuwanie w celu podglądu w trybie edycji " - Skaluj szerokość paska wyszukiwania - Dostosuj szerokość paska wyszukiwania do notatek - Użyj czcionki o stałej szerokości podczas edytowania notatki - Czcionka o stałej szerokości - Czy na pewno chcesz usunąć wybrane notatki - Tak - Anuluj - Blokada aplikacji - Zablokuj aplikację za pomocą hasła - Ustaw hasło - Wprowadź hasło - Pin (6 cyfr) - Hasło - Odcisk palca - Wymagane uwierzytelnienie - jest zablokowany - Poproś o hasło przy starcie - Poproś o odcisk palca przy starcie - UID - Wzór - Poproś o wzór przy starcie - Ustaw wzór - Wprowadź wzór - Zablokuj natychmiast - Zablokuj natychmiast po wznowieniu aplikacji - + Wstrzymaj historię odtwarzania + Wyczyść historię odtwarzania + Czy na pewno chcesz wyczyścić całą historię odtwarzania? + Wstrzymaj historię wyszukiwania + Wyczyść historię wyszukiwania + Czy na pewno chcesz wyczyścić całą historię wyszukiwania? + Pobieraj teksty utworów z KuGou + Kopia zapasowa i przywracanie + Utwórz kopię zapasową + Przywróć + Zaimportowane playlisty + Kopia zapasowa utworzona pomyślnie + Nie udało się stworzyć kopii zapasowej + Nie udało się przywrócić kopii zapasowej + O aplikacji + Wersja aplikacji + Dostępna nowa wersja + Modele Tłumaczenia + Wyczyść modele tłumaczenia + Zapewnij ciągłość odtwarzania + Dodaj wszystko do biblioteki + Duży + Motyw + Kolejka + Przywróć ostatnią kolejkę podczas uruchomienia aplikacji + Automatycznie załaduj więcej utworów + Automatycznie dodaje więcej utworów, kiedy kolejka się skończy, jeśli to możliwe + Automatycznie pomiń do następnego utworu, gdy wystąpi błąd + Zatrzymywanie muzyki po wykonaniu zadania + Kiedy opcja jest aktywna, zrzuty ekranu i widok aplikacji na ekranie Ostatnie są wyłączone + Pobieraj teksty utworów z LrcLib + Mały + Polub wszystkie + Domyślny + Usuń z playlisty + Usuń wszystko z biblioteki + Twoje playlisty YouTube + Włącz Rich Presence + Usuń z kolejki + Błąd logowania + Podobne do + Twoje playlisty pojawią się tutaj + Inne wersje + Utwór jest już obecny w Twojej playliście + Usuń wszystkie polubienia + Wyrównanie tekstu odtwarzacza + Z boku + Falisty + Zapomniane ulubione + Słuchaj dalej + Utwory z biblioteki pojawią się tutaj + Artyści z biblioteki pojawią się tutaj + Albumy z biblioteki pojawią się tutaj + Czy na pewno chcesz usunąć wszystkie utwory playlisty \"%s\" z Pobranych Utworów? + Czy na pewno chcesz usunąć playlistę \"%s\"? + Tempo i Wysokość + Duplikaty + Pomiń duplikaty + Dodaj mimo wszystko + %d utwory są już obecne w Twojej playliście + Odtwarzacz + Styl suwaka + Różne + Rozmiar komórki siatki + Nie zalogowano + Słuchaj historii + Szukaj w historii + Wyłącz zrzut ekranu + Ukryj wulgarne treści + Integracja z Discordem + Loudly korzysta z biblioteki KizzyRPC, aby móc ustawić status Twojego konta Discord. Wiąże się to z wykorzystaniem połączenia Discord Gateway, co może być uznane za naruszenie Warunków korzystania z usługi Discord. Nie ma jednak znanych przypadków zawieszania kont użytkowników z tego powodu. Używasz na własne ryzyko. +\n +\nLoudly wyodrębni tylko Twój token, a wszystkie inne dane będą przechowywane lokalnie. + Odrzuć + Opcje + Podgląd + Wyloguj + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index afc55bf..b066649 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,167 +1,433 @@ - Notas - Nenhuma nota encontrada. - AirNote - Nenhuma nota criada. - Criar Nota - Palavras - Nome - Mostrar - Tema Escuro - Cores Dinâmicas - Backup - Ainda não suportado - Sobre - Versão - Última Versão - Restaurar - Notas - Suporte - Linguagem - Formatação - Ferramentas - Widgets - Temas • Tamanho • Fonte - Nuvem - Fonte • Linguagem - NextCloud • Local • Criptografia - Versão • Desenvolvedores • Licença - Base de dados - Histórico - Modo Grade - Modo Amoled - Criar Projeto - Fazer doação :) - - Seguir Tema do Sistema - Widgets • Formatação - Declare seu amor - Histórico • Reverter - Criado • Excluir Tudo - Fonte - Modo Minimalista - Cores Amoled Intensas - Proteção de Tela - Suporte para Imagens - Instável - Selecione o Raio - Adapta o tema do app baseado nas configurações do sistema - Alternar entre esquemas de cores claras e escuras - Otimização extrema de bateria - Personalize a área da borda - Selecione o estilo de visualização - Remova os botões superiores da tela de edição - Use a cor preta como tela de fundo - Ajusta as cores de acordo com o papel de parede - Habilitar Formatação nas notas - Canal da Build - Lista de Apoiadores - Ocultar a tela em apps recentes e desativar capturas de tela - Solicitar Recursos - Outros - Copiar - Criptografar base de dados durante o backup - Digite a senha - Fixar - Entrada inválida - Classificar em ordem crescente - Classifique as notas pela data de criação - Cofre - Mudar a linguagem do app - Comportamento - Priorizar modo de edição - Senha Inválida - Abrir notas no modo de edição - Doar - Tradução - Contribuição - Exclua imagens do armazenamento do aplicativo quando elas forem removidas da galeria - Sincronização da Galeria - 1. Aceitação dos Termos - Ao pressionar Concordo e usar o Easy Notes, você aceita estes Termos de Serviço. Caso não concorde, não utilize o aplicativo. - 4. Propriedade Intelectual - Não somos responsáveis por quaisquer danos resultantes do uso do Easy Notes, incluindo danos indiretos, incidentais ou consequenciais. - 7. Rescisão - 8. Alterações nos Termos - 9. Contate-nos - Para qualquer dúvida sobre estes Termos de Serviço ou Easy Notes, entre em contato conosco em - 10. Política de Privacidade - Data Efetiva - Concordar - Termos de serviço - Não foi possível importar - Importado com sucesso - Importar - Importe outros arquivos de texto como notas - Data de criação - Caracteres - Descrição - Excluir - Modo Lista - Selecionar tudo - Informação - Pesquisar - Configurações - Suporte • Imagens - Raio - Armazena todas as notas em um arquivo - Privacidade - Restaura notas do arquivo de backup - Criptomoeda - Código Fonte - E-mail - Continuar - Discord - Tamanho de Imagem não Suportado (15mb máx) - Criptografar Base de Dados - A senha é inválida ou o banco de dados não está criptografado - Classificar em ordem decrescente - Ativar cofre de notas criptografadas - Selecionar Nota - Linguagem do Sistema - Mostrar apenas título - Mostrar apenas o título na visualização - 2.Licença - Easy Notes é distribuído sob a **Licença Pública Geral GNU (GPL) versão 3**. Isso significa: -\n -\n- Você é livre para usar, modificar e compartilhar o aplicativo. -\n- Se você distribuir o aplicativo ou qualquer versão modificada, deverá também distribuir o código-fonte e manter a licença GPL. - 3. Uso do Aplicativo - Você concorda em usar o Easy Notes apenas para fins legais e de acordo com estes Termos. Você **não** deve: -\n -\n- _Use o aplicativo para qualquer finalidade ilegal_ -\n- _Viole os direitos dos outros_ -\n- _Perturbar a funcionalidade ou segurança do aplicativo_ - Todos os direitos de propriedade intelectual do Easy Notes são de nossa propriedade ou de nossos licenciadores. O uso do aplicativo não lhe concede a propriedade de quaisquer direitos de propriedade intelectual. - 5. Isenção de Garantias - O Easy Notes é fornecido como está, sem quaisquer garantias. Não garantimos que o aplicativo estará livre de erros, seguro ou ininterrupto. - 6. Limitação de Responsabilidade - Poderemos atualizar estes Termos de Serviço de tempos em tempos. O uso continuado do Easy Notes após quaisquer alterações constitui aceitação dos termos revisados. - Poderemos encerrar ou suspender seu acesso ao Easy Notes se você violar estes Termos. Após a rescisão, seu direito de usar o aplicativo terminará imediatamente. - Easy Notes não coleta nenhum dado pessoal. Para mais informações, consulte a nossa Política de Privacidade disponível em - Falha na importação de alguns arquivos - Use fonte monoespaçada ao editar notas - Fonte monoespaçada - Trancar aplicação utilizando palavra passe - Trancar aplicação - Defina palavra passe - Insira código passe - PIN (6 dígitos) - Solicitar palavra passe no arranque - Impressão Digital - Autenticação necessária - bloqueado - Solicitar impressão digital no arranque - Padrão - Solicitar padrão no arranque - Bloquear de imediato quando a aplicação é retomada - Insira padrão - Cancelar - Bloquear de imediato - Tem certeza que quer apagar as notas selecionadas - Sim - Código passe - Configurar padrão - Identificador de usuário + +Bem-vindo ao Loudly +YouTube Music na ponta dos seus dedos +Sincronização de conta do YouTube-Music +Suas músicas livres de anúncios +Aplicativo open source +Voltar +Próximo + +Início +Músicas +Artistas +Álbuns +Playlists +Filtro de conteúdo + +Todos os tempos +Últimas 24 horas +Semana passada +Mês passado +Ano passado + + +%d selecionado +%dM selecionados +%d selecionados + + +Histórico +Estatísticas +Momentos e Gêneros +Conta +Escolhas rápidas +Escute à mais música para gerar suas escolhas rápidas +Novos álbuns lançados + +Hoje +Ontem +Essa semana +Semana passada + +Músicas mais tocadas +Artistas mais tocados +Álbuns mais tocados + +Pesquisar +Pesquisar no Loudly +Pesquisar na biblioteca… +Histórico de pesquisa +Biblioteca +Curtidos +Baixados +Tudo +Músicas +Vídeos +Álbuns +Artistas +Playlists +Playlists da comunidade +Playlists em destaque +Salvos +Nenhum resultado encontrado + +Da sua biblioteca + +Músicas curtidas +Músicas baixadas +Esta playlist está vazia + +Tentar novamente +Rádio +Aleatório +Redefinir + +Detalhes +Editar +Iniciar rádio +Reproduzir +Reproduzir em seguida +Adicionar à fila +Adicionar à biblioteca +Remover da biblioteca +Baixar +Baixando +Remover das músicas baixadas +Importar playlist +Adicionar à playlist +Ver artista +Ver álbum +Atualizar +Compartilhar +Excluir +Remover do histórico +Pesquisar online +Sincronizar +Avançado + +Data de Adição +Nome +Artista +Ano +Número de músicas +Duração +Tempo de reprodução +Ordem personalizada + +ID de mídia +Tipo do MIME +Codificadores +Taxa de bits +Taxa de amostragem +Volume +Volume +Tamanho do arquivo +Desconhecido +Copiado para a área de transferência +Editar letras +Pesquisar letras +Editar música +Título da música +Artistas da música +O título da música não pode ser vazio. +O artista da música não pode ser vazio. +Salvar +Escolher playlist +Editar playlist +Criar playlist +Nome da playlist +O nome da playlist não pode ser vazio. +Editar artista +Nome do artista +O nome do artista não pode ser vazio. + + +%d música +%dM músicas +%d músicas + + +%d artista +%dM artistas +%d artistas + + +%d álbum +%dM álbuns +%d álbuns + + +%d playlist +%dM playlists +%d playlists + + +%d semana +%dM semanas +%d semanas + + +%d mês +%dM meses +%d meses + + +%d ano +%dM anos +%d anos + + +1 segundo +%d segundos + + +A playlist foi importada +\"%s\" removido da playlist +A playlist foi sincronizada +Desfazer + +Letras não encontradas +Timer para dormir +Fim da música + +%d minuto +%dM minutos +%d minutos + +Nenhum canal de reprodução disponível +Sem conexão com à internet +Tempo esgotado +Erro desconhecido + +Curtir +Remover dos curtidos +Modo aleatório ativado +Modo aleatório desativado +Repetição desativada +Repetir a música atual +Repetir fila + +Todas as músicas +Músicas pesquisadas + +Reprodutor de Música + +Notificações +Impedir que o sistema mate o aplicativo +Use uma notificação em primeiro plano para evitar que o Loudly seja fechado aleatoriamente pelo sistema. + +Configurações +Aparência +Ativar tema dinâmico +Tema escuro +Ativado +Desativado +Automático (sistema) +Preto puro +Aba padrão ao abrir o app +Ouça no YouTube Music +Visite Loudly +Artista +Ouça no Loudly +Customizar abas de navegação +Personalização da playlists +Mostrar músicas curtidas +Mostrar lista de download +Mostrar músicas favoritas +Mostrar músicas em cache +Mostrar música local +Posição do texto das letras +Esquerda +Centro +Direita +Conteúdo +Lista padrão da biblioteca +Login +Idioma padrão +País de Origem +Padrão do sistema +Ativar proxy +Tipo de proxy +URL da proxy +Reinicie para fazer efeito +Reprodutor e áudio +Tem certeza de que deseja limpar todo o cache? +Tem certeza de que deseja limpar todos os downloads? +Tem certeza de que deseja limpar todas as músicas armazenadas em cache? +Tem certeza de que deseja limpar todas as imagens armazenadas em cache? +Configurações do player local +Qualidade do áudio +Automática +Alta +Baixa +Duração mínima de reprodução +A quantidade mínima de uma música que deve ser tocada antes de ser considerada \"tocada\" +Fila persistente +Pular silêncio +Normalização de áudio +Equalizador +Armazenamento +Cache +Cache de Imagens +Cache de Músicas +Tamanho máximo do cache +Ilimitado +Limpar todos os downloads +Tamanho máximo do cache de imagens +Limpar cache de imagens +Tamanho máximo do cache de músicas +Limpar o cache de músicas +usados +Privacidade +Pausar o histórico de escuta +Limpar o histórico de escuta +Você tem certeza que deseja limpar todo o histórico de escuta? +Pausar histórico de pesquisa +Limpar histórico de pesquisa +Tem certeza que deseja limpar todo o seu histórico de pesquisa? +Ativar o fornecedor de letras KuGou +Principal +Seletor de letras padrão +Ler letras de Músicas +Tratar todas as linhas entre os pontos de sincronização como um texto lyrics +Remover espaços ao redor das letras +Tamanho da fonte +Backup e restauração +Fazer backup +Restaurar +Playlist importada +Backup criado com sucesso +Não foi possível criar o backup +Falha ao restaurar o backup +Sobre +Versão do app +Nova versão disponível +Modelos de Tradução +Limpar modelos de tradução +Você deseja realmente remover as músicas da playlist \"%s\" do armazenamento de Músicas Baixadas? +Você deseja realmente excluir a playlist \"%s\"? +Remover da playlist +Pular duplicados +Ativar o fornecedor de letras LrcLib +Duplicados +Adicionar mesmo assim +Esta música já está na sua playlist +%d músicas já estão na sua playlist +Integração com Discord +Ignorar +Opções +Design do Aplicativo +Pré-visualização +Login falhou +Toque para mostrar o token +Toque novamente para copiar ou editar +Sair +Ativar Rich Presence +Não logado +O Loudly usa a biblioteca KizzyRPC para definir o status da sua conta do Discord. Isto envolve o uso da conexão Discord Gateway, que pode ser considerado uma violação dos termos de serviço do Discord. Porém, não há nenhum caso conhecido de contas de usuários sendo suspensas por esta razão. Use por sua conta e risco. \n \nO Loudly extrairá somente o seu token, e todo o resto é armazenado localmente. +Estilo do Player +Website +Site oficial ❯ +Canal Oficial +Doação +Estilo de fundo do Player +Source code +Acesse o código fonte do aplicativo ❯ +Bugs +Bugs ou problemas? Relate-os aqui ❯ +Ajuda +Desenvolvedor ❯ +Deslize a música para a direita para reproduzir a próxima ou para a esquerda para adicioná-la à fila +Se você quiser contribuir, clique aqui ❯ +Ao lado +Mostrar rótulos na barra de navegação inferior +Habilite o swipe para mudar de música +Configurar o tamanho da miniatura +Esconder conteúdo explícito +Alinhamento do texto do reprodutor +Estilo do slider +Padrão +Cobrinha +Bem-vindo de volta! +Suas playlists do YouTube +Curtir tudo +Remover todos os likes +Tamanho da célula da grade +Adicionar mais músicas automaticamente quando o fim da fila é atingido, se possível +Histórico de escuta +Desativar capturas de tela +Parecido com +Músicas da biblioteca aparecerão aqui +Artistas da biblioteca aparecerão aqui +Álbuns da biblioteca aparecerão aqui +Outras versões +Continue ouvindo +Adicionar tudo à biblioteca +Remover da fila +Tempo e Tom +Reprodutor +Outros +Pequeno +Grande +Fila +Restaurar sua última fila ao iniciar o app +Tocar mais músicas automaticamente +Adicionar músicas tocadas ao histórico do YouTube Music +Pular automaticamente para a próxima música quando um erro ocorre +Garanta uma experiência de reprodução contínua +Parar música ao remover dos apps recentes +Histórico de pesquisa +Quando esta opção está ativada, as capturas de tela e a visualização do app nos recentes são desativadas. +Favoritos esquecidos +Suas playlists aparecerão aqui +Importar do Spotify +Tema +Sincronize automaticamente com sua conta do YouTube Music +Baixe automaticamente suas músicas preferidas +Use o login para navegar pelo conteúdo +Isso pode influenciar o conteúdo que você vê, por exemplo mostra álbuns somente premium se você estiver conectado com uma conta Premium. +Remover tudo da biblioteca +Configurar links suportados +Abrir links suportados por padrão +Idioma do aplicativo +Configurar idiomas do aplicativo +Não foi possível encontrar as configurações de idioma do aplicativo, configure-as manualmente +Duração da história +Habilitar descarregamento +Use o caminho de áudio offload para reprodução de áudio. Desabilitar isso pode aumentar o uso de energia, mas pode ser útil se você tiver problemas com reprodução de áudio ou pós-processamento + +Local scanner + +Scan automático +Procurar músicas automaticamente ao abrir o aplicativo + +Definições adicionais do scanner +Nomes de arquivo +Quando ativadas, as extensões NÃO serão ignoradas. Ex. "Song.ogg" será uma música diferente de "Song.flac". +Configurar a sensibilidade do scanner +Combinar Título +Combinar Título e Artista +Combinar Título, Artista e Álbuns + +Pastas +Comprimir subpastas +Desabilitar para preservar a estrutura do arquivo como no disco + +Selecionar tudo +Buscando todas as playlists… +Importar itens selecionados +Algo deu errado. Estamos tão surpresos quanto você. +Bem, isso é estranho. Não temos ideia do que aconteceu. +Instruções para obter as credenciais necessárias +3. Certifique-se de clicar em "Adicionar" depois de inserir o caminho local acima. +5. Aceite os termos de serviço e clique em "Salvar". +Você será redirecionado para uma nova página. Clique em Configurações, copie o Client ID e o Client secret e cole-os nos campos de texto abaixo neste aplicativo. +2. Insira os detalhes necessários e use http://localhost:45454 como URIs de redirecionamento. +4. Marque a caixa de seleção onde esta escrito Web API. +Agora, clique no botão Autorizar e Continuar abaixo e faça login com sua conta da qual deseja importar, ao Autorizar e Continuar, você será redirecionado para um site que deve começar com http:// localhost:45454/?code= Cole a URL inteira no campo de texto abaixo que diz Código de Autorização e clique no botão Autenticar +1. Visite o site do Spotify para desenvolvedores, depois acesse o Painel do Spotify para desenvolvedores e clique em "Criar aplicativo". +Faça login com credenciais da API do Spotify +Autorizar e continuar +Código de autorização +Autenticar +Client secret +Client ID +Importação em andamento… +Não feche o aplicativo nem volte. Esta operação não é executada em segundo plano, então fique parado até que ela seja totalmente concluída! NÃO ENTRE EM PÂNICO SE PARECER PARADO; às vezes a recuperação pode levar algum tempo. +Escolha o destino \"Músicas curtidas\" +Para onde as músicas curtidas devem ser importadas? +Uma nova lista de reprodução chamada "Músicas curtidas" +Nas "Músicas curtidas" padrão diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml deleted file mode 100644 index 6ecc26b..0000000 --- a/app/src/main/res/values-pt/strings.xml +++ /dev/null @@ -1,160 +0,0 @@ - - - Donativos - Widgets - Nova Nota - Notas - Descrição - Código Fonte - Mostrar apenas título na ante-visão - Suporte - AirNote - Selecionar todas - Cores Dinâmicas - Acerca - Cores Amoled Extreme - Notas não encontradas. - Data de Criação - Vista de Grelha - Vista de Coluna - Palavras - Caracteres - Nome - Apagar - Informação - Procurar - Definições - Visualização - Tema do sistema - Tema Escuro - Cores Amoled - Base de Dados - Cópia de Segurança - Não suportado - Versão - Avaliar - - Idioma - Markdown - Ferramentas - Histórico - Temas • Tamanho • Fonte - Fonte • Idioma - NextCloud • Encriptação • Local - Versão • Programadores • Licença - Widgets • Markdown - Histórico • Reverter - Criadas • Apagar todas - Suporte • Imagens - Mostra o teu apreço - Fonte - Rácio - Selecionar Rácio - Privacidade - Proteção de ecrã - Criptomoedas - Adaptar tema da aplicação às definições do sistema - Otimização da bateria extrema - Personalizar rácio de canto - Selecionar estilo de visualização - Gravar todas as notas num ficheiro - Restaurar notas a partir de um ficheiro - Permitir quadro de verificação nas notas - Lista de Apoiantes - Email - Discord - Solicitar Funcionalidades - Copiar - Encriptar Base de Dados - Encriptar Base de Dados durante cópia de segurança - Insira palavra passe - Entrada inválida - Ordenar Ascendente - Ordenar Descendente - Ordenar por data de criação - Selecionar Nota - Imagem não suportada (15mb tam. max.) - Cofre - Habilitar Cofre - continuar - Alterar idioma da aplicação - Idioma do Sistema - Comportamento - Priorizar Modo de Edição - Entrar no modo de edição nas notas existentes - Palavra Passe inválida - Doar - Tradução - Apaga imagens do armazenamento da aplicação quando removidas da galeria - 1. Aceitação dos Termos - 2. Licença - 3. Uso da aplicação - 4. Propriedade Intelectual - 5. Garantias - 6. Exclusões de Responsabilidade - 7. Terminação - Não nos responsabilizamos por quaisquer danos resultantes da utilização do Easy Notes, incluindo danos diretos, acidentais ou consequentes. - 8. Alterações aos Termos - 9. Contactos - 10. Política de Privacidade - Concordar - Termos de serviço - Impossível importar - Importado com sucesso - Importar - Importar ficheiros de texto como notas - Tem a certeza que pretende apagar as notas selecionadas - Sim - Cancelar - Bloquear com palavra passe - Insira código - PIN (6 números) - Código - Impressão Digital - Necessária Autenticação - Identificação Única de Utilizador (UID) - Solicitar código no início - Definir padrão - Trancar imediatamente - Trancar imediatamente quando a aplicação é retomada - Último Lançamento - Para qualquer questão acerca do Termos de Serviço contacte-nos: - Restaurar - Notas - Instável - Remover botões superiores do ecrã de edição - Sem notas. - Cloud - Suporte de Imagens - Palavra passe inválida ou base de dados não se encontra encriptada - Tipo de Build - Importação de alguns ficheiros falhou - Modo Minimalista - Usar cor preta como fundo - Alternar entre esquema escuro e claro - Ajustar cores para combinarem com imagem de fundo - Outro(a)s - Usar fonte “monospace” no modo de edição - Solicitar impressão digital no início - Esconder das aplicações recentes e desabilitar capturas de ecrã - Contribuição - Fixado(a)s - Definir código - Sincronização de Galeria - Mostrar apenas título - Ao carregar em Aceito e utilizar o Easy Notes, está a aceitar os Termos de Serviço. Se não concorda, não utilize a aplicação. - Todos os direitos de propriedade intelectual do Easy Notes são sua propriedade ou dos seus associados. Utilização da aplicação não confere posse de quaisquer direitos de propriedade intelectual. - Data Efetiva - bloqueada - Padrão - Fonte “monospace” - Bloquear Aplicação - Easy Notes é distribuído sobre o **GNU General Public License (GPL) versão 3**. Isto significa:\n\n- Pode utilizar, modificar e partilhar a aplicação.\n- Se distribuir a aplicação ou uma versão modificada, deve também incluir o código fonte e reter a licença GPL. - Solicitar padrão no início - Insira padrão - Podemos terminar ou suspender o seu acesso ao Easy Notes se romper as condições destes Termos. Aquando da terminação, o seu direito de utilização da aplicação cessa no imediato. - Concorda em utilizar o Easy Notes para propósitos de acordo com a lei ao aceitar os Termos. **Não** pode:\n\n- _Utilizar a aplicação para fins ilegais_\n- _Violar os direitos de terceiros_\n- _Comprometer a funcionalidade e segurança da aplicação_ - O Easy Notes é fornecido \"como está\", sem qualquer garantia. Não podemos garantir que a aplicação esteja livre de erros, seja segura e não esteja sujeita a interrupções na utilização. - Estes termos poderão ser atualizados periodicamente. A utilização do Easy Notes após estas atualizações constituem aceitação dos novos Termos. - O Easy Notes não recolhe quaisquer dados pessoais. Para mais informação, consulte a nossa Política de Privacidade: - diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml new file mode 100644 index 0000000..3d06b02 --- /dev/null +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -0,0 +1,659 @@ + + + + Добро пожаловать в Loudly + Давайте начнём персонализировать ваш музыкальный опыт. + YouTube Music у вас под рукой + Синхронизация аккаунта YT Music + Воспроизведение без рекламы + Офлайн-прослушивание музыки + Поддержка Android Auto + Поддержка статуса Discord + Полностью открытый исходный код + Выберите интерфейс плеера + Выберите вид плеера в приложении. Вы сможете изменить эту настройку позже в "Настройки > Внешний вид". + Настройка завершена! + Вы готовы исследовать Loudly. + Готово + Назад + Далее + Назад + + Главная + Обзор + Песни + Исполнители + Альбомы + Плейлисты + + Понравившиеся + Нравится + Офлайн + Мой топ + Кэшированные + Локальные + + Фильтр контента + + За всё время + За 24 часа + За неделю + За месяц + За год + + + Выбран %d элемент + Выбрано %d элемента + Выбрано %d элементов + Выбрано %d элементов + + + Доброе утро + Добрый день + Добрый вечер + Доброй ночи + Недавняя активность + История + Ваша история будет отображаться здесь + Ваша статистика будет отображаться здесь + Статистика + Свернуть + Развернуть + Настроения и жанры + Аккаунт + Быстрый выбор + Слушайте песни, чтобы сгенерировать быстрый выбор + Забытые фавориты + Продолжить слушать + Ваши плейлисты YouTube + Похоже на + Новые альбомы + Нет подключения к интернету + Похоже, у вас нет подключения к интернету. Пожалуйста, проверьте вашу сеть. + Рекомендовано для вас + Рекомендовано для вас сегодня + + Сегодня + Вчера + На этой неделе + На прошлой неделе + Локальная + Удалённая + Локальная история + Удалённая история + + Самые прослушиваемые песни + Самые прослушиваемые исполнители + Самые прослушиваемые альбомы + + Поиск + Искать в Loudly + Искать в библиотеке… + История поиска + Предложения + Результаты поиска + Библиотека + Понравившиеся + Скачанные + Все + Песни + Видео + Альбомы + Исполнители + Плейлисты + Плейлисты сообщества + Рекомендуемые плейлисты + В закладках + Ничего не найдено + + Ваша библиотека будет отображаться здесь + Песни из вашей библиотеки будут отображаться здесь + Исполнители из вашей библиотеки будут отображаться здесь + Альбомы из вашей библиотеки будут отображаться здесь + Ваши плейлисты будут отображаться здесь + + Из вашей библиотеки + + Другие версии + Избранное + Лучшие песни + + Понравившиеся песни + Скачанные песни + Плейлист пуст + Вы действительно хотите удалить все песни плейлиста \"%s\" из скачанных? + Вы действительно хотите удалить плейлист \"%s\"? + + Отключить + + Повторить + Радио + Перемешать + Сбросить + + Подробности + Изменить + Запустить радио + Воспроизвести + Воспроизвести следующей + Добавить в очередь + Добавить в библиотеку + Добавить всё в библиотеку + Удалить из библиотеки + Удалить всё из библиотеки + Скачать + Скачивание + Удалить загрузку + Импортировать плейлист + Добавить в плейлист + Перейти к исполнителю + Перейти к альбому + Обновить + Поделиться + Удалить + Удалить из истории + Удалить из плейлиста + Удалить из очереди + Удалить из кэша + Экспортировать как M3U + Искать в интернете + Синхронизировать + Расширенные + Темп и высота тона + + По дате добавления + По дате обновления + По названию + По исполнителю + По году + По количеству песен + По длительности + По времени воспроизведения + Пользовательский порядок + + ID медиа + Тип MIME + Кодеки + Битрейт + Частота дискретизации + Громкость + Громкость + Размер файла + Неизвестно + Скопировано в буфер обмена + + Изменить текст песни + Искать текст песни + Вы действительно хотите удалить сохранённый текст песни для \"%s\"? + + Изменить песню + Название песни + Исполнители песни + Название песни не может быть пустым. + Исполнитель песни не может быть пустым. + Сохранить + + Синхронизировать плейлист + Выбрать плейлист + Изменить плейлист + Создать плейлист + Название плейлиста + Название плейлиста не может быть пустым. + + Изменить исполнителя + Имя исполнителя + Имя исполнителя не может быть пустым. + + Дубликаты + Пропустить дубликаты + Всё равно добавить + Эта песня уже есть в вашем плейлисте + %d песен уже есть в вашем плейлисте + Примечание: Это позволяет синхронизироваться с YouTube Music + + + %d песня + %d песни + %d песен + %d песни + + + %d исполнитель + %d исполнителя + %d исполнителей + %d исполнителей + + + %d альбом + %d альбома + %d альбомов + %d альбомов + + + %d плейлист + %d плейлиста + %d плейлистов + %d плейлистов + + + %d неделя + %d недели + %d недель + %d недель + + + %d месяц + %d месяца + %d месяцев + %d месяцев + + + %d год + %d года + %d лет + %d лет + + + Плейлист импортирован + Песня \"%s\" удалена из плейлиста + Плейлист синхронизирован + Отменить + + Сейчас играет + Текст не найден + Таймер сна + Конец песни + + 1 минута + %d минуты + %d минут + %d минут + + Поток недоступен + Нет подключения к сети + Время ожидания истекло + Неизвестная ошибка + Меню + Поделиться списком + + 5 секунд назад + 5 секунд вперёд + + Нравится + Понравились все + Убрать "Нравится" + Убрать "Нравится" у всех + Перемешивание вкл. + Перемешивание выкл. + Повтор выкл. + Повторять текущую песню + Повторять очередь + + Все песни + Найденные песни + + Музыкальный плеер + + Уведомления + Предотвратить закрытие приложения системой + Использовать постоянное уведомление, чтобы предотвратить случайное закрытие Loudly системой. + Требуется разрешение на уведомления + + Настройки + Внешний вид + Тема + Прокси + Включить динамическую тему + Тёмная тема + Включена + Выключена + Как в системе + Чисто чёрный + Дизайн приложения + Интерфейс приложения 1.0 + Интерфейс приложения 1.5 + Слушать на YouTube Music + Посетить Loudly + Исполнитель + Слушать в Loudly + Включить свайп по обложке для смены песни + Настроить вкладки навигации + Плеер + Выравнивание текста в плеере + Расположение текста песни + По бокам + Слева + По центру + Стиль плеера + Старый интерфейс плеера + Интерфейс плеера 1.0 + Интерфейс плеера 2.0 + Следовать теме + Градиент + Размытие + Размытие (в движении) + Стиль фона плеера + Справа + Стиль слайдера + По умолчанию + Волнистый + Compose + Настроить радиус углов обложки + Показывать фильтр контента + Разное + Основное + Настройка авто-плейлистов + Показывать авто-плейлист "Понравившиеся" + Показывать авто-плейлист "Скачанные" + Показывать авто-плейлист "Топ" + Показывать авто-плейлист "Кэшированные" + Показывать авто-плейлист "Локальные" + Смахните песню вправо для воспроизведения следующей или влево для добавления в очередь + Вкладка по умолчанию + Чипы библиотеки по умолчанию + Размер ячейки сетки + Показывать метки на нижней панели навигации + Маленький + Большой + Журнал изменений + Недавних изменений не найдено + Последние изменения: + Закрыть + Доступна новая версия + Снова здравствуйте + Поддержать + Официальный канал + Веб-сайт + Официальный веб-сайт ❯ + Исходный код + Доступ к исходному коду приложения + Проблемы + Сообщения об ошибках и прочее + Помощь + Если вы хотите внести свой вклад, нажмите здесь ❯ + Разработчик | @RRechz + + Контент + Войти + Войдите, чтобы получить доступ к плейлистам YT Music и многому другому + Нажмите, чтобы показать токен + Нажмите ещё раз, чтобы скопировать или изменить + Это РАСШИРЕННЫЙ метод входа. В качестве альтернативы веб-порталу вы можете напрямую ввести или обновить свой токен здесь. Например, это может ускорить вход на нескольких устройствах. Обратите внимание, что любые неверные форматы токенов, которые приложение не сможет разобрать, не будут приняты + Импорт из Spotify + Spotify + Discord + Автоматически скачивать понравившиеся песни + Автоматически синхронизировать с вашим аккаунтом YouTube Music + Синхронизируйте свои плейлисты и многое другое с вашим аккаунтом + Только по Wi-Fi + Вы не вошли в систему + Язык контента по умолчанию + Страна контента по умолчанию + Как в системе + Включить прокси + Тип прокси + URL прокси + Перезапустите, чтобы применить + Язык приложения + Настроить язык приложения + Не удалось найти настройки языка приложения, пожалуйста, настройте их вручную + Открывать поддерживаемые ссылки по умолчанию + Настроить поддерживаемые ссылки + Не удалось найти настройки поддерживаемых ссылок, пожалуйста, настройте их вручную + + Плеер и Аудио + Качество аудио + Максимальное + Авто + Высокое + Низкое + Минимальная длительность воспроизведения + Минимальное время воспроизведения песни, после которого она считается "прослушанной" + Очередь + Постоянная очередь + Восстанавливать последнюю очередь при запуске приложения + Автоматически загружать больше песен + Автоматически добавлять больше песен при достижении конца очереди, если это возможно + Пропускать тишину + Нормализация звука + Продолжать воспроизведение при подключении Bluetooth-устройства + Останавливать воспроизведение при минимальной громкости + Добавлять прослушанные песни в историю YouTube Music + Автоматически переключаться на следующую песню при ошибке + Обеспечьте непрерывное воспроизведение + Останавливать музыку при очистке задач + Эквалайзер + Текст песни + Текст песни + Настройки локального плеера + Хранилище + Кэш + Кэш изображений + Кэш песен + Максимальный размер кэша + Неограниченно + Включить разгрузку аудио + Использовать разгрузку аудио для воспроизведения. Отключение может увеличить энергопотребление, но полезно при проблемах с воспроизведением или постобработкой. + Очистить все загрузки + Макс. размер кэша изображений + Очистить кэш изображений + Макс. размер кэша песен + Очистить кэш песен + использовано + Вы уверены, что хотите очистить весь кэш? + Вы уверены, что хотите очистить все загрузки? + Вы уверены, что хотите очистить кэш всех песен? + Вы уверены, что хотите очистить кэш всех изображений? + Вы уверены, что хотите очистить кэш всех моделей перевода? + + Конфиденциальность + История прослушиваний + Приостановить историю прослушиваний + Очистить историю прослушиваний + Вы уверены, что хотите очистить всю историю прослушиваний? + Приостановить историю поиска + Очистить историю поиска + Вы уверены, что хотите очистить всю историю поиска? + История поиска + Использовать вход для просмотра контента + Это может влиять на то, какой контент вы видите, например, показывать эксклюзивные альбомы для Premium-пользователей. + Отключить скриншоты + Когда эта опция включена, скриншоты и вид приложения в недавних отключены. + Включить поставщика текстов LrcLib + Включить поставщика текстов KuGou + Поставщик текстов по умолчанию + Читать многострочные тексты + Рассматривать все строки между точками синхронизации как один текст + Удалять пробелы вокруг текста + Размер шрифта + Скрывать откровенный контент + Включить уведомления + + Резервное копирование и восстановление + Резервное копирование + Восстановление + Импортировать плейлист (m3u) + Не удалось импортировать плейлист + Импортированный плейлист + Резервная копия успешно создана + Не удалось создать резервную копию + Не удалось восстановить резервную копию + + Интеграция с Discord + Loudly использует библиотеку KizzyRPC для установки статуса вашего аккаунта Discord. Это включает использование соединения Discord Gateway, что может считаться нарушением Условий обслуживания Discord. Однако известных случаев блокировки аккаунтов по этой причине нет. Используйте на свой страх и риск.\n\nLoudly извлечёт только ваш токен, всё остальное хранится локально. + Закрыть + Опции + Предпросмотр + Ошибка входа + Выйти + Включить Rich Presence + + О приложении + Версия приложения + Проверка обновлений... + версия %1$s доступна для скачивания. + Скачивание... + Обновить + Попробовать снова + Скачать сейчас + Тип приложения + + Доступна новая версия + Модели перевода + Очистить модели перевода + Длительность истории + + 1 секунда + %d секунды + %d секунд + %d секунд + + Музыка 3x2 + Музыка 2x1 + Воспроизведение/Пауза + Версия · Разработчики · Лицензия + Интерфейс + Контент и Аккаунт + Плеер + Система + Библиотека + Интеграции + Системная интеграция + Всего использовано + Используется сейчас + Очистить кэш + Резервная копия · Восстановление · Импорт + История · Поиск · Скриншот + Загрузки · Кэш · Изображения + Звук · Очередь · Тексты песен + Язык · Местоположение · Прокси + Вход · Spotify · Discord + Тема · Слайдер · Плеер + + Изображение карточки профиля + Настройте изображение на карточке профиля + Выбрать изображение профиля + Готовые изображения + Выбрать из галереи + Редактировать фото + Отмена + + Отправить разработчику + Закрыть + Показать детали ошибки + Ошибка загрузки + Показать ошибку + Обновить вручную + Подробности на странице настроек + Программное обеспечение обновлено + + Интерфейс приложения + Выберите один из 3 различных дизайнов + Выбор версии интерфейса + Дизайн старой версии приложения. (Приложение, Плеер, Мини-плеер) + Компактный дизайн, созданный с более современным подходом. (Приложение, Плеер, Мини-плеер) + Самый популярный дизайн. (Приложение, Плеер, Мини-плеер) + Создайте свой собственный дизайн интерфейса. (Приложение, Плеер, Мини-плеер) + Настроить интерфейс + + Другие настройки + Нажмите для других различных настроек + + Автоматически регулирует качество между 45 и 512 Кбит/с + Предлагает высочайшее качество до 512 Кбит/с + Устанавливает качество для средних скоростей интернета от 128 до 256 Кбит/с. + Ограничивает качество от 45 до 52 Кбит/с для экономии данных (Экономия трафика) + + Не удалось импортировать + + Выравнивание текста + Установите положение текста на карточке + Интенсивность градиента + Скругление углов + Изображение профиля + Карточка профиля + Удалить изображение профиля + Показывать тексты + Отображать приветствие и имя пользователя на карточке профиля + Нажмите, чтобы изменить + + Перевести (БЕТА) + Копировать + + Ошибка сети + Проверьте подключение к интернету и попробуйте снова. + Неподдерживаемый контент + Формат этой песни не поддерживается на вашем устройстве. + Ошибка прямой трансляции + Возникла проблема с прямой трансляцией. Пожалуйста, перезапустите. + Ошибка воспроизведения + Произошла неизвестная ошибка. Пожалуйста, попробуйте снова. + Повторная попытка… + Использование VPN может повлиять на качество соединения. Если у вас проблемы с сетью, отключите VPN или используйте прокси. Настроить это можно в "Настройки >> Контент >> Прокси". + + Локальные песни успешно синхронизированы + Локальный сканер + + Сканировать автоматически + Автоматически сканировать песни при открытии приложения + + Дополнительные настройки сканера + Строгие имена файлов + При включении расширения НЕ будут игнорироваться. Например, "Song.ogg" будет отличаться от "Song.flac" + Настроить чувствительность сканера + Совпадение по названию + Совпадение по названию и исполнителям + Совпадение по названию, исполнителям, альбомам + + Папки + Объединить подпапки + Отключите, чтобы сохранить структуру файлов как на диске + + Советы и возможности + Советы · Возможности + Создайте свою историю + Поделитесь песней, которую вы слушаете, в социальных сетях со стильным дизайном, созданным с доминирующими цветами обложки альбома. + Настраиваемые параметры + Сделайте приложение своим, заменив фон карточки заголовка на экране настроек изображением из вашей галереи. + Новый список песен + Просматривайте текущий плейлист и легко переключайтесь между песнями, смахнув вверх с экрана плеера (новый дизайн, эксклюзивный для интерфейса плеера 2.0) + Обновлённый дизайн настроек + Легко находите то, что ищете, с новым дизайном настроек на основе карточек, который стал более современным и удобным. + Классический музыкальный виджет + Быстрый доступ ко всем элементам управления и информации о песне с вашего главного экрана. + Минималистичный музыкальный виджет + Музыкальный виджет "Quik", вдохновлённый дизайном кнопок быстрых настроек Android 16 + Поделиться в истории + Копировать ссылку на песню + Ссылка скопирована в буфер обмена! + Прослушано в Loudly + + Выбрать всё + Получение всех плейлистов… + Импортировать выбранные элементы + Что-то пошло не так. Мы так же сбиты с толку, как и вы. + Ну, это неловко. Мы тоже понятия не имеем, что произошло. + Инструкции по получению необходимых учётных данных + 3. Убедитесь, что вы нажали "Добавить" после ввода локального пути выше. + 5. Примите условия обслуживания и нажмите "Сохранить". + Вы будете перенаправлены на новую страницу. Нажмите на Настройки, затем скопируйте Client ID и Client Secret и вставьте их в текстовые поля ниже в этом приложении. + 2. Введите необходимые данные и используйте http://127.0.0.1:45454 в качестве URI перенаправления. + 4. Переключите Web API. В чекбоксе должна появиться галочка. + Теперь нажмите кнопку "Авторизовать и продолжить" ниже и войдите в свой аккаунт, из которого вы хотите импортировать, и авторизуйтесь. Вас перенаправит на сайт, который должен начинаться с http://127.0.0.1:45454/?code= + +Вставьте этот URL-адрес целиком в текстовое поле ниже с надписью "Код авторизации" и нажмите кнопку "Аутентификация" + 1. Посетите Spotify для разработчиков и нажмите "Создать приложение". + Войти с учётными данными Spotify API + Авторизовать и продолжить + Код авторизации + Аутентификация + Секрет клиента + ID клиента + Идёт импорт… + Не закрывайте приложение и не возвращайтесь назад. Эта операция не выполняется в фоновом режиме, поэтому оставайтесь на месте, пока она не будет завершена! +НЕ ПАНИКУЙТЕ, ЕСЛИ КАЖЕТСЯ, ЧТО ПРОЦЕСС ЗАВИС; иногда получение данных может занять некоторое время. + Выберите место назначения для "Понравившихся песен" + Куда импортировать понравившиеся песни? + В новый плейлист с названием "Понравившиеся песни" + В плейлист по умолчанию "Понравившиеся песни" + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml deleted file mode 100644 index 21ab7b8..0000000 --- a/app/src/main/res/values-ru/strings.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - Нет заметок. - Заметки не найдены. - Создано - Название заметки - Количество слов - Количество символов - Описание заметки - Поиск заметок - Настройки - Персонализация - Тёмная тема - Динамические цвета - Натуральный чёрный - База данных - Экспортировать - Пока не поддерживается - Оценить приложение - Пожертвование - AirNote - Заметки - Новая заметка - О приложении - Версия приложения - Последняя версия - Восстановить - Количество заметок - Один столбец - Удалить - Системная тема - - Язык - Виджеты - Темы • Размер • Шрифт - Облако - Версия • Разработчики • Лицензия - Виджеты • Markdown - История • Отмена - Создано • Удалить всё - Покажите свою любовь - Выбрать всё - Несколько столбцов - Информация - Поддержать проект - Markdown - Инструменты - История - Шрифт • Язык - NextCloud • Локальный • Шифрование - Поддержка • Изображения - Шрифт - Радиус - Выберите радиус - Минималистичный режим - Супер натуральный чёрный - Приватность - Защита от скриншотов/записи экрана - Нестабильно - Адаптирует тему оформления приложения под системные настройки - Использовать чёрный цвет для фона - Установите радиус скругления углов - Выберите стиль просмотра заметок - Адаптирует цвет приложения исходя из ваших обоев - Экстремальная оптимизация батареи - Включает Markdown для заметок - Экспортировать все заметки в файл - Тип сборки - Исходный код - Список поддержавших проект - Криптовалюта - Поддержка изображений - Запрещает скриншоты и запись экрана в приложении - Почта - Disсоrd - Восстановить заметки из файла резервного копирования - Запрос новой функции - Переключение между тёмной и светлой темой оформления - Убирает верхние кнопки из экрана редактирования заметок - Скопировать - Введите пароль - Пароль неверный или база данных заметок не зашифрована - Остальные заметки - Закреплено - Шифровать базу данных заметок - Шифровать базу данных заметок во время резервного копирования - Некорректное значение - Сортировать по возрастанию - Сортировать по убыванию - Сортировать заметки по времени их создания - Слишком высокий размер изображения (максимальный размер – 15MB) - Выберите заметку - продолжить - Сменить язык приложения - Системный язык - Включить защищенное хранилище заметок - Безопасное хранилище - Действия - Приоритет режиму редактирования - Просматривать заметку в режиме редактирования - 1. Принятие условий - 3. Использование приложения - 4. Интеллектуальная собственность - 6. Ограничение ответственности - 9. Связаться с нами - Если у вас есть вопросы по этим Условиям обслуживания или Easy Notes, пожалуйста, свяжитесь с нами по адресу - Дата вступления в силу - Согласен - 10. Политика конфиденциальности - Переводчики - Показывать только заголовок в режиме просмотра - Нажимая \"Согласен\" и используя Easy Notes, вы принимаете эти Условия обслуживания. Если вы не согласны, не используйте приложение. - 2. Лицензия - Easy Notes распространяется под лицензией **GNU General Public License (GPL)** версии 3. Это означает -\n -\n- Вы можете свободно использовать, модифицировать и делиться приложением -\n- Если вы распространяете приложение или любые его модифицированные версии, вы также обязаны распространять исходный код и сохранять лицензию GPL. - Вы соглашаетесь использовать Easy Notes только для законных целей и в соответствии с этими Условиями. Вы **не должны**: -\n -\n- _Использовать приложение для незаконных целей_ -\n- _Нарушать права других_ -\n- _Нарушать функциональность или безопасность приложения_ - Все права интеллектуальной собственности на Easy Notes принадлежат нам или нашим лицензодателям. Ваше использование приложения не дает вам никаких прав собственности на интеллектуальную собственность. - 5. Отказ от гарантий - Easy Notes предоставляется \"как есть\", без каких-либо гарантий. Мы не гарантируем, что приложение будет без ошибок, безопасным. - Мы не несём ответственности за любые убытки, возникшие в результате использования Easy Notes, включая косвенные, случайные или последующие убытки. - 7. Прекращение - Мы можем прекратить или приостановить ваш доступ к Easy Notes, если вы нарушите эти Условия. При прекращении действие вашего права на использование приложения прекратится немедленно. - 8. Изменения в Условиях - Мы можем время от времени обновлять эти Условия обслуживания. Ваше продолжение использования Easy Notes после любых изменений означает ваше согласие с пересмотренными условиями. - Easy Notes не собирает никаких личных данных. Для получения дополнительной информации, пожалуйста, ознакомьтесь с нашей политикой конфиденциальности, доступной по адресу - Условия предоставления услуг - Неверный пароль - Благодетели - Контрибьюторы - Удалять изображения из хранилища приложения, когда они удаляются из галереи - Синхронизация с галереей - Показывать только заголовок - Успешно импортировано - Импорт - Импортировать текстовые файлы как заметки - Импортирование некоторых файлов не удалось - Не удалось импортировать - Моноширинный шрифт - Использовать моноширинный шрифт при редактировании заметок - Вы уверены, что хотите удалить выбранные заметки - Да - Отмена - Блокировка приложения - Блокировка приложения с помощью пароля - Настроить пароль доступа - Введите пароль доступа - Графический ключ - UID - Пин-код (6 цифр) - Пароль - Отпечаток пальца - Требуется аутентификация - Заблокировано - Запрашивает графический ключ при запуске - Запрашивает пароль при запуске - Запрашивает отпечаток пальца при запуске - Установка графического ключа - Введите графический ключ - Незамедлительная блокировка - Блокировать немедленно при открытии приложения - Подгонять ширину поисковой строки - Отключить смахивание - "Отключить смахивание (свайп) для предпросмотра в режиме редактирования " - Масштабировать поисковую строку под ширину заметок - diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml deleted file mode 100644 index a054d62..0000000 --- a/app/src/main/res/values-sk/strings.xml +++ /dev/null @@ -1,167 +0,0 @@ - - - AirNote - Poznámky - Nová poznámka - Žiadne vytvorené poznámky. - Nenašli sa žiadne poznámky. - Vytvorený čas - Slová - Znaky - Meno - Popis - Vyhľadávanie - Nastavenia - Displej - Tmavá téma - Dynamické farby - Amoled Farby - Databáza - Zálohovanie - Zatiaľ nepodporované - O aplikácii - Verzia - Najnovšie vydanie - Hviezda projektu - Podpor Ma :) - Obnoviť - Poznámky - Markdown - Nástroje - História - Miniaplikácie - Témy • Veľkosť • Písmo - Cloud - Písmo • Jazyk - Písmo - Rádius - Vyberte rádius - Minimalistický režim - Extrémne farby Amoled - Mriežkové Zobrazenie - Stĺpcové Zobrazenie - Vybrať všetko - Odstrániť - Informácie - Systémová téma - Podpora - - Jazyk - NextCloud • Lokálne • Šifrovanie - Verzia • Vývojári • Licencia - Widgety • Markdown - História • Vrátiť - Vytvorené • Odstrániť všetky - Podpora • Obrázky - Ukážte svoju lásku - Súkromie - Ochrana obrazovky - Podpora obrázkov - Nestabilné - Prispôsobuje tému aplikácie na základe systému - Prepínanie medzi tmavými a svetlými farebnými schémami - Použitie čiernej farby na pozadí - Extrémna optimalizácia batérie - Prispôsobenie zaokrúhľovania - Uloženie všetkých poznámok do súboru - Obnoviť poznámky z záložného súboru - Podpora markdown v poznámkach - Skrytie obrazovky a zakázanie snímok obrazovky - Zdrojový kód - Zoznam podporovateľov - Discord - Email - Žiadosť o funkciu - Pripnuté - Iné - Kopírovať - Šifrovanie databázy - Šifrovanie databázy počas zálohovania - Zadajte heslo - Heslo je neplatné alebo databáza nie je zašifrovaná - Neplatný vstup - Kryptomena - Upravuje farby tak, aby zodpovedali tapete - Výber štýlu zobrazenia - Odstránenie horných tlačidiel z obrazovky úprav - Typ zostavy - Zoradiť vzostupne - Zoradiť zostupne - Zoradiť poznámky podľa času ich vytvorenia - Vyberte poznámku - Nepodporovaná veľkosť obrázka (max 15 MB) - Trezor - Povolenie šifrovaného trezoru poznámok - pokračovať - Zmeňte jazyk aplikácie - Systémový jazyk - Správanie - Prioritný režim úprav - Zadajte režim úprav na existujúcich poznámkach - Neplatné heslo - Darujte - Príspevok - Synchronizácia galérie - Preklad - Odstránenie obrázkov z úložiska aplikácie po ich odstránení z galérie - Zobraziť iba názov - Zobrazenie iba názvu v náhľade - Stlačením tlačidla Súhlasím a používaním aplikácie Easy Notes súhlasíte s týmito podmienkami služby. Ak s nimi nesúhlasíte, aplikáciu nepoužívajte. - 1. Prijatie podmienok - 2. Licencia - 5. Zrieknutie sa záruk - 9. Kontaktujte nás - 10. Zásady ochrany osobných údajov - Easy Notes sa šíri pod **GNU General Public License (GPL) verzia 3**. To znamená, že: -\n -\n- Aplikáciu môžete slobodne používať, upravovať a zdieľať. -\n- Ak budete aplikáciu alebo jej upravené verzie šíriť, musíte šíriť aj zdrojový kód a zachovať licenciu GPL. - 3. Používanie aplikácie - Súhlasíte s tým, že budete Easy Notes používať len na zákonné účely a v súlade s týmito podmienkami. Nesmiete**: -\n -\n- _používať aplikáciu na akýkoľvek nezákonný účel_ -\n- _porušovať práva iných osôb_ -\n- _Narušovať funkčnosť alebo bezpečnosť aplikácie_ - 4. Duševné vlastníctvo - Všetky práva duševného vlastníctva v službe Easy Notes vlastníme my alebo naši poskytovatelia licencií. Používanie aplikácie vám neudeľuje vlastníctvo žiadnych práv duševného vlastníctva. - Easy Notes sa poskytuje tak, ako je, bez akýchkoľvek záruk. Nezaručujeme, že aplikácia bude bez chýb, bezpečná alebo nepretržitá. - 6. Obmedzenie zodpovednosti - 7. Ukončenie - Nenesieme zodpovednosť za žiadne škody vyplývajúce z používania aplikácie Easy Notes vrátane nepriamych, náhodných alebo následných škôd. - Ak porušíte tieto podmienky, môžeme ukončiť alebo pozastaviť váš prístup k službe Easy Notes. Po ukončení sa vaše právo používať aplikáciu okamžite skončí. - 8. Zmeny podmienok - Tieto Podmienky služby môžeme z času na čas aktualizovať. Vaše ďalšie používanie služby Easy Notes po akýchkoľvek zmenách predstavuje súhlas s revidovanými podmienkami. - V prípade akýchkoľvek otázok týkajúcich sa týchto podmienok poskytovania služieb alebo poznámok Easy Notes nás kontaktujte na adrese - Easy Notes nezhromažďuje žiadne osobné údaje. Viac informácií nájdete v našich zásadách ochrany osobných údajov, ktoré sú k dispozícii na adrese - Dátum účinnosti - Podmienky služby - Súhlasím - Nemohol importovať - Import niektorých súborov zlyhal - Úspešne importované - Import - Importovať ďalšie textové súbory ako poznámky - Použite monospace písma pri editácii poznámky - Monospace písmo - Požiadať o vzor pri štarte - Zadajte vzor - Ste si istí, že chcete odstrániť vybrané poznámky - Áno - Zrušiť - Zámok aplikácie - Lock aplikácie pomocou hesla - Pin (6 čísel) - Nastavenie kódu - Zadať kód - Kód - Odtlačok prsta - je uzamknutý - UID - Požiadať o odtlačky prstov na začiatku - Požiadať o kód pri štarte - Potrebné overenie - Vzor - Nastavenie vzoru - Zamknúť okamžite - Lock okamžite, keď je aplikácia obnovená - \ No newline at end of file diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml deleted file mode 100644 index 91692a5..0000000 --- a/app/src/main/res/values-sr/strings.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - AirNote - Beleške - Nova beleška - Nema kreiranih beleški. - Beleške nisu pronađene. - Vreme Kreiranja - Prikaz po kolonama - Reči - Karakteri - Naziv - Izaberi sve - Izbriši - Informacija - Pretraži - Ekran - Tamna Tema - Sistemska Tema - Amoled Boje - Dinamičke Boje - Podešavanja - Verzija - Rezervna Kopija - Još Nije Podržano - O Aplikaciji - Lajkuj Projekat - Doniraj mi :) - Vrati - Beleške - Podrška - Markdown Jezik - Alati - Istorija - Vidžeti - Teme • Veličina • Font - Oblak - Font • Jezik - NektCloud • Lokalno • Šifrovanje - Verzija • Programeri • Licenca - Istorija • Vrati - Kreirano • Izbriši sve - Podrška • Slike - Pokaži svoju ljubav - Radijus - Izaberi Radijus - Minimalistički Režim - Privatnost - Zaštita Ekrana - Podrška Za Slike - Nestabilna - Kriptovaluta - Prilagođava temu aplikacije na osnovu podešavanja sistema - Izaberi neku od tamnih i svetlih šema boja - Prilagođava boje tako da odgovaraju vašoj pozadini - Koristite crnu boju za pozadinu - Prilagodite radijus granice - Izaberite stil prikaza - Uklonite gornju dugmad sa ekrana za uređivanje - Vrati beleške iz rezervne kopije - Omogućite Markdown u beleškama - Sakrij ekran u nedavnim i onemogući snimke ekrana - Build Tip Aplikacije - Lista Podržavaoca - Imejl - Zahtev za funkcije - Zakačeno - Ostalo - Kopiraj - Šifrujte bazu podataka - Šifrujte bazu podataka tokom pravljenja rezervnih kopija - Sortiraj po Opadajućem nizu - Nepodržana veličina slike (maksimalno 15 mb) - Trezor - Omogući šifrovani trezor za beleške - Nastavi - Promeni jezik aplikacije - Sistemski jezik - Ponašanje - Daj prioritet režimu uređivanja - Uđi u režim za uređivanje postojećih beleški - Doniraj - Prevod - Briše slike iz skladišta aplikacije kada se uklone iz galerije - Sinhronizacija Galerije - Prikaži samo naslov u pregledu - 1. Prihvatanje Uslova - 2. Licenca - Easy Notes se distribuira pod **GNU General Public License (GPL) verzija 3**. to znači: -\n -\n- Možete slobodno da koristite, menjate i delite aplikaciju. -\n- Ako distribuirate aplikaciju ili bilo koju modifikovanu verziju, morate takođe distribuirati izvorni kod i zadržati GPL licencu. - Sva prava intelektualne svojine u Easi Notes-u su u vlasništvu nas ili naših davalaca licence. Vaše korišćenje aplikacije ne daje vam vlasništvo nad bilo kakvim pravima intelektualne svojine. - 5. Odricanje od Garancije - 4. Intelektualna Svojina - Easy Notes je obezbeđen takav kakav jeste, bez ikakvih garancija. Ne garantujemo da će aplikacija biti bez grešaka, bezbedna ili neprekidna. - 6. Ograničenje Odgovornosti - 7. Raskid - 10. Politika Privatnosti - Easy Notes ne prikuplja nikakve lične podatke. Za više informacija pogledajte našu Politiku privatnosti koja je dostupna na - Za bilo kakva pitanja u vezi sa ovim uslovima korišćenja usluge ili Easy Notes-a, kontaktirajte nas na - Datum stupanja na snagu - Slažem se - Uslovi korišćenja usluge - Mrežni prikaz - Opis - Baza Podataka - Jezik - Najnovije Izdanje - Boje i stilovi - Vidžeti • Markdown - Font - Ekstremne Amoled Boje - Ekstremna optimizacija baterije - Sačuvaj sve beleške u datoteku - Otvoreni kod - Diskord - Unesi lozinku - Lozinka je nevažeća ili baza podataka nije šifrovana - Sortiraj po Rastućem nizu - Sortirajte beleške prema vremenu kreiranja - Izaberi Belešku - Nevažeća lozinka - Doprinos - Nevažeći unos - Prikaži samo naslov - Pritiskom na Slažem se i korišćenjem Easi Notes prihvatate ove Uslove korišćenja usluge. Ako se ne slažete, nemojte koristiti aplikaciju. - Saglasni ste da ćete Easy Notes koristiti samo u zakonite svrhe iu skladu sa ovim Uslovima. Ne smete **ne**: -\n -\n- _Koristite aplikaciju u bilo koju nezakonitu svrhu_ -\n- _Krši prava drugih_ -\n- _Remeti funkcionalnost ili bezbednost aplikacije_ - Ne snosimo odgovornost za bilo kakvu štetu nastalu vašim korišćenjem Easy Notes-a, uključujući indirektnu, slučajnu ili posledičnu štetu. - Možemo ukinuti ili suspendovati vaš pristup Easy Notes-u ako prekršite ove Uslove. Nakon raskida, vaše pravo na korišćenje aplikacije prestaje odmah. - Možemo s vremena na vreme ažurirati ove Uslove korišćenja usluge. Vaše dalje korišćenje Easy Notes-a nakon bilo kakvih promena predstavlja prihvatanje revidiranih uslova. - 8. Promene Uslova - 3. Korišćenje Aplikacije - 9. Kontaktirajte Nas - Nije moguće uvesti - Uvoz nekih fajlova nije uspeo - Uspešno uvezeno - Uvezi - Uvezi druge tekstualne fajlove kao beleške - Koristi monospace font dok uređujete belešku - Monospace Font - \ No newline at end of file diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000..e7d9427 --- /dev/null +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,270 @@ + + + Home + Låtar + Artister + Albums + Spellistor + + + + %d vald + %d valda + + + + History + Stats + Mood and Genres + Account + Quick picks + Listen to songs to generate your quick picks + New release albums + + + Today + Yesterday + This week + Last week + + + Most played songs + Most played artists + Most played albums + + + Sök + Search Loudly + Search library… + Library + Liked + Downloaded + Alla + Låtar + Videor + Album + Artister + Spellistor + Community playlists + Featured playlists + Bookmarked + No results found + + + From your library + + + Liked songs + Downloaded songs + The playlist is empty + + + Försök igen + Radio + Blanda + Reset + + + Details + Redigera + Start radio + Play + Spela upp nästa + Lägg till i kön + Add to library + Remove from library + Ladda ned + Downloading + Ta bort nedladdning + Import playlist + Lägg till i spellista + View artist + View album + Refetch + Share + Ta bort + Remove from history + Search online + Sync + Advanced + + + Datum tillagd + Namn + Artist + Year + Song count + Length + Play time + Custom order + + + Media id + MIME type + Codecs + Bitrate + Sample rate + Loudness + Volume + File size + Unknown + Copied to clipboard + + Edit lyrics + Search lyrics + + Redigera låten + Låt titel + Låt artist + Låt titel kan inte vara tom. + Låt artist kan inte vara tom. + Spara + + Välj spellista + Redigera spellista + Skapa spellista + Namn på spellista + Namnet på spellistan kan inte vara tom. + + Redigera artist + Artist namn + Artist namn kan inte vara tom. + + + + %d låt + %d låtar + + + %d artist + %d artists + + + %d album + %d albums + + + %d playlist + %d playlists + + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + + + + Playlist imported + Removed \"%s\" from playlist + Playlist synced + Undo + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes + + No stream available + No network connection + Timeout + Unknown error + + + Like + Remove like + Shuffle on + Shuffle off + Repeat mode off + Repeat current song + Repeat queue + + + All songs + Searched songs + + + Music Spelare + + + Inställningar + Utseende + Enable dynamic theme + Mörkt tema + + Av + Följ systemet + Pure black + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Innehåll + Login + Standard innehållsspråk + Standard innehållsland + Systemets standardinställning + Enable proxy + Proxy type + Proxy URL + Restart to take effect + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Clear all downloads + Max image cache size + Clear image cache + Max song cache size + Clear song cache + used + + Privacy + Pause listen history + Clear listen history + Are you sure to clear all listen history? + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Imported playlist + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + Om + App version + + New version available + Translation Models + Clear translation models + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml deleted file mode 100644 index d4fa761..0000000 --- a/app/src/main/res/values-sv/strings.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - Skapade • Radera allt - Dynamiska färger - Återställ - Anteckningar - Markera alla - Stöds inte än - Beskrivning - Senaste utgåvan - Symboler - Anteckningar - Säkerhetskopiering - Stjärnmärk projektet - Kolumnvy - Namn - Extrema Amoled-färger - Inställningar - Ny anteckning - Inga anteckningar. - Rutnätsvy - Ord - Radera - Information - Sök - Systemtema - Amoled-färger - Databas - - Markdown - Verktyg - Historik - Widgetar - Teman • Storlek • Typsnitt - Typsnitt • Språk - Version - Språk - AirNote - Mörkt tema - Inga anteckningar hittades. - Skapelsetid - Visa - NextCloud • Lokalt • Kryptering - Widgetar • Markdown - Support - Om - Visa din uppskattning - Typsnitt - Välj rundning - Minimalistiskt läge - Molnet - Donera till mig :) - Version • Utvecklare • Licens - Support • Bilder - Rundning - Historik • Återgå - Integritet - Skärmskydd - Bildsupport - Anpassar appens tema baserat på systeminställningar - Skifta mellan mörkt och ljust färgläge - Instabil - Kryptovaluta - Justerar färger för att matcha din bakgrundsbild - Ta bort översta knappar från redigeringsfält - Använd svart bakgrundsfärg - Lagra alla anteckningar i en fil - Välj vy - Aktivera markdown i anteckningar - Återställ anteckningar från säkerhetskopierad fil - Källkod - Mejl - Göm skärm i nyliga appar och avaktivera skärmdumpning - Patroner - Andra - Discord - Fästa - Kopiera - Kryptera databas - Kryptera databas under säkerhetskopiering - Ogiltig input - Sortera stigande - Sortera fallande - Sortera anteckning efter skapelsedatum - Välj anteckning - Ange lösenord - Ogiltigt lösenord eller okrypterad databas - Bildstorlek stöds ej (max 15MB) - fortsätt - Ändra appspråk - Systemspråk - Beteende - Aktivera krypterat anteckningsvalv - Valv - Prioritera redigeringsläge - Ogiltigt lösenord - Donera - Översättning - Bidrag - Visa endast rubrik - Visa endast rubrik vid förhandsgranskning - Raderar bilder från apputrymme när de tas bort från galleriet - Kunde ej importera - Importera - Importera andra textfiler som anteckningar - Monospace-typsnitt - Applås - Avbryt - Lås app med lösenord - PIN (6 siffror) - Är du säker på att du vill ta bort de valda anteckningarna? - Ja - Fingertryck - Autentisering krävs - är låst - Be om fingertryck vid start - Mönster - Be om mönster vid start - UID - Lås omedelbart - Ställ in mönster - Ange mönster - diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml deleted file mode 100644 index a6b3dae..0000000 --- a/app/src/main/res/values-ta/strings.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 0c641c5..531d8f9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,468 +1,667 @@ - - AirNote - Notlar - Yeni Not - Oluşturulan not yok. - Not bulunamadı. - Oluşturma Zamanı - Izgara görünümü - Kolon görünümü - Kelimeler - Karakterler - Ad - Açıklama - Hepsini seç - Sil - Bilgi - Ara - Ayarlar - Görüntü - Sistem Teması - Koyu Tema - Dinamik Renkler - Amoled Renkler - Veritabanı - Yedekle - Henüz desteklenmiyor - Hakkında - Sürüm - Son Yayın - Projeye Başla - Bağış Yap :) - Geri Yükle - Notlar - Destek - - Dil - Markdown - Araçlar - Geçmiş - Widget\'lar - Temalar • Boyut • Yazı Tipi - Bulut - Yazı Tipi • Dil - NextCloud • Yerel • Şifreleme - Sürüm • Geliştiriciler • Lisans - Widget\'lar • Markdown - Geçmiş • Geri Al - Oluşturuldu • Hepsini Sil - Destek • Görseller - Sevginizi Gösterin - Yazı Tipi - Yarıçap - Yarıçap Seç - Sade Mod - Ekstrem Amoled Renkler - Ekran Koruması - Görseller Desteği - Kararsız - Kripto Para - Koyu ve açık renk düzenleri arasında geçiş yapın - Aşırı pil iyileştirmesi - Kenarlık yarıçapını özelleştirin - Görünüm biçimini seçin - Tüm notları dosyaya kaydedin - Notları yedekleme dosyasından geri yükleyin - Ekranı son kullanılanlarda gizleyin ve ekran görüntülerini devre dışı bırakın - Kaynak Kodu - Destekçiler Listesi - E-posta - Discord - Hata Bildirimi ve Daha Fazlası - Gizlilik - Uygulama temasını sistem ayarlarına göre uyarlar - Arka plan için siyah renk kullan - Renkleri duvar kağıdınıza uyacak şekilde ayarlar - Düzenleme ekranından üst düğmeleri kaldırın - Derleme Türü - Notlarda markdown\'ı etkinleştirin - Sabitlendi - Diğer - Kopyala - Veri Tabanını Şifrele - Yedekleme sırasında veri tabanını şifreleyin - Parola gir - Parola geçersiz veya veri tabanı şifreli değil - Geçersiz giriş - Artan Sıralama - Azalan Sıralama - Notları oluşturulma zamanlarına göre sırala - Not Seç - Desteklenmeyen Görsel Boyutu (en fazla 15mb) - Kasa - Şifreli not kasasını etkinleştir - devam et - Uygulama dilini değiştir - Sistem Dili - Davranış - Düzenleme Moduna Öncelik Ver - Var olan notlarda düzenleme moduna girin - Geçersiz Şifre - Katkıda Bulun - Galeriden kaldırıldıklarında görselleri uygulama depolama alanından siler - Galeri Eşzamanlaması - Yalnızca bağlığı göster - Bağış Yap - Çeviri - Ön izlemede yalnızca başlığı göster - 3. Uygulamanın Kullanımı - 5. Garantilerin Reddi - 7. Sonlandırma - Geçerlilik Tarihi - Kabul Et - Hizmet koşulları - 1. Koşulların Kabul Edilmesi - Kabul ediyoruma basarak ve AirNote\'u kullanarak bu Hizmet Koşullarını kabul etmiş olursunuz. Kabul etmiyorsanız, uygulamayı kullanmayın. - 2. Lisans - AirNote **GNU Genel Kamu Lisansı (GPL) sürüm 3** altında dağıtılmaktadır. Bu şu anlama gelir: -\n -\n- Uygulamayı kullanmakta, değiştirmekte ve paylaşmakta özgürsünüz. -\n- Uygulamayı veya üzerinde değişiklik yapılan sürümlerini dağıtırsanız, kaynak kodunu da dağıtmanız ve GPL lisansını korumanız gerekir. - Dolaylı, kazayla veya sonuç olarak ortaya çıkan zararlar da dahil olmak üzere Easy Notes\'u kullanmanızdan kaynaklanan hiçbir zarardan sorumlu değiliz. - AirNote\'u yalnızca yasal amaçlar için ve bu koşullara uygun olarak kullanmayı kabul edersiniz. Aşağıdakileri **yapamazsınız**: -\n -\n- _Uygulamayı herhangi bir yasa dışı amaç için kullanmak_ -\n- _Başkalarının haklarını ihlal etmek_ -\n- _Uygulamanın işlevselliğini veya güvenliğini bozmak_ - 4. Fikri Mülkiyet - AirNote\'taki tüm fikri mülkiyet hakları bize veya lisans verenlerimize aittir. Uygulamayı kullanmanız size herhangi bir fikri mülkiyet hakkının sahipliğini vermez. - AirNote herhangi bir garanti olmaksızın olduğu gibi sunulmaktadır. Uygulamanın hatasız, güvenli veya kesintisiz olacağını garanti etmiyoruz. - 6. Sorumluluğun Sınırlandırılması - Bu koşulları ihlal etmeniz halinde AirNote\'a erişiminizi sonlandırabilir veya askıya alabiliriz. Sonlandırıldığında, uygulamayı kullanma hakkınız hemen sona erecektir. - 8. Koşullarda Değişiklikler - Bu Hizmet Koşullarını zaman zaman güncelleyebiliriz. Herhangi bir değişiklikten sonra AirNote\'u kullanmaya devam etmeniz, değiştirilen koşulları kabul ettiğiniz anlamına gelir. - 9. Bizimle İletişime Geçin - Bu Hizmet Koşulları veya AirNote ile ilgili sorularınız için lütfen bizimle şu adresten iletişime geçin - 10. Gizlilik Politikası - AirNote ve AirNote Kai (AI Servisleri) herhangi bir kişisel veri toplamaz. Daha fazla bilgi github.com/RRechz/airnote-ai sitesine bakabilirsiniz - İçe aktarılamadı - Diğer metin dosyalarını not olarak içe aktarın - Bazı dosyalar içe aktarılamadı - Başarıyla içe aktarıldı - İçe aktar - Notu düzenlerken eş aralıklı yazı tipi kullanın - Eş aralıklı yazı tipi - Seçili notları silmek istediğinize emin misiniz - Evet - Hayır - Uygulama Kilidi (Deneysel) - Uygulamayı şifre ile kilitle - Şifre Girin - Şifre Ayarla - PIN (6 sayılı) - Şifre - Parmak İzi (Deneysel) - Doğrulama gerekiyor - Kilitlendi - UID - Açılışta şifre sor - Parmak izi - Açılışta parmak izi iste - Açılışta parmak izi iste - Parmak izi doğrulaması kur - Parmak izinizi kullanın - Hemen Kilitle - Uygulamaya geri döner dönmez uygulamayı kilitle - Kaydırmayı Kapat - Düzenleme modunda önizleme için kaydırmayı kapat - Arama Çubuğu Genişliğini Ölçekle - Arama çubuğunu notların genişliğiyle eşleştirin - Yazı Boyutu - Notlardaki metin boyutunu değiştir - Yazı Boyutu: %1$d - Önizleme - Notlarınız seçilen yazı boyutuyla bu şekilde görünecektir. - Babel Software kurucusu ve Android Geliştiricisi - Yeni klasör ekle - Klasör Adı - Klasör Rengi - Ekle - Klasöre Taşı - Mevcut klasör yok - Klasörü Sil - \'%1$s\' klasörünü silmek istediğinizden emin misiniz? İçindeki notlar ‘Tüm Notlar’ bölümüne taşınacaktır. - Klasörü Düzenle - Kaydet - Klasör Etiketi - Notların ait olduğu klasörün etiketini gösterir. - Kai - AI Yapılandır • API Ayarları - AirNote API kullan - Uygulamanın yerleşik API ile yapay zeka özelliklerini kullanın (Önerilen) - API Anahtarın (BETA) - Kontrol Et - AI Modelleri (BETA) - Model Seçimi (BETA) - AI\'ın Önerisi - Kapat - Tüm Notlar - Sor, sohbet et, AI ile bul... - Kendi API Anahtarınızı Nasıl Alırsınız? - Google AI Studio\'dan, cömert bir ücretsiz kullanım limitine sahip kişisel bir API anahtarı oluşturabilirsiniz. Bu anahtar sadece size aittir ve tüm kontrol sizde olur. - 1. Aşağıdaki butona tıklayarak Google AI Studio web sitesine gidin. - 2. Google hesabınızla giriş yapın. - 3. Açılan ekranda \"Create API key\" (API anahtarı oluştur) butonuna tıklayın. - 4. Yeni bir proje oluşturmanız istenebilir, adımları takip ederek projeyi oluşturun. - 5. Oluşturulan uzun karakter dizisini kopyalayın. - 6. Kopyaladığınız anahtarı aşağıdaki metin kutusuna yapıştırın ve \"Kontrol Et\" butonuna basın. - Google AI Studio\'ya Git - %1s silinemedi - Perplexity API Ayarlarına Git - Lütfen önce bir API anahtarı girin. - API anahtarı geçerli ve başarıyla kaydedildi! ✅ - Geçersiz API anahtarı. Lütfen kontrol edin ve tekrar deneyin. - Bir soru sor (?) - Bir konu hakkında not oluştur - Fikir Üret - Bana rastgele bir konu hakkında 5 ilginç fikir ver - Notu Kaydet - Yeniden Oluştur - Yenilikler + + Loudly\'ye Hoş Geldiniz + Müzik deneyiminizi kişiselleştirmeye başlayalım. + YouTube Music parmaklarınızın ucunda + YT Music hesap senkronizasyonu + Reklamsız müzik keyfi + Çevrimdışı müzik deneyimi + Android Auto desteği + Discord durum desteği + Tamamen açık kaynak + Oynatıcı Arayüzünüzü Seçin + Uygulama içi oynatıcı görünümünü seçin. Bu ayarı daha sonra “Ayarlar > Görünüm” bölümünden değiştirebilirsiniz. + Kurulum Tamamlandı! + Loudly\'yi keşfetmeye hazırsınız. + Bitir Geri + İleri + + Önce ki + Ana Sayfa + Keşfet + Şarkılar + Sanatçılar + Albümler + Çalma Listeleri + + Beğenilen müzikler + Beğen + Çevrimdışı müzikler + En çok dinlediklerim + Önbellekteki müzikler + Yerel müzikler + + İçerik filtresi + + Tüm zamanlar + Son 24 saat + Geçen hafta + Geçen ay + Geçen yıl + + + %d seçildi + %d seçildi + + + Günaydın + Tünaydın + İyi akşamlar + İyi geceler + Son etkinlik + Geçmiş + Geçmişiniz burada görünecek + İstatistikleriniz burada görünecek + İstatistikler + Daha Fazla + Modlar ve Türler + Hesap + Hızlı seçimler + Hızlı seçimlerinizi oluşturmak için şarkı dinleyin + Unutulmuş favoriler + Dinlemeye devam et + YouTube çalma listeleriniz + Benzer + Yeni çıkan albümler + İnternet Bağlantısı Yok + Görünüşe göre internet bağlantınız yok. Lütfen ağınızı kontrol edin. - - Yenilikler - Görüntü analizi sırasında bir hata oluştu. - İkon Seç - Bu sürümde ki yenilikler - Çeviri Başarısız... - Başarıyla doğrulandı ve kaydedildi! - Uygulama açıldığında en son görüntülenen klasörü gösterir. - Değiştir - Tüm Notu Çevir - Çevirmek istediğiniz metni seçin! - Görüntülemek veya düzenlemek için soldaki listeden bir not seçin. - DeX Sürümü - AI Servis Sürümü - AI asistanınızı ve özelliklerini masaüstü modunda kullanın - Araştır - Gönder - AirNote AI\'ya ne sormak istersiniz? - Ayarlanmadı - Görsel anliz ediliyor... - Rüyanız bulunamadı! Lütfen rüyanızı açıklayın. - Metin dosyası ekle (sadece .txt) - Yazımı İyileştir - Özetle - Daha Kısa Yap - Daha Uzun Yap - Tonu Değiştir - - Resmi - Dengeli - Samimi - Kullanıcı API anahtarı bulunamadı. Lütfen ayarlardan kontrol edin. - API isteği başarısız oldu: %1$s - Merhaba! Size nasıl yardımcı olabilirim? - ÖNERİLEN - Artıları ve Eksileri Listele - Yapılacaklar listesi oluştur - Daha basit anlat - İçe aktarılanlar için önerilri - Başlık Öner - - Resimden Notlar Alın - Harika, ne hakkında bir taslak oluşturalım? Lütfen konuyu yazın. - - Aşağıdaki metindeki dilbilgisi ve anlatım hatalarını düzelterek daha akıcı hale getir. Sadece düzeltilmiş metni cevap olarak ver:\n\n---\n\n%1$s - Aşağıdaki metni ana fikrini koruyarak bir veya iki cümleyle özetle. Sadece özet metnini cevap olarak ver:\n\n---\n\n%1$s - Aşağıdaki metni anlamını koruyarak önemli ölçüde kısalt. Sadece kısaltılmış metni cevap olarak ver:\n\n---\n\n%1$s - Aşağıdaki metne yaratıcı detaylar ekleyerek daha uzun bir hale getir. Sadece uzatılmış metni cevap olarak ver:\n\n---\n\n%1$s - - AI sunucuları şu anda meşgul. Lütfen birkaç dakika içinde tekrar deneyin. - %1$s\n\n---\n\n%2$s - Aşağıdaki metni profesyonel ve resmi bir dille yeniden yaz. Anlamı koru, sadece tonu değiştir. Sadece sonuç metnini cevap olarak ver: - Aşağıdaki metni ne çok resmi ne de çok samimi, dengeli bir dille yeniden yaz. Anlamı koru. Sadece sonuç metnini cevap olarak ver: - Aşağıdaki metni daha samimi, sıcak ve arkadaşça bir dille yeniden yaz. Anlamı koru. Sadece sonuç metnini cevap olarak ver: - - Bir not alma uygulaması kullanıcısı, \"%1$s\" başlıklı bir not üzerinde çalışıyor. Bu başlığa uygun, yaratıcı ve ilham verici birkaç fikir veya başlangıç noktası öner. Cevabın kısa ve liste formatında olsun. - Aşağıdaki metni, anlam bütünlüğünü ve yazarın tarzını koruyarak devam ettir. Sadece eklenecek yeni metni cevap olarak ver, mevcut metni tekrarlama:\n\n---\n\nBaşlık: %1$s\n\nİçerik:\n%2$s - Aşağıdaki metni analiz et ve bu konuya tamamen farklı bir bakış açısıyla nasıl yaklaşılabileceğine dair bir paragraf yaz. Mevcut metni özetleme, ona alternatif bir perspektif sun:\n\n---\n\nBaşlık: %1$s\n\nİçerik:\n%2$s - Aşağıdaki metni analiz et ve bir \'### Artılar\' ve bir \'### Eksiler\' başlığı altında, konuyla ilgili olumlu ve olumsuz noktaları madde madde listele. Sadece bu iki başlığı ve maddeleri cevap olarak ver:\n\n---\n\n%1$s - Aşağıdaki metni analiz et. Eğer metin gün içinde yapılacak görevlerden bahsediyorsa, bu görevleri her satırın başına \'[] \' koyarak bir yapılacaklar listesine dönüştür. Eğer metin görevlerden bahsetmiyorsa, başka hiçbir şey yazmadan SADECE \'NO_TASKS\' kelimesini cevap olarak ver. Metnin dilini algıla ve listeyi o dilde oluştur:\n\n---\n\n%1$s - Aşağıdaki karmaşık metni, herkesin anlayabileceği basit ve akıcı bir dile çevir. Metnin ana fikrini ve önemli detaylarını koru. Sadece basitleştirilmiş metni cevap olarak ver:\n\n---\n\n%1$s - Aşağıdaki metnin içeriğine en uygun, yaratıcı ve dikkat çekici 5 adet başlık önerisi sun. Cevabını numaralandırılmış bir liste olarak ver:\n\n---\n\n%1$s - Bir kullanıcı \"%1$s\" konusu hakkında bir not taslağı oluşturmak istiyor. Cevabını MUTLAKA şu formatta ver: BAŞLIK: [buraya başlığı yaz]\n\nİÇERİK: [buraya not içeriğini yaz] - - Kullanıcı şu anda \'%1$s\' başlıklı bir nota referans veriyor. Sorularını bu notun içeriğine göre yanıtla. Notun içeriği aşağıdadır:\n\n---\n%2$s\n--- - Anlaşıldı. \'%1$s\' başlıklı notun içeriğini aldım ve bu notu referans alarak yanıt vereceğim. - - Sen, AirNote uygulamasında ki Kai yapay zekasına ait bir profesyonel bir strateji uzmanısın. Amacın iş ve profesyonel iletişimde yardımcı olmak. Yanıtların açık, öz ve hedef odaklı olmalı. Uygulanabilir içgörüler ve pratik çözümlere odaklan. Profesyonel ve kendinden emin bir üslup kullan. Metin oluştururken veya değiştirirken açıklık, etki ve hedef kitle farkındalığını önceliklendir. Çıktın iş raporları, profesyonel e-postalar, sunumlar ve stratejik belgeler için uygun olmalı. - Elbette, \'@%1$s\' notunun içeriğini güncelledim. - Tamam, \'@%1$s\' notuna istediğiniz bilgiyi ekledim. - Anlaşıldı. \'@%1$s\' notunun başlığını \'@%2$s\' olarak değiştirdim. - İsteğiniz üzerine \'@%1$s\' notunu sildim. - Notu değiştirirken bir sorunla karşılaştım. Lütfen tekrar deneyin. - - Yeni Sürüm - Güncelle - Yeni güncelleme mevcut! - Uygulamayı güncelle - Değişiklik günlüğü alınamadı. - Değişiklik günlüğü alınamadı: %1$s - Güncellemeleri kontrol ediyorum... - Güncelleme Mevcut - Yeni sürüm Mevcut! - %1$s sürümü indirilebilir. - Şimdi Güncelle - İndir - İndiriliyor... %1$d% - Kur - Kur - Güncelleme Başarısız - Hata - Tekrar Dene - Tekrar Dene - Uygulamanız Güncel - Güncellendi - Güncelleme bilinmeyen kaynaklardan yükleme izni gerektirir. - İndirme linki bulunamadı. - Bu ekteki resmi Sherlock Holmes gibi analiz edin ve içeriğini açıklayan bir not oluşturun. Cevabınız, başlık ve içerik arasında net bir ayrım yaparak ‘BAŞLIK:’ ve ‘İÇERİK:’ içermelidir. - Son Değişiklikler + Sizin için öne çıkanlar + Bugün sizin için önerilenler + Bugün + Dün + Bu hafta + Geçen hafta + Yerel + Uzak + Yerel geçmiş + Uzak geçmiş + + En çok çalınan şarkılar + En çok çalınan sanatçılar + En çok çalınan albümler + + Ara + Loudly\'de ara + Kütüphanede ara… + Arama Geçmişi + Öneriler + Arama Sonuçları + Kütüphane + Beğenilenler + İndirilenler + Tümü + Şarkılar + Videolar + Albümler + Sanatçılar + Çalma Listeleri + Topluluk çalma listeleri + Öne çıkan çalma listeleri + İşaretlenmiş + Sonuç bulunamadı + + Kütüphaneniz burada görünecek + Kütüphanedeki şarkılar burada görünecek + Kütüphanedeki sanatçılar burada görünecek + Kütüphanedeki albümler burada görünecek + Çalma listeleriniz burada görünecek + + Kütüphanenizden + + Diğer versiyonlar + + En Popüler Şarkılar + Beğenilen şarkılar + İndirilen şarkılar + Çalma listesi boş + \"%s\" çalma listesindeki tüm şarkıları İndirilen Şarkılar deposundan gerçekten kaldırmak istiyor musunuz? + \"%s\" çalma listesini gerçekten silmek istiyor musunuz? + + Devre dışı bırak + + Tekrar dene + Radyo + Karıştır + Sıfırla + + Ayrıntılar Düzenle - Önizle - Klasörlerde ara... - Yeni Klasör - Gizle - Yenilikler - Kuruluma hazır! - Harika! En son sürüm özelliklerini kullanıyorsunuz. - Çevir - Orijinal Dil - Gemini API Anahtarı - Perplexity API Anahtarı - Kaydet ve Doğrula - Son Klasör (BETA) - Çeviri Modülleri - İndirildi - İndirilmedi + Radyoyu başlat + Oynat + Sıradaki yap + Sıraya ekle + Kütüphaneye ekle + Tümünü kütüphaneye ekle + Kütüphaneden kaldır + Tümünü kütüphaneden kaldır İndir - Kullanılabilir çeviri dili bulunmuyor! Lütfen Ayarlar > Kai menüsünden bir dil modeli indirin - API Anahtar Simgesi - Başarıyla Doğrulanmış Simgesi - %1s Silinsin mi? - Bu dil modelini cihazınızdan kaldırmak istediğinizden emin misiniz? - %1s dil modelini sil - Hata - İndirilen dil modellerinin listesi alınamadı. - %1s indirildi - %1s indirilemedi - %1s silindi - Çalışmak istediğiniz metni seçin! - Bilinmeyen bir hata oluştu! - Beklenmeyen bir hata oluştu: %1s - Öncelikle, gün içinde yapmanız gerekenleri kısaca açıklayın. - İndirilen dil modelleri alınamadı: %1s - Çeviri başarısız: %1s - AirNote DeX\'e Hoşgeldiniz - Geniş ekranlarda daha iyi not alma deneyimi! - Klasörler - Notlarda ara... - Başlık... - Notunuzu buraya yazın... - Yeni Not Oluştur - Başlayın! - Yeni Sohbet - Sohbet Geçmişi Yok... - Sohbet Silinsin Mi? - Bu sohbeti silmek istediğinizden emin misiniz? Bu işlem geri ALINAMAZ! - Not Asistanı - Beyin Fırtınası - Profesyonel Strateji - Merhaba! - DeX Modu - Büyük ekranlarda masaüstü arayüzünü yatay modda kullanın - DeX AI Özellikleri - - AirNote adlı bir uygulamaya entegre edilmiş yaratıcı bir beyin fırtınası ortağı ve yazma asistanısınız. Amacınız, kullanıcılara ilham vermek, yeni fikirler üretmek ve yazma engellerini aşmalarına yardımcı olmaktır. Tonunuz cesaret verici, yaratıcı ve geniş kapsamlı olmalıdır. Listeler, metaforlar kullanmaktan ve alışılmadık yaklaşımlar önermekten çekinmeyin. Kullanıcıyı yaratıcı bir diyaloga dahil edin. - Düşün - Merakınızı Serbest Bırakın! - Hangi konu merakınızı uyandırıyor? - Oluştur - Örn: Kuantum Fiziğine Giriş - Hangi konu hakkında not oluşturmak istersiniz? Konuyu kısaca açıklayın. - Yeni Bir AI Not Taslağı - Merakınızı Serbest Bırakın - "AirNote Kai hata yapabilir. " - Daha fazla bilgi - Akademiz Araştırma - Sen, AirNote uygulamasında ki Kai yapay zekasına ait bir Akademik Araştırmacı AI\'sın. Ana hedefin akademik yazımlara yardımcı olmaktır. Objektif, kesin ve kanıta dayalı olmalısın. Yanıtlarını mantıklı bir şekilde, açık argümanlarla yapılandır. Konuşma dilini kullanmaktan kaçın ve resmi bir üslup kullan. Metni analiz ederken mantıksal tutarlılık, argümanların gücü ve netliğe odaklan. Yanıtların iyi yapılandırılmış ve akademik makaleler, denemeler ve araştırma notları için uygun olmalıdır. - Rüya Günlüğü - Yeni Rüya Notu - Duygusal Analiz - Sembolleri Anlat - Rüyamı Anlamlandır - AirNote Kai - Rüya Modeli - AirNote uygulamasına entegre edilmiş Kai AI\'nın rüya yorumcususunuz. Göreviniz, size anlatılan rüyaları analiz etmek, sembolleri açıklamak ve psikolojik veya kültürel anlamlarını yorumlamaktır. Yalnızca ve sadece rüyalar, rüya sembolleri ve rüya yorumları hakkında konuşun. Rüyalarla ilgisi olmayan sorulara “Ben sadece rüyaları yorumlamak için buradayım” şeklinde yanıt verin. Yorumlamanız gereken rüya şudur:\\n\\n - AirNote uygulamasına entegre edilmiş Kai AI için rüya sembolü uzmanı sizsiniz. Göreviniz, size anlatılan rüyadaki anahtar sembolleri (nesneler, kişiler, yerler, eylemler) bulmak ve bunların olası anlamlarını listelemektir. Genel bir rüya yorumu yapmayın; yalnızca sembollere odaklanın. Yalnızca rüya sembollerini tartışın. Başka bir soru sorulursa, “Ben sadece rüya sembollerini açıklamak için buradayım” şeklinde yanıt verin. İşte sembollerini analiz etmeniz gereken rüya:\\n\\n - AirNote uygulamasına entegre edilmiş Kai AI\'nın rüya terapistisisiniz. Göreviniz, size anlatılan rüyadaki baskın duygusal tonu (korku, mutluluk, endişe vb.) analiz etmek ve bu duyguların rüya gören kişi için ne anlama gelebileceğini açıklamaktır. Genel yorumlarda bulunmaktan veya sembolleri açıklamaktan kaçının; yalnızca rüyanın uyandırdığı duygulara ve bunların olası psikolojik temellerine odaklanın. Yalnızca rüyanın duygularını tartışın. Başka bir soru sorulursa, “Ben yalnızca rüyaların duygusal analizini yapmak için buradayım” şeklinde yanıt verin. Analiz etmeniz gereken rüya şöyledir:\\n\\n - Resmi | @AirNoteApp - Aktif Model: %1$s - Perplexity AI Servisi - Etkin - Perplexity AI Etkinleştir - Notların kilidi açıldı - not oluştur veya not oluştur - analiz et ya da metni analiz et ya da analiz - notlar al ya da not al ya da notlara dök ya da nota dök - özetle ve notlar al ya da özetle ve notlara ekle - bir taslak hazırla veya benim için bir taslak metin hazırla veya bir taslak oluştur - Dosyamı/görüntümü analiz et veya ekli dosyayı analiz et veya Dosyamı/görüntümü analiz et ve notlar al - Dosya boş veya okunamıyor. - Görsel ekle - Sen, AirNote uygulamasına entegre edilmiş akıllı bir asistan olan Kai\'sin. Ana hedefin, kullanıcının niyetini anlayarak notları konusunda ona yardımcı olmaktır. Sağlanan not bağlamına ve mesajına göre kullanıcının isteğini analiz etmelisin. - AMAÇ 1: CHAT\nKullanıcı notla ilgili bir soru soruyorsa, fikir istiyorsa veya notun içeriğini açıkça değiştirmeyi istemeden bir özet istiyorsa, şu JSON formatını kullanın:\n{\n \“intent\”: \“CHAT\”,\n \“response\”: \“[Yardımcı sohbet yanıtınız burada]\”\n} - AMAÇ 2: EDIT_NOTE\nKullanıcı notu açıkça değiştirmek isterse (örneğin, “son paragrafı sil”, “sonuna özet ekle”, “grameri düzelt”, “yazmaya devam et”), bu JSON formatını kullanın:\n{\n \“intent\”: \“EDIT_NOTE\”,\n \“new_note_content\”: \" [Kullanıcının istediği değişiklikleri uyguladıktan sonra notun TAM içeriği]\"\n} - ÖNEMLİ: YALNIZCA ham JSON ile yanıt vermelisiniz. ```json veya ```. - --- GÜNCEL NOT İÇERİĞİ ---\n\"\"\"%s\"\"\" - --- KULLANICI TALEBİ ---\n\"\"\"%s\"\"\" - İsteğiniz üzerine notunuzu güncelledim! - Kai\'nin AI sunucularından geçersiz bir yanıt alındı. Lütfen tekrar deneyiniz! - Aramanızla eşleşen hiçbir not bulunamadı. - Aradığınız %1$s klasörünü bulamadım. - Bu notları buldum; \n%1$s - Perplexity\'nin sohbet API\'sını kullanmak için bir API anahtarına ihtiyacınız var. Anahtarınızı aldıktan sonra buraya yapıştırın. - 1. Perplexity AI web sitesine gidin. - 2. Hesabınıza giriş yapın (veya yeni bir hesap oluşturun). - 3. Sağ üst köşedeki profil simgenize tıklayın ve “API Anahtarları” seçeneğini seçin. - 4. “Yeni API Anahtarı Oluştur” düğmesini tıklayın. - 5. Anahtarınıza bir ad verin (örneğin, “AirNote”) ve oluşturun. - 6. Oluşturulan anahtarı kopyalayın ve bu ekrandaki “Perplexity API Anahtarı” alanına yapıştırın. - 7. “Kaydet ve Doğrula” düğmesini tıklayın. - Perplexity\'de ara... - Perplexity API anahtarı boş bırakılamaz! - Gemini AI Servisi - DİKKAT: Bu API yalnızca Perplexity Pro kullanıcı API\'lerini destekler. - Güvenlik yönetimi seçin - Aktif - Aktif et - Parmak İzi Doğrulama - Etkinleştirmek için onaylayın - Cihazınız parmak izi tanıma özelliğini desteklemiyor. - Kasa Kurulumu - Kasa için bir şifre belirleyin: - Kasa Şifresi - Şifreleme Türü: - Kasa oluşturuldu! - Şifre boş bırakılamaz! - Not Kilit Şifresi - Bireysel notlarınızı kilitlemek için bir şifre belirleyin. - Not Şifresi - Şifre Güncellendi - Ayarlamak için dokunun... - Aktif etmek için dokunun... - Uygulama Kilidi Şifresi - Yeni Şifre - Şifre ayarlandı - En az 4 karakter girin - Parmak izi etkinleştirildi! - Öncelikle, ayarlarda bir not şifresi belirleyin! - Notlarınızı kilitlemek için önce ayarlarda bir şifre belirleyin. - Bu not kilitlidir ancak şifre belirlenmemiştir. - Notun kilidini aç - Bu notu görüntülemek için lütfen şifrenizi girin. - Seçili Notların Kilidini Aç - Onayla - Notlar kilitlendi + İndiriliyor + İndirmeyi kaldır + Çalma listesini içe aktar + Çalma listesine ekle + Sanatçıyı görüntüle + Albüme git + Yeniden al + Paylaş + Sil + Geçmişten kaldır + Çalma listesinden kaldır + Sıradan kaldır + Önbellekten kaldır + M3U dosyası olarak dışa aktar + Çevrimiçi ara + Senkronize et + Gelişmiş + Tempo ve Ton + + Eklenme tarihi + Güncellenme tarihi + Ad + Sanatçı + Yıl + Şarkı sayısı + Süre + Çalma süresi + Özel sıralama + + Medya ID + MIME türü + Codec\'ler + Bit hızı + Örnekleme hızı + Ses yüksekliği + Ses + Dosya boyutu + Bilinmiyor + Panoya kopyalandı + + Şarkı sözlerini düzenle + Şarkı sözü ara + \"%s\" için saklanan şarkı sözlerini gerçekten silmek istiyor musunuz? + + Şarkıyı düzenle + Şarkı adı + Şarkı sanatçıları + Şarkı adı boş olamaz. + Şarkı sanatçısı boş olamaz. + Kaydet + + Çalma Listesini Senkronize Et + Çalma listesi seç + Çalma listesini düzenle + Çalma listesi oluştur + Çalma listesi adı + Çalma listesi adı boş olamaz. + + Sanatçıyı düzenle + Sanatçı adı + Sanatçı adı boş olamaz. + + Tekrarlananlar + Tekrarlananları atla + Yine de ekle + Şarkı zaten çalma listenizde mevcut + %d şarkı zaten çalma listenizde mevcut + Not: Bu, YouTube Music ile senkronizasyona izin verir + + + %d şarkı + %d şarkı + + + %d sanatçı + %d sanatçı + + + %d albüm + %d albüm + + + %d çalma listesi + %d çalma listesi + + + %d hafta + %d hafta + + + %d ay + %d ay + + + %d yıl + %d yıl + + + Çalma listesi içe aktarıldı + \"%s\" çalma listesinden kaldırıldı + Çalma listesi senkronize edildi + Geri al + + Şimdi çalıyor + Şarkı sözü bulunamadı + Uyku zamanlayıcısı + Şarkı sonu + + 1 dakika + %d dakika + + Kullanılabilir akış yok + Ağ bağlantısı yok + Zaman aşımı + Bilinmeyen hata + Menü + Listeyi Paylaş + + 5 saniye geri sar + 5 saniye ileri sar + + Beğen + Tümünü beğen + Beğeniyi kaldır + Tüm beğenileri kaldır + Karıştırma açık + Karıştırma kapalı + Tekrarlama kapalı + Geçerli şarkıyı tekrarla + Sırayı tekrarla + + Tüm şarkılar + Aranan şarkılar + + Müzik Oynatıcı + + Bildirimler + Sistemin uygulamayı kapatmasını engelle + Loudly\'nin sistem tarafından rastgele kapatılmasını önlemek için bir ön plan bildirimi kullanın. + Canlı Tutma Ayarları + Bildirim izni gereklidir + + Ayarlar + Görünüm + Tema + Proxy + Dinamik temayı etkinleştir + Koyu tema + Açık + Kapalı + Sistemi takip et + Saf siyah + Uygulama tasarımı + Uygulama Arayüzü 1.0 + Uygulama Arayüzü 1.5 + YouTube Music\'te dinle + Loudly\'yi ziyaret et + Sanatçı + Loudly dinle + Şarkı değiştirmek için küçük resimde kaydırmayı etkinleştir + Gezinme sekmelerini özelleştir + Oynatıcı + Oynatıcı metin hizalaması + Şarkı sözü metin konumu + Yan + Sol + Orta + Mini Oynatıcı Aksiyonları + Oynatıcı stili + Eski Oynatıcı Arayüzü + Oynatıcı Arayüzü 1.0 + Oynatıcı Arayüzü 2.0 + Yok + Temayı takip et + Gradyan + Bulanıklık + Bulanıklık (Hareketli) + Oynatıcı arka plan stili + Sağ + Kaydırıcı stili + Varsayılan + Dalgalı + Oluştur + Küçük resim köşe yarıçapını yapılandır + İçerik filtresini göster + Çeşitli + Ana + Otomatik çalma listeleri özelleştirmesi + Beğenilenleri otomatik çalma listesinde göster + İndirilenleri otomatik çalma listesinde göster + En çok dinlenenleri otomatik çalma listesinde göster + Önbellektekileri otomatik çalma listesinde göster + Yereldekileri otomatik çalma listesinde göster + Sıradaki şarkı için sağa, sıraya eklemek için sola kaydırın + Varsayılan başlangıç sekmesi + Varsayılan kütüphane çipleri + Izgara hücre boyutu + Alt gezinme çubuğu etiketlerini göster + Küçük + Büyük + Değişiklik Günlüğü + Son değişiklik bulunamadı + Son değişiklikler: + Kapat + Yeni sürüm mevcut + Tekrar Merhaba + Bağış Yap + Resmi Kanal + Web sitesi + Resmi web sitesi ❯ + Kaynak kodu + Uygulamanın kaynak koduna erişin + Sorunlar + Hata Raporları ve Daha Fazlası + Yardım + Katkıda bulunmak isterseniz buraya tıklayın ❯ + Geliştirici | @RRechz + + İçerik + Giriş yap + YT Music çalma listelerinize ve daha fazlasına giriş yapın + Tokeni göstermek için dokunun + Kopyalamak veya düzenlemek için tekrar dokunun + Bu GELİŞMİŞ bir giriş yöntemidir. Web portalına alternatif olarak, giriş tokeninizi doğrudan buraya girebilir veya güncelleyebilirsiniz. Örneğin, bu birden fazla cihazda oturum açmayı hızlandırabilir. Lütfen uygulamanın ayrıştıramadığı geçersiz token formatlarının kabul edilmeyeceğini unutmayın + Spotify\'dan içe aktar + Spotify + Discord + Beğendiğiniz şarkıları otomatik olarak indirin + YouTube Music hesabınızla otomatik olarak senkronize edin + Çalma listelerinizi ve daha fazlasını hesabınızla senkronize edin + Sadece Wi-Fi + Giriş yapılmadı + Varsayılan içerik dili + Varsayılan içerik ülkesi + Sistem varsayılanı + Proxy\'yi etkinleştir + Proxy türü + Proxy URL\'si + Etkinleşmesi için yeniden başlatın + Uygulama dili + Uygulama dillerini yapılandır + Uygulama dili ayarları bulunamadı, lütfen manuel olarak yapılandırın + Desteklenen bağlantıyı varsayılan olarak aç + Desteklenen bağlantıları yapılandır + Desteklenen bağlantı ayarları bulunamadı, lütfen manuel olarak yapılandırın + + Oynatıcı ve Ses + Ses kalitesi + Maksimum + Otomatik + Yüksek + Düşük + Minimum çalma süresi + Bir şarkının \"çalındı\" olarak kabul edilmesi için çalınması gereken minimum süre + Sıra + Kalıcı sıra + Uygulama başladığında son sıranızı geri yükleyin + Otomatik olarak daha fazla yükle + Sıranın sonuna gelindiğinde, mümkünse otomatik olarak daha fazla şarkı ekleyin + Sessizliği atla + Ses normalleştirme + Bluetooth cihazı bağlandığında oynatmaya devam et + Ses en düşük seviyedeyken şarkıyı çalmayı durdur + Çalınan şarkıları YouTube-Music geçmişine ekle + Hata oluştuğunda otomatik olarak sonraki şarkıya atla + Kesintisiz çalma deneyiminizi sağlayın + Görev temizlendiğinde müziği durdur + Ekolayzer + Şarkı Sözleri + Şarkı Sözleri + Yerel oynatıcı ayarları + Depolama + Önbellek + Resim Önbelleği + Şarkı Önbelleği + Maksimum önbellek boyutu + Sınırsız + Boşaltmayı etkinleştir + Ses çalmak için boşaltma ses yolunu kullanın. Bunu devre dışı bırakmak güç kullanımını artırabilir ancak ses çalma veya son işleme ile ilgili sorunlar yaşıyorsanız yararlı olabilir + Tüm indirmeleri temizle + Önbellekler + Maksimum resim önbelleği boyutu + Resim önbelleğini temizle + Maksimum şarkı önbelleği boyutu + Şarkı önbelleğini temizle + kullanıldı + Tüm önbelleği temizlemek istediğinizden emin misiniz? + Tüm indirmeleri temizlemek istediğinizden emin misiniz? + Tüm önbelleğe alınmış şarkıları temizlemek istediğinizden emin misiniz? + Tüm önbelleğe alınmış resimleri temizlemek istediğinizden emin misiniz? + Tüm önbelleğe alınmış çeviri modellerini temizlemek istediğinizden emin misiniz? + + Gizlilik + Dinleme geçmişi + Dinleme geçmişini duraklat + Dinleme geçmişini temizle + Tüm dinleme geçmişini temizlemek istediğinizden emin misiniz? + Arama geçmişini duraklat + Arama geçmişini temizle + Tüm arama geçmişini temizlemek istediğinizden emin misiniz? + Arama geçmişi + İçeriğe göz atmak için girişi kullan + Bu, hangi içeriği gördüğünüzü etkileyebilir ve örneğin Premium bir hesapla giriş yaptıysanız yalnızca premium albümleri gösterir + Ekran görüntüsünü devre dışı bırak + Bu seçenek açıkken, ekran görüntüleri ve uygulamanın Son Uygulamalar\'daki görünümü devre dışı bırakılır. + LrcLib şarkı sözü sağlayıcısını etkinleştir + KuGou şarkı sözü sağlayıcısını etkinleştir + Varsayılan şarkı sözü sağlayıcısı + Çok satırlı şarkı sözlerini oku + Senkronizasyon noktaları arasındaki tüm satırları tek bir şarkı sözü metni olarak kabul et + Şarkı sözlerinin etrafındaki boşlukları kaldır + Yazı tipi boyutu + Müstehcen içeriği gizle + Bildirimleri etkinleştir + + Yedekle ve geri yükle + Yedekle + Geri yükle + Bir çalma listesi içe aktar (m3u) + Çalma listesi içe aktarılamadı + İçe aktarılan çalma listesi + Yedek başarıyla oluşturuldu + Yedek oluşturulamadı + Yedek geri yüklenemedi + + Discord Entegrasyonu + Loudly, Discord hesabınızın durumunu ayarlamak için KizzyRPC kütüphanesini kullanır. Bu, Discord\'un Hizmet Şartları\'nın ihlali olarak kabul edilebilecek Discord Gateway bağlantısını kullanmayı içerir. Ancak, bu nedenle kullanıcı hesaplarının askıya alındığına dair bilinen bir vaka yoktur. Riski size aittir.\n\nLoudly yalnızca tokeninizi alacak ve diğer her şey yerel olarak saklanacaktır. + Kapat + Seçenekler + Önizleme + Giriş başarısız + Çıkış yap + Zengin Durumu Etkinleştir + + Hakkında + Uygulama sürümü + Güncellemeler kontrol ediliyor... + %1$s sürümü indirilmeye hazır. + İndiriliyor... %1$d%% + Yükselt + Tekrar Dene + Şimdi İndir + Uygulama Türü + + Yeni sürüm mevcut + Çeviri Modelleri + Çeviri modellerini temizle + Geçmiş süresi + + 1 saniye + %d saniye + + Müzik 3x2 + Müzik 2x1 + Oynat/Duraklat + Sürüm · Geliştiriciler · Lisans + Arayüz + İçerik & Hesap + Oynatıcı + Sistem + Kütüphane + Entegrasyonlar + Sistem Entegrasyonu + Toplam kullanılan alan + Şu anda kullanılıyor + Önbelleği temizle + Yedekle · Geri Yükle · İçe Aktar + Geçmiş · Arama · Ekran Görüntüsü + İndirmeler · Önbellek · Resimler + Ses · Sıra · Şarkı Sözleri + Dil · Konum · Proxy + Giriş Yap · Spotify · Discord + Tema · Kaydırıcı · Oynatıcı + + Giriş işleminiz başarıyla tamamlandı! + Profil Kart Resmi + Profil kartında ki resmi kişiselleştirin + Profil resmi seç + Hazır resimler + Galeriden seç + Fotoğrafı Düzenle + İptal + + Yeniden Deneniyor... + VPN kullanmak bağlantı kalitenizi etkileyebilir. Ağ sorunları yaşıyorsanız, VPN bağlantınızı kesin veya bir proxy kullanın. Bunu “Ayarlar >> İçerik >> Proxy” bölümünden yapılandırabilirsiniz. + Yerel şarkılar başarıyla senkronize edildi + Yerel tarayıcı + + Otomatik tara + Uygulamayı açarken şarkıları otomatik olarak tara + + Ek tarayıcı ayarları + Katı dosya adları + Etkinleştirildiğinde uzantılar göz ardı EDİLMEZ. Örn. "Sarki.ogg", "Sarki.flac"tan farklı bir şarkı olacaktır + Tarayıcı hassasiyetini yapılandır + Başlığı eşleştir + Başlığı ve sanatçıları eşleştir + Başlığı, sanatçıları, albümleri eşleştir + + Klasörler + Alt klasörleri düzleştir + Diskteki dosya yapısını korumak için devre dışı bırakın + + İpuçları & Özellikler + İpuçları · Özellikler + Hikayeni oluştur + Dinlediğiniz şarkıyı albüm kapağının baskın renkleriyle oluşturulmuş şık bir tasarımla sosyal medyada paylaşın. + Özelleştirilebilir Ayarlar + Ayarlar ekranındaki başlık kartının arka planını galerinizden bir resimle değiştirerek uygulamayı kendinize özel hale getirin. + Yeni Şarkı Listesi + Oynatıcı ekranından yukarı kaydırarak mevcut çalma listenizi görüntüleyin ve şarkılar arasında kolayca geçiş yapın (Player UI 2.0\'a özel yeni tasarım) + Yenilenmiş Ayarlar Tasarımı + Daha modern ve kullanışlı olan yeni kart tabanlı ayarlar tasarımıyla aradığınızı kolayca bulun. + Klasik Müzik Widget\'ı + Ana ekranınızdan tüm kontrollere ve şarkı bilgilerine hızla erişin. + Minimalist Müzik Widget\'ı + Android 16\'nın Hızlı Ayarlar düğmesi tasarımından ilham alan “Quik” müzik widget\'ı + Hikayede Paylaş + Şarkı bağlantısını kopyala + Bağlantı panoya kopyalandı! + Loudly\'de dinlendi + + Tümünü Seç + Tüm çalma listeleri alınıyor… + Seçili Öğeleri İçe Aktar + Bir şeyler ters gitti. En az sizin kadar şaşkınız. + Bu utanç verici. Bizim de ne olduğu hakkında hiçbir fikrimiz yok. + Gerekli kimlik bilgilerini almak için talimatlar + 3. Yukarıdaki yerel yolu girdikten sonra "Ekle"ye tıkladığınızdan emin olun. + 5. Hizmet şartlarını kabul edin ve "Kaydet"e tıklayın. + Yeni bir sayfaya yönlendirileceksiniz. Ayarlar\'a tıklayın, ardından İstemci ID\'sini ve İstemci Sırrını kopyalayın ve bu uygulamadaki aşağıdaki metin alanlarına yapıştırın. + 2. Gerekli ayrıntıları girin ve Yönlendirme URI\'leri olarak http://127.0.0.1:45454 kullanın. + 4. Web API\'sini açın. Onay kutusunda şimdi bir tik işareti olmalıdır. + Şimdi, aşağıdaki Yetkilendir ve Devam Et düğmesine tıklayın ve içe aktarmak istediğiniz hesabınızla giriş yapın, ve Yetkilendir ve Devam Et\'e tıklayın; bu sizi http://127.0.0.1:45454/?code= ile başlaması gereken bir siteye yönlendirecektir. + +Bu URL\'nin tamamını aşağıdaki Yetkilendirme Kodu yazan metin alanına yapıştırın ve Doğrula düğmesine tıklayın. + 1. Geliştiriciler için Spotify panosunu ziyaret edin ve "Uygulama oluştur"a tıklayın. + Spotify API Kimlik Bilgileri ile Giriş Yap + Yetkilendir ve Devam Et + Yetkilendirme Kodu + Doğrula + İstemci Sırrı + İstemci ID + İçe aktarma devam ediyor… + Uygulamayı kapatmayın veya geri gitmeyin. Bu işlem arka planda çalışmaz, bu yüzden bitene kadar bekleyin! +TAKILMIŞ GÖRÜNÜRSE PANİK YAPMAYIN; bazen alım işlemi biraz zaman alabilir. + "Beğenilen Şarkılar" Hedefini Seçin + Beğenilen şarkılar nereye aktarılmalı? + "Beğenilen Şarkılar" adında yeni bir çalma listesi + Varsayılan "Beğenilen Şarkılar"a + Daha Az + Geliştiriciye İlet + Kapat + Hata Detaylarını Göster + İndirme Başarısız + Hatayı Göster + Manuel Güncelle + Detaylar için ayarları kontrol et + Yazılım Güncel + Kopyala + “Energy” çalma listenizle temponuzu koruyun. + Ağ Hatası + Ağ bağlantınızı kotnrol edin ve yeniden deneyin. + Desteklenmeyen İçerik + Bu şarkı formatı cihazınızda desteklenmemektedir. + Canlı Yayın Hatası + Canlı yayında bir sorun var. Lütfen yeniden başlatın. + Oynatma Başarısız + Bilinmeyen bir hata oluştu. Lütfen tekrar deneyin. + Değiştirmek için dokun + Çeviri (BETA( + Favori + Uygulama Arayüzü + 3 farklı tasarımdan birini seçin + UI Sürüm Seçimi + Uygulamanın eski sürüm tasarımı. (Uygulama, Oynatıcı, Mini Oynatıcı) + Daha modern bir yaklaşımla tasarlanmış kompakt tasarım. (Uygulama, Oynatıcı, Mini Oynatıcı) + En popüler tasarım. (Uygulama, Oynatıcı, Mini Oynatıcı) + Kendi uygulama kullanıcı arayüzü tasarımınızı oluşturun. (Uygulama, Oynatıcı, Mini Oynatıcı) + Kullanıcı Arayüzünü Özelleştir + Diğer Ayarlar + Diğer çeşitli ayarlar için tıklayın + Kaliteyi 45 ile 512 Kbps arasında otomatik olarak ayarlar. + 512 Kbps\'ye kadar en yüksek kaliteyi sunar + Ortalama internet hızları için kaliteyi 128 ile 256 Kbps arasında ayarlar. + Verileri tasarruf etmek için kaliteyi 45 ile 52 Kbps arasında sınırlar (Veri Tasarrufu) + İçe aktarılamadı + Metin Hizalama + Kart üzerindeki metnin konumunu ayarlayın + Gradyan Yoğunluğu + Köşe Yuvarlama + Profil Resmi + Profil Kartı + Profil Fotoğrafını Kaldır + Metni Göster + Profil kartında selamlama ve kullanıcı adı metnini göster + Yoldaş Bildirimleri · Yoldaş Kurulumu + Uygulamanın ve oynatıcının görünümünü özelleştirin + Cihazın renk temasını kullanır + Ses ve oynatma deneyiminizi özelleştirin + Şarkılardaki sessiz anları atla + Uygulama son kullanılanlar listesinden silinirse, müzik durur. + Yoldaş Bildirimleri + Yoldaş Bildirimleri + Akıllı Bildirimleri Etkinleştir + Loudly, tercihlerinize göre akıllı müzik önerileri sunar. + İzinler + Fiziksel Aktivite İzinleri + Bu izin, koşarken veya araba sürerken ilgili müzik önerileri sunmak için gereklidir. + İzin ver + İzin verildi + Öneri Türleri + Sürüş Algılama + Aracı kullandığınızda rota listeleri önerir. + Koşu/Yürüyüş Algılama + Egzersiz aktiviteleriniz için enerji listeleri önerir. + UYARI! Tüm indirmeler silinecektir. Onaylıyor musunuz? + Şu anda kullanılan Biçimlendirilmiş + “%1s” albümden devam ediyor mu? + Sanatçı “%1s” ile devam etmek ister misiniz? + Bu akışla devam etmek ister misiniz? + Müzik dinlemeye hazır mısınız? 🎧 + Son dinlediğiniz şarkıdan devam edin: %1s + Loudly\'yi açmak için dokunun! + Yolculuk başlıyor! 🚗 + “Yol Şarkıları” çalma listenizi çalmak ister misiniz? + Koşu zamanı! 👟 \ No newline at end of file diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml new file mode 100644 index 0000000..b389036 --- /dev/null +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -0,0 +1,542 @@ + + + Ласкаво просимо до Loudly + YouTube-Music завжди під рукою + Синхронізація з аккаунтом YouTube-Music + Відтворення без реклами + Безкоштовний додаток з відкритим кодом + Назад + Далі + + + Попередній + Головна + Навігатор + Музика + Виконавці + Альбоми + Плейлисти + Улюблена музика + Завантажена музика + Кешована музика + Локальна музика + + Фільтр контенту + + + + %d вибрано + %d вибрано + %d вибрано + %d вибрано + + + + Доброго ранку + Добридень + Добрий вечір + Надобраніч + Недавня активність + Історія + Ваша історія прослуховувань буде знаходиться тут + Статистика + Ваша статистика буде знаходиться тут + Настрій та жанри + Акаунт + Швидкий вибір + Послухайте кілька пісень, щоб створити ваш швидкий вибір + Забуті улюблені + Продовжуйте слухати + Ваші плейлисти з YouTube + Схожі на + Нові релізи альбомів + Немає підключення до Інтернету + Здається, у вас немає підключення до Інтернету. Будь ласка, перевірте свою мережу. + + + Сьогодні + Вчора + Цього тижня + Минулого тижня + Локальний + Онлайн + Локальна історія + Онлайн історія + + + Найпопулярніші пісні + Найпопулярніші виконавці + Найпопулярніші альбоми + + + Пошук + Пошук в Loudly + Пошук в бібліотеці… + Бібліотека + Вподобані + Завантажені + Всі + Композиції + Відео + Альбоми + Виконавці + Плейлисти + Плейлисти спільноти + Обрані плейлисти + Додано в закладки + Результатів не знайдено + Історія пошуку + Пропозиції + Результати пошуку + + + З вашої бібліотеки + + + Інші версії + + + Тут відображатиметься ваша бібліотека + Тут будуть відображатися пісні з вашої бібліотеки + Тут будуть відображатися виконавці з вашої бібліотеки + Тут будуть відображатися альбоми з вашої бібліотеки + Тут будуть відображатися ваші плейлисти + + + Улюблені треки + Завантажена музика + Мій топ музики + Плейлист порожній + Ви впевнені, що хочете видалити всі пісні з плейлиста «%s» зі сховища завантаженої музики? + Ви впевнені, що хочете видалити плейлист «%s»? + + Вимкнути + + + За весь час + За останні 24 години + За минулий тиждень + За минулий місяць + За минулий рік + + + Повторювати + Радіо + Перемішати + Скинути + + + Детальніше + Редагувати + Увімкнути радіо + Відтворити + Відтворити наступним + Додати в чергу + Додати в бібліотеку + Додати все до бібліотеки + Видалити з бібліотеки + Видалити все з бібліотеки + Завантажити + Завантаження + Видалити із завантажених + Імпортувати плейлист + Додати в плейлист + Перейти до виконавця + Перейти до альбому + Оновити + Поділитися + Видалити + Видалити з історії + Видалити з плейлиста + Експортувати як файл M3U + Видалити з черги + Видалити з кешу + Пошук в Інтернеті + Синхронізувати + Контроль аудіо + Темп і висота тону + + + Нещодавно додані + Нещодавно оновлені + Назва + Виконавець + Рік + Кількість треків + Тривалість + Кількість відтворень + Корист. порядок + + + Ідентифікатор медіа + Тип MIME + Кодеки + Бітрейт + Частота дискретизації + Гучність + Рівень гучності + Розмір файлу + Невідомо + Скопійовано + + Редагувати текст пісні + Пошук тексту пісні + Ви дійсно хочете видалити збережені тексти пісень для \"%s\"? + + Редагувати композицію + Назва композиції + Виконавці композиції + Вкажіть назву композиції + Вкажіть виконавців композиції + Зберегти + + Синхронізувати плейлист + Вибрати плейлист + Редагувати плейлист + Створити плейлист + Назва плейлиста + Вкажіть назву плейлиста + + Редагувати виконавця + Ім\'я виконавця + Вкажіть ім\'я виконавця + + Дублікати + Пропустити дублікати + Додати в будь-якому разі + Ця пісня вже у вашому плейлисті + %d пісень вже у вашому плейлисті + Примітка: Це дозволяє синхронізуватися з YouTube Music + + + + %d композиція + %d композиції + %d композицій + %d композицій + + + %d виконавець + %d виконавця + %d виконавців + %d виконавців + + + %d альбом + %d альбоми + %d альбомів + %d альбомів + + + %d плейлист + %d плейлисти + %d плейлистів + %d плейлистів + + + %d тиждень + %d тижні + %d тижнів + %d тижнів + + + %d місяць + %d місяці + %d місяців + %d місяців + + + %d рік + %d роки + %d років + %d років + + + + Плейлист імпортовано + «%s» видалена з плейлиста + Плейлист синхронізовано + Скасувати + + + Зараз граю + Текст пісні не знайдено + Таймер сну + Кінець пісні + + %d хвилина + %d хвилини + %d хвилин + %d хвилин + + Немає доступних потоків + Відсутнє підключення до мережі + Тайм-аут + Невідома помилка + + 5 секунд тому + 5 секунд вперед + + + Поставити «Подобається» + Відзначити все як «Подобається» + Прибрати «Подобається» + Прибрати всі позначки «Подобається» + Увімкнути перемішування + Вимкнути перемішування + Вимкнути повторення + Повторити поточну пісню + Повторити чергу + + + Всі композиції + Шукані композиції + + + Музичний плеєр + + + Сповіщення + Запобігти закриттю додатку системою + Використовуйте сповіщення у фоновому режимі, щоб запобігти випадковому закриттю Loudly системою. + Требуется дозвіл на сповіщення + + + Параметри + Зовнішній вигляд + Тема + Проксі + Увімкнути динамічну тему + Стиль гравця + Темна тема + Увімк. + Вимк. + Використовувати налаштування системи + Дизайн програми + Режим чистого чорного кольору + Слухати в YouTube Music + Відвідавши Loudly + Виконавець + Слухати в Loudly + Налаштування вкладок навігації + Плеєр + Вирівнювання тексту плеєра + Розташування тексту пісні + Збоку + Ліворуч + По центру + Праворуч + Стиль повзунка + Стандартний + Радіус заокруглення обкладинки + Фільтр контенту + Хвилястий + Різне + Основні + Налаштування автоматичних плейлистів + Показувати автоматичний плейлист «Вподобані» + Показувати автоматичний плейлист «Завантаження» + Показувати автоматичний плейлист «Топ» + Показувати автоматичний плейлист «Кешовані» + Показувати автоматичний плейлист «Локальні» + Проведіть по пісні вправо для наступного відтворення або вліво для додавання в чергу + Основна вкладка навігації + Вкладка бібліотеки за замовчуванням + Включити свайп по обкладинці для перемикання композиції + Стиль фону плеєра + Залежить від теми + Градієнт + Розмиття + Розмиття (рух) + Розмір осередків + Показувати назви кнопок навігаційної панелі + Історія змін + Останні зміни не знайдено + Останні зміни: + Закрити + Доступна нова версія + Привіт знову + Пожертвувати + Офіційний канал + Наш сайт + Офіційний сайт ❯ + Вихідний код + Отримайте доступ до вихідного коду програми ❯ + Проблеми + Є помилки чи проблеми? Повідомте про це тут ❯ + Допомога + Якщо ви хочете внести вклад, натисніть тут ❯ + Розробник ❯ + + + + Вибрати все + Отримання всіх плейлистів… + Імпортувати вибрані елементи + Щось пішло не так. Ми також в недоумінні, як і ви. + Ну, це незручно. Ми також не знаємо, що сталося. + Інструкції щодо отримання необхідних облікових даних + 3. Переконайтеся, що ви натиснули "Додати", після того як ввели локальний шлях вище. + 5. Прийміть умови обслуговування та натисніть "Зберегти". + Вас перенаправлять на нову сторінку. Натисніть на Налаштування, потім скопіюйте Client ID та Client Secret, і вставте їх у текстові поля нижче в цьому додатку. + Увійти з обліковими даними Spotify API + Авторизуватися та продовжити + Код авторизації + Аутентифікувати + Секрет клієнта + ID клієнта + Імпорт в процесі… + Не закривайте додаток і не повертайтеся назад. Ця операція не працює у фоновому режимі, тому залишайтеся на місці, поки вона не завершиться! +НЕ ПАНІКУЙТЕ, ЯКЩО ПОКАЗУЄТЬСЯ, ЩО ВОНА ЗАСТРЯЛА; іноді отримання може зайняти деякий час. + Виберіть місце призначення для "Улюбленых пісень" + Куди повинні бути імпортовані сподобані пісні? + Новий плейлист з назвою "Улюблена музика" + В стандартні "Улюблена музика" + 2. Введіть необхідні дані та використовуйте http://127.0.0.1:45454 в якості URI перенаправлення. + 4. Переключіть Web API. Установіть прапорець, він має бути відмічений. + Тепер натисніть кнопку "Авторизувати та продовжити" нижче і увійдіть у свій обліковий запис, з якого ви хочете імпортувати дані, потім авторизуйте і продовжте, що перенаправить вас на сайт, адреса якого починається з http://127.0.0.1:45454/?code= +Вставте весь цей URL у текстове поле нижче, яке називається "Код авторизації", і натисніть кнопку "Аутентифікувати". + 1. Відвідайте панель розробників Spotify і натисніть на "Створити додаток". + + Контент + Логін + Не авторизовано + Показати токен + Натисніть ще раз, щоб скопіювати або відредагувати + Це розширений спосіб входу в систему. Як альтернативу веб-порталу, Ви можете безпосередньо ввести або оновити свій логін тут. Наприклад, це може прискорити вхід на декількох пристроях. Будь ласка, зверніть увагу, що будь-які неприпустимі формати токенів, які програма не зможе проаналізувати, не будуть прийняті + Імпорт з Spotify + Автоматично завантажувати вподобане композиції + Автоматична синхронізація з вашим аккаунтом + Тільки при використанні Wi-Fi + Мова контенту + Країна контенту + Використовувати налаштування системи + Увімкнути проксі + Тип проксі + URL проксі + Перезапуск програми + Мова додатки + Вибрати мову додатки + Не вдалося знайти мовні налаштування програми, будь ласка, налаштуйте їх вручну + Відкривати підтримувану посилання за замовчуванням + Налаштування підтримуваних посилань + Не вдалося знайти Налаштування підтримуваних посилань, будь ласка, налаштуйте їх вручну + + Плеєр та аудіо + Якість аудіо + Авто + Максимальна + Висока + Низька + Мінімальна тривалість відтворення + Мінімальна кількість часу, протягом якого пісня повинна відтворюватися, перш ніж вона буде вважатися "відтвореною" + Черга + Постійна черга + Відновлювати останню чергу після запуску програми + Автозавантаження більшої кількості пісень + Автоматично додавати більше пісень при досягненні кінця черги, якщо це можливо + Пропуск тиші в композиціях + Нормалізація аудіо + Продовжити відтворення при підключенні bluetooth пристрою + Зупиняти відтворення при мінімальному значенні гучності + Додавати програні пісні в історію YouTube-Music + Автоперехід до наступної пісні при помилці + Забезпечити безперервне відтворення + Зупиняти музику при очищенні завдань + Еквалайзер + Текст пісні + Налаштування текстів + Налаштування локального плеєра + + Сховище + Кеш + Кеш зображень + Кеш аудіо + Максимальний розмір кешу + Розвантажити аудіопотік + Для відтворення звуку використовуйте параметр "розвантажити аудіопотік". Вимкнення цього параметра може призвести до збільшення споживання енергії, але може бути корисним, якщо у вас виникнуть проблеми з відтворенням звуку або подальшою обробкою + Необмежено + Очистити всі завантаження + Макс. розмір кешу зображень + Очистити кеш зображень + Макс. розмір кешу аудіо + Очистити кеш аудіо + використано + Ви впевнені, що хочете очистити всі кешовані дані? + Ви впевнені, що хочете очистити всі завантаження? + Ви впевнені, що хочете очистити всі кешовані пісні? + Ви впевнені, що хочете очистити всі кешовані зображення? + Ви впевнені, що хочете очистити всі кешовані моделі перекладу? + + Конфіденційність + Історія прослуховувань + Призупинити історію прослуховування + Очистити історію прослуховування + Ви впевнені, що хочете очистити всю історію прослуховування? + Призупинити історію пошуку + Історія пошуку + Очистити історію пошуку + Ви впевнені, що хочете очистити всю історію пошуку? + Вимкнути знімок екрана + Якщо ця опція увімкнена, знімки екрана та відображення програми в списку останніх відключаються. + Використовувати логін для перегляду контента + Це може вплинути на те, який контент ви бачете, і, наприклад, показувати альбоми тільки преміум-класа, якщо ви війшли в систему з преміум-акаунтом + Увімкнути провайдера текстів LrcLib + Увімкнути провайдера текстів KuGou + Поставшик текстів за замовчуванням + Багаторядкові тексти пісень + Розглядати всі рядки між точками синхронізації як один ліричний текст + Прибрати прогалини навколо тексту пісні + Розмір шрифту тексту + Приховувати контент із нецензурною лексикою + Включити повідомлення + + Резервне копіювання + Створити резервну копію + Відновити з резервної копії + Імпортований плейлист + Резервну копію створено успішно + Не вдалося створити резервну копію + Не вдалося відновити з резервної копії + + Інтеграція з Discord + Loudly використовує бібліотеку KizzyRPC для встановлення статусу вашого облікового запису Discord. Це передбачає використання підключення через Discord Gateway, що може вважатися порушенням умов використання Discord. Однак, наразі немає відомих випадків блокування облікових записів користувачів з цієї причини. Використовуйте на свій страх і ризик.\n\nLoudly буде зчитувати тільки ваш токен, а все інше зберігається локально. + Закрити + Налаштування + Попередній перегляд + Помилка входу + Вийти + Увімкнути Rich Presenсe + + Про програму + Версія застосунку + + Доступна нова версія + Моделі перекладу + Очистити моделі перекладу + Тривалість історії + + 1 секунда + %d секунд + + + + Локальний сканер + + Автосканування + Автоматично сканувати пісні з пристрою при відкритті додатку + + + Додаткові налаштування сканера + Суворі назви файлів + При ввімкненні розширення файлів НЕ ігноруються. Наприклад: «Song.ogg» та «Song.flac» вважатимуться різними піснями + + Налаштування чутливості + Збіг за назвою + Збіг за назвою та виконавцями + Збіг за назвою, виконавцями та альбомами + Папки + Об\'єднання підпапок + Вимкніть, щоб зберегти структуру файлів як на диску + Локальні пісні успішно синхронізовано + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml deleted file mode 100644 index 328a387..0000000 --- a/app/src/main/res/values-uk/strings.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - Оберіть нотатку - AirNote - Нотатки - Немає створених нотаток. - Нотаток не знайдено. - Сітка - Стовпці - Назва - Символів - Системна тема - Темна тема - Відображення - База даних - Ще не підтримується - Оцінити застосунок - Підтримайте мене :) - Відновити - Нотатки - - Markdown - Інструменти - Віджети - Хмара - Шрифт • Мова - Версія • Розробники • Ліцензія - Історія • Повернення - Створено • Видалити всі - Шрифт - Підтримка зображень - Нестабільно - Захист змісту - Перемикання між темною та світлою кольоровою схемою - Налаштувати радіус заокруглення кутів - Екстремальна оптимізація батареї - Тип збірки - Список меценатів - Запит нової функції - Закріплено - Інші - Скопіювати - Зашифрувати базу даних - Нова нотатка - Сховище - продовжити - Змінити мову застосунка - Час створення - Опис - Обрати всі - Подробиці - Слів - Динамічні кольори - Видалити - Пошук - Кольори Amoled - Останній випуск - Налаштування - Резервне копіювання - Про застосунок - Версія - Пожертвуйте - Мова - Тема • Розмір • Шрифт - NextCloud • Локально • Шифрування - Підтримка • Зображення - Проявіть свою любов - Радіус - Оберіть радіус - Історія - Віджети • Markdown - Мінімалістичний режим - Екстремальні кольори Amoled - Приватність - Криптовалюта - Адаптовувавати тему застосунка згідно з налаштуваннями системи - Підлаштовувати кольори, щоб вони відповідали вашим шпалерам - Використовувати чорний колір для тла - Оберіть стиль відображення - Приховати верхні кнопки з екрана редагування - Зберегти всі нотатки у файл - Відновити нотатки з файлу - Використовувати Markdown у нотатках - Приховувати застосунок у нещодавніх та забороняти знімки екрана - Вихідний код - Ел. пошта - Discord - Неправильний пароль або база даних не зашифрована - Зашифровувати базу даних під час резервного копіювання - Введіть пароль - Недійсний ввід - Сортувати за зростанням - Сортувати за спаданням - Сортувати нотатки за часом їх створення - Не підтримуваний розмір зображення (до 15 МБ) - Увімкнути зашифроване сховище для нотаток - Мова системи - Поведінка - Надавати пріоритет режиму редагування - Увійдіть у режим редагування наявних нотаток - Недійсний пароль - 1. Прийняття умов - 2. Ліцензія - 3. Використання застосунку - 4. Інтелектуальна власність - 5. Відмова від гарантій - Easy Notes надається як є, без жодних гарантій. Ми не гарантуємо, що застосунок буде без помилок, безпечним або безперебійним. - 6. Обмеження відповідальності - 8. Зміни до умов - 9. Зв\'язок з нами - Погоджуюсь - З будь-яких питань щодо цих умов надання послуг або Easy Notes, будь ласка, зв\'яжіться з нами за адресою - Дата набуття чинності - Умови надання послуг - Не вдалося імпортувати - Успішно імпортовано - Імпортуйте інші текстові файли як нотатки - Переклад - Пожертвування - Внесок - Синхронізація з галереєю - Показувати лише заголовок - Зображення видалятимуться зі сховища застосунку, коли їх видалено з галереї - Показувати лише заголовок у попередньому перегляді - Імпорт - Easy Notes розповсюджується на умовах ліцензії **GNU General Public License (GPL) 3 версії**. Це означає, що: -\n -\n- Ви можете вільно використовувати, змінювати та поширювати застосунок. -\n- Якщо ви поширюєте застосунок або будь-які модифіковані версії, ви також повинні поширювати його джерельний код та залишити ліцензію GPL. - Натискаючи кнопку «Погоджуюсь» та використовуючи застосунок Easy Notes, ви приймаєте ці умови надання послуг. Якщо ви не згодні, не використовуйте цей застосунок. - Ви погоджуєтеся використовувати Easy Notes тільки в законних цілях та відповідно до цих умов. Ви **не повинні**: -\n -\n- _Використовувати застосунок з будь-якою незаконною метою_ -\n- _Порушувати права інших_ -\n- _Порушувати функціональність або безпеку застосунку_ - Всі права інтелектуальної власності на Easy Notes належать нам або нашим ліцензіарам. Використання застосунку не надає вам права власності на будь-які права інтелектуальної власності. - Ми не несемо відповідальності за будь-які збитки, пов\'язані з використанням Easy Notes, включаючи непрямі, випадкові або побічні збитки. - 7. Припинення - Ми можемо припинити або призупинити ваш доступ до Easy Notes, якщо ви порушите ці умови. Після припинення ваше право на використання застосунку буде негайно припинено. - Ми можемо час від часу оновлювати ці умови надання послуг. Ваше подальше використання Easy Notes після будь-яких змін означає, що ви приймаєте переглянуті умови. - Easy Notes не збирає жодних персональних даних. Для отримання додаткової інформації, зверніться до нашої політики конфіденційності, доступної за посиланням - 10. Політика конфіденційності - Не вдалося імпортувати деякі файли - Використовуйте моноширинний шрифт під час редагування примітки - Шрифт MPV - Негайно заблокувати - Ви впевнені, що бажаєте видалити вибрані нотатки - Миттєво блокувати після відновлення програми - Блокування програми за допомогою пароля - Заблоковано - Введіть шаблон - Так - Скасувати - Блокування програми - Налаштувати пароль - Введіть пароль - Pin (6 номерів) - Пароль - Відбиток пальця - Потрібна автентифікація - UID - Запитати відбиток пальця на початку - Запитайте пароль на початку - Візерунок - Запитайте шаблон на початку - Шаблон налаштування - "Вимкніть гортання для попереднього перегляду в режимі редагування " - Зробіть так, щоб панель пошуку відповідала ширині нотаток - Вимкнути свайп - Масштабувати ширину панелі пошуку - diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml new file mode 100644 index 0000000..af73a3d --- /dev/null +++ b/app/src/main/res/values-v21/styles.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v31/colors.xml b/app/src/main/res/values-v31/colors.xml deleted file mode 100644 index 374b552..0000000 --- a/app/src/main/res/values-v31/colors.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - @android:color/system_neutral2_900 - @android:color/system_accent1_200 - @color/splash_bg_light - #FFFFFF - #121212 - \ No newline at end of file diff --git a/app/src/main/res/values-v31/styles.xml b/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..b705286 --- /dev/null +++ b/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..5df12fb --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v33/themes.xml b/app/src/main/res/values-v33/themes.xml deleted file mode 100644 index c842360..0000000 --- a/app/src/main/res/values-v33/themes.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 5e6e84e..cc8357e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1,102 +1,253 @@ - Không có ghi chú được tạo. - Hiển thị dạng cột - Hiển thị dạng lưới - Từ - Ký tự - Tên - Mô tả - Xóa - Chủ đề hệ thống + + Trang chủ + Bài hát + Nghệ sĩ + Album + Danh sách phát + + + Đã chọn %d + + + Lịch sử + Thống kê + Tâm trạng và thể loại + Tài Khoản + Lựa chọn nhanh + Hãy nghe các bài hát để tạo danh sách chọn nhanh của bạn + Các album mới phát hành + + Hôm nay + Hôm qua + Tuần này + Tuần trước + + Bài hát được phát nhiều nhất + Nghệ sĩ được phát nhiều nhất + Album được phát nhiều nhất + + Tìm kiếm + Tìm kiếm trên Loudly + Tìm kiếm trong thư viện… + Thư viện + Đã thích + Đã tải xuống + Tất cả + Bài hát + Video + Album + Nghệ sĩ + Danh sách phát + Danh sách phát cộng đồng + Danh sách phát nổi bật + Đã đánh dấu + Không tìm thấy kết quả nào + + Từ thư viện của bạn + + Bài hát được yêu thích + Bài hát được tải xuống + Danh sách phát đang trống + + Thử lại + Đài phát + Trộn bài + Cài lại + + Chi tiết + Chỉnh sửa + Bắt đầu đài phát + Phát + Phát bài tiếp theo + Thêm vào hàng chờ + Thêm vào thư viện + Xoá khỏi thư viện + Tải xuống + Đang tải xuống + Xoá mục đã tải xuống + Nhập danh sách phát + Thêm vào danh sách phát + Xem nghệ sĩ + Xem album + Làm mới + Chia sẻ + Xoá + Xoá khỏi lịch sử + Tìm kiếm trực tuyến + Đồng bộ + Nâng cao + + Ngày thêm vào + Tên + Nghệ sĩ + Năm + Số lượng bài hát + Độ dài + Thời gian phát + Tuỳ chỉnh bộ lọc + + ID phương tiện + Định dạng + Bộ giải mã + Tốc độ bit + Tỷ lệ mẫu + Độ ồn + Âm lượng + Kích thước tệp tin + Không rõ + Đã sao chép vào clipboard + Sửa lời bài hát + Tìm kiếm lời bài hát + Sửa bài hát + Tên bài hát + Nghệ sĩ bài hát + Tên bài hát không được để trống. + Nghệ sĩ bài hát không được để trống. + Lưu + Chọn danh sách phát + Chỉnh sửa danh sách phát + Tạo danh sách phát + Tên danh sách phát + Tên danh sách phát không được để trống. + Chỉnh sửa nghệ sĩ + Tên nghệ sĩ + Tên nghệ sĩ không được để trống. + + + %d bài hát + + + %d nghệ sĩ + + + %d album + + + %d danh sách phát + + + %d tuần + + + %d tháng + + + %d năm + + + Danh sách phát đã được nhập vào + Đã xoá \"%s\" khỏi danh sách phát + Danh sách phát được đồng bộ + Hoàn tác + + Không tìm thấy lời bài hát + Hẹn giờ ngủ + Kết thúc bài hát + + %d phút + + Không có luồng nào khả dụng + Không có kết nối mạng + Hết giờ + Lỗi không xác định + + Thích + Bỏ thích + Bật trộn bài + Tắt trộn bài + Tắt lặp lại + Lặp lại bài hát này + Lặp lại hàng đợi + + Tất cả bài hát + Những bài hát được tìm kiếm + + Trình phát nhạc + + Cài đặt + Hiển thị + Bật chủ đề theo màu sắc Chủ đề tối - Màu sắc năng động - Màu Amoled - Cơ sở dữ liệu + Bật + Tắt + Theo hệ thống + Tối hoàn toàn + Tab mặc định + Tùy chỉnh thanh tab điều hướng + Vị trí lời bài hát + Trái + Giữa + Phải + Nội dung + Đăng nhập + Nội dung ngôn ngữ mặc định + Nội dung quốc gia mặc định + Mặc định hệ thống + Bật Proxy + Loại Proxy + Liên kết Proxy + Khởi động lại để áp dụng + Trình phát và âm thanh + Chất lượng âm thanh + Tự động + Cao + Thấp + Hàng đợi liên tục + Bỏ qua khoảng lặng + Chuẩn hoá âm lượng + Bộ chỉnh âm + Bộ nhớ + Bộ nhớ đệm + Bộ nhớ đệm hình ảnh + Bộ nhớ đệm bài hát + Kích thước bộ nhớ đệm tối đa + Không giới hạn + Xoá tất cả các mục đã tải xuống + Kích thước bộ nhớ đệm hình ảnh tối đa + Xóa bộ nhớ đệm hình ảnh + Kích thước bộ nhớ đệm bài hát tối đa + Xóa bộ nhớ đệm bài hát + Đã sử dụng + Riêng tư + Tạm dừng lịch sử nghe + Xóa lịch sử nghe + Bạn có chắc muốn xoá tất cả lịch sử nghe không? + Tạm dừng lịch sử tìm kiếm + Xóa lịch sử tìm kiếm + Bạn có chắc muốn xoá tất cả lịch sử tìm kiếm? + Bật nhà cung cấp lời bài hát KuGou + Sao lưu và khôi phục Sao lưu - Chưa được hỗ trợ - Giới thiệu - Phiên bản - Phát hành mới nhất - Sao cho Dự án - Quyên tặng cho tôi :) Khôi phục - Công cụ - Lịch sử - Tiện ích - Đám mây - Phông chữ • Ngôn ngữ - Chủ đề • Kích cỡ • Phông chữ - NextCloud • Cục bộ • Mã hóa - Phiên bản • Nhà phát triển • Giấy phép - Tiện ích • Markdown - Lịch sử • Hoàn nguyên - Đã tạo • Xóa tất cả - Hỗ trợ • Hình ảnh - Thể hiện tình yêu của bạn - Phông chữ - Bán kính - Chọn bán kính - Chế độ tối giản - Màu sắc Amoled cực chất - Riêng tư - Điều chỉnh màu sắc để phù hợp với hình nền của bạn - Sử dụng màu đen làm nền - Tối ưu hóa pin cực cao - Tùy chỉnh bán kính đường viền - Chọn kiểu xem - Bật markdown trong ghi chú - Ẩn màn hình trong phần gần đây và tắt ảnh chụp màn hình - Loại bản dựng - Mã nguồn - Danh sách người ủng hộ - Yêu cầu tính năng - Đã ghim - Khác - Sao chép - Mã hóa cơ sở dữ liệu - Mã hóa cơ sở dữ liệu trong quá trình sao lưu - Nhập mật khẩu - Mật khẩu không hợp lệ hoặc cơ sở dữ liệu không được mã hóa - Đầu vào không hợp lệ - Sắp xếp tăng dần - Kích thước hình ảnh không được hỗ trợ (tối đa 15mb) - Tiền điện tử - Thay đổi ngôn ngữ ứng dụng - Ngôn ngữ hệ thống - Ưu tiên chế độ chỉnh sửa - Mật khẩu không hợp lệ - Ghi chú mới - Ghi chú - Không tìm thấy ghi chú nào. - Thời gian đã tạo - Chọn tất cả - Thông tin - Tìm kiếm - Thiết đặt - Hiển thị - Ghi chú - Hỗ trợ - Màu sắc & Kiểu dáng - Ngôn ngữ - Điều chỉnh chủ đề ứng dụng dựa trên thiết đặt hệ thống - Xóa các nút trên cùng khỏi màn hình chỉnh sửa - Hỗ trợ hình ảnh - Bảo vệ màn hình - Không ổn định - Chuyển đổi giữa cách phối màu tối và sáng - Lưu trữ tất cả các ghi chú vào tệp tin - Khôi phục ghi chú từ tệp tin - Sắp xếp ghi chú theo thời gian tạo - Chọn Ghi chú - Sắp xếp giảm dần - Bật kho ghi chú được mã hóa - tiếp tục - Vào chế độ chỉnh sửa trên ghi chú hiện có - Hành vi - Vault - AirNote - Markdown - Email - Discord + Đã nhập danh sách phát + Đã tạo bản sao lưu thành công + Không thể tạo bản sao lưu + Không thể khôi phục bản sao lưu + Giới thiệu + Phiên bản ứng dụng + Phiên bản mới có sẵn + Mô hình dịch thuật + Xoá mô hình dịch thuật + Xoá khỏi danh sách phát + Bản sao + Bỏ qua các bản sao + Vẫn cứ thêm vào + Bài hát đã có trong danh sách phát của bạn + %d bài hát đã có trong danh sách phát của bạn + Bạn có thực sự muốn xóa tất cả \"%s\" bài hát trong danh sách phát khỏi bộ nhớ Bài hát được tải xuống không? + Bạn có thực sự muốn xóa \"%s\" danh sách phát không? + Chưa đăng nhập + Bật nhà cung cấp lời bài hát LrcLib + Tích hợp Discord + Bỏ qua + Tùy chọn + Xem trước + Đăng nhập không thành công + Đăng xuất + Kích hoạt Rich Presence + Cạnh bên + Căn chỉnh văn bản của trình phát + Ẩn nội dung phản cảm \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dee78b1..337657e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,278 +1,626 @@ - AirNote - 笔记 - 新笔记 - 没有已创建的笔记。 - 未找到笔记。 - 创建时间 - 网格视图 - 列表视图 - 字数 - 字符数 - 名称 - 描述 - 全选 - 删除 - 信息 + + 欢迎使用 Loudly + 让我们开始个性化您的音乐体验吧。 + YouTube Music 触手可及 + YT Music 账号同步 + 播放无广告 + 离线音乐体验 + 支持 Android Auto + 支持 Discord 状态 + 完全开源 + 选择您的播放器界面 + 选择应用内的播放器视图。您可以稍后在“设置 > 外观”中更改此设置。 + 设置完成! + 您已准备好探索 Loudly。 + 完成 + 返回 + 下一步 + 上一步 + + 主页 + 发现 + 歌曲 + 艺术家 + 专辑 + 播放列表 + + 喜欢的音乐 + 喜欢 + 离线音乐 + 我的热门音乐 + 已缓存的音乐 + 本地音乐 + + 内容筛选 + + 所有时间 + 过去 24 小时 + 过去一周 + 过去一月 + 过去一年 + + + 已选择 %d 项 + + + 早上好 + 下午好 + 晚上好 + 晚安 + 最近活动 + 历史记录 + 您的历史记录将显示在这里 + 您的统计数据将显示在这里 + 统计 + 显示更少 + 显示更多 + 心情与流派 + 账号 + 快速选择 + 收听歌曲以生成您的快速选择 + 被遗忘的收藏 + 继续聆听 + 您的 YouTube 播放列表 + 类似于 + 新发行的专辑 + 无网络连接 + 您似乎没有网络连接。请检查您的网络。 + 为您精选 + 今日为您推荐 + + 今天 + 昨天 + 本周 + 上周 + 本地 + 云端 + 本地历史记录 + 云端历史记录 + + 播放最多的歌曲 + 播放最多的艺术家 + 播放最多的专辑 + 搜索 - 设置 - 显示 - 系统主题 + 在 Loudly 中搜索 + 搜索音乐库… + 搜索历史 + 建议 + 搜索结果 + 音乐库 + 已喜欢 + 已下载 + 全部 + 歌曲 + 视频 + 专辑 + 艺术家 + 播放列表 + 社区播放列表 + 精选播放列表 + 已收藏 + 未找到结果 + + 您的音乐库将显示在这里 + 音乐库中的歌曲将显示在这里 + 音乐库中的艺术家将显示在这里 + 音乐库中的专辑将显示在这里 + 您的播放列表将显示在这里 + + 来自您的音乐库 + + 其他版本 + 收藏 + 热门歌曲 + + 喜欢的歌曲 + 已下载的歌曲 + 播放列表为空 + 您真的要从已下载歌曲存储中移除“%s”播放列表中的所有歌曲吗? + 您真的要删除播放列表“%s”吗? + + 禁用 + + 重试 + 电台 + 随机播放 + 重置 + + 详情 + 编辑 + 开始电台 + 播放 + 下一首播放 + 添加到队列 + 添加到音乐库 + 全部添加到音乐库 + 从音乐库移除 + 全部从音乐库移除 + 下载 + 下载中 + 移除下载 + 导入播放列表 + 添加到播放列表 + 查看艺术家 + 查看专辑 + 重新获取 + 分享 + 删除 + 从历史记录中移除 + 从播放列表中移除 + 从队列中移除 + 从缓存中移除 + 导出为 M3U 文件 + 在线搜索 + 同步 + 高级 + 速度和音高 + + 添加日期 + 更新日期 + 名称 + 艺术家 + 年份 + 歌曲数量 + 时长 + 播放时间 + 自定义排序 + + 媒体 ID + MIME 类型 + 编解码器 + 比特率 + 采样率 + 响度 + 音量 + 文件大小 + 未知 + 已复制到剪贴板 + + 编辑歌词 + 搜索歌词 + 您真的要删除“%s”的已存歌词吗? + + 编辑歌曲 + 歌曲标题 + 歌曲艺术家 + 歌曲标题不能为空。 + 歌曲艺术家不能为空。 + 保存 + + 同步播放列表 + 选择播放列表 + 编辑播放列表 + 创建播放列表 + 播放列表名称 + 播放列表名称不能为空。 + + 编辑艺术家 + 艺术家名称 + 艺术家名称不能为空。 + + 重复项 + 跳过重复项 + 仍然添加 + 这首歌曲已在您的播放列表中 + 您的播放列表中已有 %d 首重复歌曲 + 注意:这允许与 YouTube Music 同步 + + + %d 首歌曲 + + + %d 位艺术家 + + + %d 张专辑 + + + %d 个播放列表 + + + %d 周 + + + %d 个月 + + + %d 年 + + + 已导入播放列表 + 已从播放列表中移除“%s” + 已同步播放列表 + 撤销 + + 正在播放 + 未找到歌词 + 睡眠定时器 + 歌曲结束时 + + %d 分钟 + + 无可用流 + 无网络连接 + 超时 + 未知错误 + 菜单 + 分享列表 + + 后退 5 秒 + 快进 5 秒 + + 喜欢 + 全部喜欢 + 取消喜欢 + 全部取消喜欢 + 随机播放已开启 + 随机播放已关闭 + 循环模式已关闭 + 单曲循环 + 列表循环 + + 所有歌曲 + 搜索到的歌曲 + + 音乐播放器 + + 通知 + 防止应用被系统终止 + 使用前台通知,防止 Loudly 被系统随机关闭。 + 需要通知权限 + + 设置 + 外观 + 主题 + 代理 + 启用动态主题 深色主题 - 动态颜色 - Amoled 颜色 - 数据库 + 开启 + 关闭 + 跟随系统 + 纯黑 + 应用设计 + 应用界面 1.0 + 应用界面 1.5 + 在 YouTube Music 上收听 + 访问 Loudly + 艺术家 + 收听 Loudly + 启用滑动缩略图切换歌曲 + 自定义导航标签页 + 播放器 + 播放器文本对齐 + 歌词文本位置 + 侧边 + + + 播放器样式 + 旧版播放器界面 + 播放器界面 1.0 + 播放器界面 2.0 + 跟随主题 + 渐变 + 模糊 + 模糊(动态) + 播放器背景样式 + + 滑块样式 + 默认 + 波浪形 + 组合 + 配置缩略图圆角半径 + 显示内容筛选器 + 杂项 + 主要 + 自动播放列表自定义 + 显示“已喜欢”自动播放列表 + 显示“已下载”自动播放列表 + 显示“热门”自动播放列表 + 显示“已缓存”自动播放列表 + 显示“本地”自动播放列表 + 向右滑动歌曲播放下一首,向左滑动添加到队列 + 默认打开的标签页 + 默认音乐库筛选 + 网格单元格大小 + 显示底部导航栏标签 + + + 更新日志 + 未找到最近的更改 + 最近更改: + 关闭 + 有新版本可用 + 再次问好 + 捐赠 + 官方频道 + 网站 + 官方网站 ❯ + 源代码 + 访问应用的源代码 + 问题反馈 + 报告 Bug 及其他 + 帮助 + 如果您想做出贡献,请点击此处 ❯ + 开发者 | @RRechz + + 内容 + 登录 + 登录您的 YT Music 播放列表等 + 点击以显示令牌 + 再次点击以复制或编辑 + 这是一种高级登录方法。作为 Web 门户的替代方案,您可以直接在此处输入或更新您的登录令牌。例如,这可以加快在多个设备上的登录速度。请注意,应用无法解析的任何无效令牌格式都将不被接受。 + 从 Spotify 导入 + Spotify + Discord + 自动下载您喜欢的歌曲 + 与您的 YouTube Music 账号自动同步 + 将您的播放列表等与您的账号同步 + 仅 Wi-Fi + 未登录 + 默认内容语言 + 默认内容国家 + 系统默认 + 启用代理 + 代理类型 + 代理 URL + 重启后生效 + 应用语言 + 配置应用语言 + 找不到应用语言设置,请手动配置 + 默认打开支持的链接 + 配置支持的链接 + 找不到支持的链接设置,请手动配置 + + 播放器与音频 + 音质 + 最高 + 自动 + + + 最短播放时长 + 一首歌曲必须播放的最短时间,才会被计为“已播放” + 队列 + 持久队列 + 应用启动时恢复您上次的队列 + 自动加载更多歌曲 + 当到达队列末尾时,如果可能,自动添加更多歌曲 + 跳过静音 + 音量标准化 + 连接蓝牙设备时继续播放 + 当音量最小时停止播放 + 将播放的歌曲添加到 YouTube Music 历史记录 + 发生错误时自动跳到下一首 + 确保您的连续播放体验 + 清除任务时停止音乐 + 均衡器 + 歌词 + 歌词 + 本地播放器设置 + 存储 + 缓存 + 图片缓存 + 歌曲缓存 + 最大缓存大小 + 无限制 + 启用音频硬件加速 + 使用硬件加速音频路径进行播放。禁用此选项可能会增加功耗,但如果您在音频播放或后处理方面遇到问题,可能会有所帮助。 + 清除所有下载 + 最大图片缓存大小 + 清除图片缓存 + 最大歌曲缓存大小 + 清除歌曲缓存 + 已用 + 您确定要清除所有缓存吗? + 您确定要清除所有下载吗? + 您确定要清除所有缓存的歌曲吗? + 您确定要清除所有缓存的图片吗? + 您确定要清除所有缓存的翻译模型吗? + + 隐私 + 收听历史 + 暂停收听历史 + 清除收听历史 + 您确定要清除所有收听历史吗? + 暂停搜索历史 + 清除搜索历史 + 您确定要清除所有搜索历史吗? + 搜索历史 + 使用登录信息浏览内容 + 这可能会影响您看到的内容,例如,如果您使用高级帐户登录,则会显示仅限高级会员的专辑。 + 禁止截图 + 开启此选项后,将禁用截图和最近任务中的应用预览。 + 启用 LrcLib 歌词源 + 启用酷狗歌词源 + 默认歌词源 + 读取多行歌词 + 将同步点之间的所有行视为一个歌词文本 + 移除歌词周围的空格 + 字体大小 + 隐藏露骨内容 + 启用通知 + + 备份与恢复 备份 - 暂不支持 - 关于 - 版本 - 最新版本 - 为项目加星 - 为我捐赠 :) 恢复 - 笔记 - 支持 - - 语言 - Markdown - 工具 - 历史记录 - 小组件 - 主题 • 大小 • 字体 - 云服务 - 字体 • 语言 - NextCloud • 本地 • 加密 - 版本 • 开发者 • 许可证 - 小组件 • Markdown - 历史记录 • 还原 - 已创建 • 全部删除 - 支持 • 图片 - 表达你的喜爱 - 字体 - 圆角 - 选择圆角大小 - 极简模式 - 极致 Amoled 颜色 - 隐私 - 屏幕保护 - 图片支持 - 不稳定功能 - 加密货币 - 根据系统设置调整应用主题 - 在深色和浅色配色方案之间切换 - 调整颜色以匹配您的壁纸 - 使用黑色作为背景色 - 极致电池优化 - 自定义边框圆角 - 选择视图样式 - 从编辑屏幕移除顶部按钮 - 将所有笔记存储到文件 - 从备份文件恢复笔记 - 在笔记中启用 Markdown - 在最近任务中隐藏屏幕并禁用屏幕截图 - 构建类型 - 源代码 - 支持者列表 - 电子邮件 - Discord - 错误报告及更多 - 已置顶 - 其他 - 复制 - 加密数据库 - 在备份期间加密数据库 - 输入密码 - 密码无效或数据库未加密 - 无效输入 - 升序排序 - 降序排序 - 按创建时间对笔记进行排序 - 选择笔记 - 不支持的图片大小(最大15MB) - 保险库 - 启用加密笔记保险库 - 继续 - 更改应用语言 - 系统语言 - 行为 - 优先进入编辑模式 - 打开现有笔记时进入编辑模式 - 密码无效 - 捐赠 - 翻译 - 贡献 - 当图片从图库中删除时,同时从应用存储中删除 - 图库同步 - 仅显示标题 - 在预览中仅显示标题 - 1. 接受条款 - 按下“同意”并使用 Easy Notes,即表示您接受这些服务条款。如果您不同意,请勿使用该应用程序。 - 2. 许可证 - AirNote (Easy Notes) 在 **GNU通用公共许可证(GPL)第3版**下分发。这意味着:\n\n- 您可以自由使用、修改和共享该应用程序。\n- 如果您分发该应用程序或任何修改后的版本,您还必须分发源代码并保留GPL许可证。 - 3. 应用程序的使用 - 您同意仅为合法目的并根据这些条款使用 AirNote (Easy Notes)。您**不得**:\n\n- _将应用程序用于任何非法目的_\n- _侵犯他人权利_\n- _破坏应用程序的功能或安全_ - 4. 知识产权 - AirNote (Easy Notes) 的所有知识产权均归我们或许可方所有。您对应用程序的使用不授予您任何知识产权的所有权。 - 5. 免责声明 - Easy Notes 按“原样”提供,不提供任何保证。我们不保证该应用程序没有错误、安全或不间断。 - 6. 责任限制 - 对于因您使用 AirNote (Easy Notes) 而导致的任何损害,包括间接、附带或后果性损害,我们概不负责。 - 7. 终止 - 如果您违反这些条款,我们可能会终止或暂停您对 AirNote (Easy Notes) 的访问。终止后,您使用该应用程序的权利将立即终止。 - 8. 条款变更 - 我们可能会不时更新这些服务条款。在任何更改后继续使用 AirNote (Easy Notes) 即表示您接受修订后的条款。 - 9. 联系我们 - 有关这些服务条款或 AirNote (Easy Notes) 的任何问题,请通过以下方式联系我们 - 10. 隐私政策 - AirNote (Easy Notes) 不会收集任何个人数据。有关更多信息,请参阅我们的隐私政策,网址为 - 生效日期 - 同意 - 服务条款 - 无法导入 - 部分文件导入失败 - 成功导入 - 导入 - 将其他文本文件作为笔记导入 - 编辑笔记时使用等宽字体 - 等宽字体 - 您确定要删除所选笔记吗? - + 导入播放列表 (m3u) + 导入播放列表失败 + 已导入的播放列表 + 备份创建成功 + 无法创建备份 + 恢复备份失败 + + Discord 集成 + Loudly 使用 KizzyRPC 库来设置您的 Discord 账号状态。这涉及到使用 Discord 网关连接,可能被视为违反 Discord 的服务条款。但是,目前没有已知用户账号因此被暂停的案例。请自行承担风险。\n\nLoudly 只会提取您的令牌,其他所有内容都存储在本地。 + 忽略 + 选项 + 预览 + 登录失败 + 登出 + 启用 Rich Presence + + 关于 + 应用版本 + 正在检查更新… + 版本 %1$s 可供下载。 + 下载中… + 升级 + 重试 + 立即下载 + 应用类型 + + 有新版本可用 + 翻译模型 + 清除翻译模型 + 历史记录时长 + + %d 秒 + + 音乐 3x2 + 音乐 2x1 + 播放/暂停 + 版本 · 开发者 · 许可证 + 界面 + 内容与账号 + 播放器 + 系统 + 音乐库 + 集成 + 系统集成 + 总使用空间 + 当前已用 + 清除缓存 + 备份 · 恢复 · 导入 + 历史记录 · 搜索 · 截图 + 下载 · 缓存 · 图片 + 声音 · 队列 · 歌词 + 语言 · 位置 · 代理 + 登录 · Spotify · Discord + 主题 · 滑块 · 播放器 + + 个人资料卡片图片 + 自定义个人资料卡片上的图片 + 选择个人资料图片 + 预设图片 + 从相册选择 + 编辑照片 取消 - 应用锁 - 使用密码锁定应用 - 设置密码 - 输入密码 - PIN码(6位数字) - 密码 - 指纹 - 需要身份验证 - 已锁定 - UID - 启动时要求指纹 - 启动时要求密码 - 图案 - 启动时要求图案 - 设置图案 - 输入图案 - 立即锁定 - 恢复应用时立即锁定 - 禁用滑动 - "在编辑模式下禁用滑动预览" - 缩放搜索栏宽度 - 使搜索栏宽度与笔记宽度匹配 - 字体大小 - 更改笔记中文本的大小 - 字体大小:%1$d - 预览 - 这是您选择的字体大小下笔记的外观。 - Babel Software 创始人兼 Android 开发者 - 添加新文件夹 - 文件夹名称 - 文件夹颜色 - 添加 - 移动到文件夹 - 没有可用的文件夹 - 删除文件夹 - 您确定要删除“%1$s”文件夹吗?其中的笔记将被移动到“所有笔记”部分。 - 编辑文件夹 - 保存 - 文件夹标签 - 显示笔记所属文件夹的标签。 - AirNote AI 设置 - 配置 AI · API 设置 - 使用 AirNote API - 使用应用内置的 API 体验 AI 功能(推荐) - 您的 API 密钥 (BETA) - 检查 - 要使用的模型 - 模型选择 (BETA) - AI 的建议 + + 发送给开发者 关闭 - 所有笔记 - 用 AI 提问、聊天、查找... - 如何获取您自己的 API 密钥? - 您可以从 Google AI Studio 生成个人 API 密钥,并享有慷慨的免费使用额度。此密钥仅属于您,您拥有完全控制权。 - 1. 点击下方按钮,前往 Google AI Studio 网站。 - 2. 使用您的 Google 帐户登录。 - 3. 在打开的屏幕上,点击“创建 API 密钥”按钮。 - 4. 系统可能会要求您创建一个新项目,请按照步骤创建。 - 5. 复制生成的长字符串。 - 6. 将复制的密钥粘贴到下方的文本框中,然后按“检查”按钮。 - 前往 Google AI Studio - 请先输入 API 密钥。 - API 密钥有效并已成功注册!✅ - API 密钥无效。请检查后重试。 - 提问 - 就某个主题做笔记 - 产生想法 - 就一个随机主题给我 5 个有趣的想法 - 新版本 - 更新 - 有新更新可用! - 更新应用 - 无法检索更新日志。 - 无法接收更新日志:%1$s - 正在检查更新... - 有可用更新 - 有新版本可用! - 版本 %1$s 可供下载。 - 立即更新 - 下载 - 正在下载... %1$d%% - 安装 - 安装 - 更新失败 - 错误 - 重试 - 重试 - 您的应用已是最新版本 - 已更新 - 更新需要安装来自未知来源的权限。 - 未找到下载链接。 - 创新 - 儲存備註 - 重建 - - 优化写作 - 总结 - 缩短 - 加长 - 改变语气 - - 正式 - 平衡 - 友好 - 未找到用户 API 密钥。请在设置中检查。 - API 请求失败:%1$s - 您好!有什么可以帮助您的吗? - 建议 - 列出优缺点 - 创建待办事项列表 - 说得简单些 - 推荐用于导入的文件 - 建议标题 - - 太好了,我们应该起草什么主题?请输入主题。 - - 纠正以下文本中的语法和表达错误,使其更流畅。仅回答更正后的文本:\n\n---\n\n%1$s - 将以下文本总结成一两句话,保留主要思想。仅回答总结文本:\n\n---\n\n%1$s - 在保留其含义的同时显著缩短以下文本。仅回复缩短后的文本:\n\n---\n\n%1$s - 在以下文本中添加创意细节,使其更长。仅回复扩展后的文本:\n\n---\n\n%1$s - AI 服务器当前繁忙。请在几分钟后重试。 - - %1$s\n\n---\n\n%2$s - 用专业和正式的语言重写以下文本。保留含义,只改变语气。仅回复最终文本: - 用平衡的语言重写以下文本,既不太正式也不太亲切。保留含义。仅回复结论: - 用更真诚、热情、对话和友好的方式重写以下文本。保留含义。仅回复结论: - - 一个笔记应用用户正在处理一个标题为“%1$s”的笔记。建议一些符合此标题的创意和鼓舞人心的想法或起点。保持答案简短并以列表形式呈现。 - 继续以下文本,保持意义的完整性和作者的风格。仅回复新文本,不要重复现有文本:\n\n---\n\n\n标题:%1$s\n\n内容:\n%2$s - 分析以下文本,并写一段关于如何从完全不同的角度探讨此主题的段落。不要总结现有文本,提供一个替代视角:\n\n---\n\n\n标题:%1$s\n\n内容:\n%2$s - 分析以下文本,并在“### 优点”和“### 缺点”标题下列出正面和负面要点。仅回答这两个标题和项目:\n\n\n---\n\n\n\n%1$s - 分析以下文本。如果文本提到白天要完成的任务,请在每行开头加上“[]”将其变成待办事项列表。如果文本没有提到任务,请仅回答“NO_TASKS”,不要输入任何其他内容。检测文本的语言并用该语言创建列表:\n\n---\n\n%1$s - 将以下复杂的文本翻译成每个人都能理解的简单流畅的语言。保留文本的主要思想和重要细节。仅回答简化后的文本:\n\n---\n\n%1$s - 建议 5 个最符合以下文本内容的创意和引人注目的标题。以编号列表的形式给出答案:\n\n---\n\n%1$s - - 用户想要起草一个关于“%1$s”主题的笔记。您必须按以下格式给出答案:标题:[在此处插入标题]\n\n内容:[在此处插入笔记内容] + 显示错误详情 + 下载失败 + 显示错误 + 手动更新 + 详情请查看设置页面 + 软件已是最新版本 + + 应用界面 + 从 3 种不同的设计中选择一种 + 界面版本选择 + 应用的旧版本设计。(应用、播放器、迷你播放器) + 以更现代的方式创建的紧凑设计。(应用、播放器、迷你播放器) + 最流行的设计。(应用、播放器、迷你播放器) + 创建您自己的应用界面设计。(应用、播放器、迷你播放器) + 自定义界面 + + 其他设置 + 点击查看其他各种设置 + + 自动在 45 至 512 Kbps 之间调整质量 + 提供最高 512 Kbps 的质量 + 为平均网速设置 128 至 256 Kbps 的质量。 + 将质量限制在 45 至 52 Kbps 之间以节省数据(数据节省程序) + + 无法导入 + + 文本对齐 + 设置卡片上文本的位置 + 渐变强度 + 圆角 + 个人资料图片 + 个人资料卡片 + 移除个人资料图片 + 显示文本 + 在个人资料卡片上显示问候语和用户名的文本 + 点击以更改 + + 翻译 (BETA) + 复制 + + 网络错误 + 请检查您的网络连接并重试。 + 不支持的内容 + 您的设备不支持此歌曲格式。 + 直播错误 + 直播流出现问题。请重新启动。 + 播放失败 + 发生未知错误。请重试。 + 正在重试… + 使用 VPN 可能会影响您的连接质量。如果您遇到网络问题,请断开 VPN 连接或使用代理。您可以在“设置 >> 内容 >> 代理”中进行配置。 + + 本地歌曲同步成功 + 本地扫描器 + + 自动扫描 + 打开应用时自动扫描歌曲 + + 附加扫描器设置 + 严格文件名 + 启用后,扩展名将不会被忽略。例如,“Song.ogg”将与“Song.flac”是不同的歌曲。 + 配置扫描器灵敏度 + 匹配标题 + 匹配标题和艺术家 + 匹配标题、艺术家、专辑 + + 文件夹 + 平铺子文件夹 + 禁用以保留磁盘上的文件结构 + + 贴士与功能 + 贴士 · 功能 + 创建您的故事 + 用专辑封面的主色调创建时尚的设计,将您正在听的歌曲分享到社交媒体。 + 可自定义的设置 + 通过将设置屏幕上标题卡的背景替换为您相册中的图片,让应用成为您自己的。 + 新歌曲列表 + 查看您当前的播放列表,并通过从播放器屏幕向上滑动轻松切换歌曲(Player UI 2.0 独有的新设计)。 + 重制设置设计 + 通过新的基于卡片的设置设计,更现代、更方便地轻松找到您想要的内容。 + 经典音乐小组件 + 从您的主屏幕快速访问所有控件和歌曲信息。 + 极简音乐小组件 + 灵感来自 Android 16 快速设置按钮设计的“Quik”音乐小组件。 + 分享到故事 + 复制歌曲链接 + 链接已复制到剪贴板! + 在 Loudly 上播放 + + 全选 + 正在获取所有播放列表… + 导入所选项目 + 出了点问题。我们和您一样困惑。 + 嗯,这很尴尬。我们也不知道发生了什么。 + 获取所需凭证的说明 + 3. 确保在输入上述本地路径后点击“添加”。 + 5. 接受服务条款并点击“保存”。 + 您将被重定向到一个新页面。点击“设置”,然后复制客户端 ID 和客户端密钥,并将其粘贴到此应用下方的文本字段中。 + 2. 输入必要的详细信息,并使用 http://127.0.0.1:45454 作为重定向 URI。 + 4. 切换 Web API。复选框现在应该有一个勾号。 + 现在,点击下方的“授权并继续”按钮,并使用您要从中导入的帐户登录,然后授权并继续,这将把您重定向到一个以 http://127.0.0.1:45454/?code= 开头的网站。\n\n将整个 URL 粘贴到下方名为“授权码”的文本字段中,然后点击“验证”按钮。 + 1. 访问 Spotify 开发者仪表板,然后点击“创建应用”。 + 使用 Spotify API 凭证登录 + 授权并继续 + 授权码 + 验证 + 客户端密钥 + 客户端 ID + 正在导入… + 请不要关闭应用或返回。此操作不会在后台运行,请等待其完成!\n如果看起来卡住了,请不要惊慌;有时检索可能需要一些时间。 + 选择“喜欢的歌曲”目的地 + 喜欢的歌曲应该导入到哪里? + 一个名为“喜欢的歌曲”的新播放列表 + 在默认的“喜欢的歌曲”中 \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b904d43..369e1c3 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,278 +1,626 @@ - AirNote - 筆記 - 新增筆記 - 尚無建立任何筆記。 - 找不到任何筆記。 - 建立時間 - 網格檢視 - 清單檢視 - 字數 - 字元數 - 名稱 - 描述 - 全選 - 刪除 - 資訊 + + 歡迎使用 Loudly + 讓我們開始打造個人化的音樂體驗。 + YouTube Music 觸手可及 + 同步 YT Music 帳號 + 無廣告播放 + 離線音樂體驗 + 支援 Android Auto + 支援 Discord 狀態 + 完全開放原始碼 + 選擇您的播放器介面 + 選擇應用程式內的播放器畫面。您可以稍後在「設定 > 外觀」中變更此設定。 + 設定完成! + 您已準備好探索 Loudly。 + 完成 + 返回 + 下一步 + 上一步 + + 主畫面 + 探索 + 歌曲 + 演出者 + 專輯 + 播放清單 + + 喜歡的音樂 + 喜歡 + 離線音樂 + 我的熱門音樂 + 已快取的音樂 + 本機音樂 + + 內容篩選 + + 所有時間 + 過去 24 小時 + 過去一週 + 過去一個月 + 過去一年 + + + 已選取 %d 個項目 + + + 早安 + 午安 + 晚安 + 晚安 + 最近的活動 + 歷史記錄 + 您的歷史記錄將會顯示在此處 + 您的統計資料將會顯示在此處 + 統計資料 + 顯示較少 + 顯示更多 + 心情與類型 + 帳號 + 快速選取 + 聆聽歌曲以產生您的快速選取 + 遺忘的摯愛 + 繼續聆聽 + 您的 YouTube 播放清單 + 類似於 + 新發行專輯 + 沒有網際網路連線 + 您似乎沒有網際網路連線。請檢查您的網路設定。 + 為您精選 + 今日為您推薦 + + 今天 + 昨天 + 本週 + 上週 + 本機 + 雲端 + 本機歷史記錄 + 雲端歷史記錄 + + 最常播放的歌曲 + 最常播放的演出者 + 最常播放的專輯 + 搜尋 - 設定 - 顯示 - 系統主題 + 在 Loudly 中搜尋 + 搜尋媒體庫… + 搜尋記錄 + 建議 + 搜尋結果 + 媒體庫 + 已按讚 + 已下載 + 全部 + 歌曲 + 影片 + 專輯 + 演出者 + 播放清單 + 社群播放清單 + 精選播放清單 + 已加入書籤 + 找不到結果 + + 您的媒體庫將會顯示在此處 + 媒體庫中的歌曲將會顯示在此處 + 媒體庫中的演出者將會顯示在此處 + 媒體庫中的專輯將會顯示在此處 + 您的播放清單將會顯示在此處 + + 來自您的媒體庫 + + 其他版本 + 最愛 + 熱門歌曲 + + 喜歡的歌曲 + 已下載的歌曲 + 播放清單是空的 + 您確定要從已下載歌曲儲存空間中移除「%s」播放清單中的所有歌曲嗎? + 您確定要刪除「%s」播放清單嗎? + + 停用 + + 重試 + 電台 + 隨機播放 + 重設 + + 詳細資訊 + 編輯 + 開始電台 + 播放 + 下一首播放 + 新增至待播清單 + 新增至媒體庫 + 全部新增至媒體庫 + 從媒體庫移除 + 全部從媒體庫移除 + 下載 + 下載中 + 移除下載 + 匯入播放清單 + 新增至播放清單 + 檢視演出者 + 檢視專輯 + 重新整理 + 分享 + 刪除 + 從歷史記錄中移除 + 從播放清單中移除 + 從待播清單中移除 + 從快取中移除 + 匯出為 M3U 檔案 + 線上搜尋 + 同步 + 進階 + 速度與音高 + + 新增日期 + 更新日期 + 名稱 + 演出者 + 年份 + 歌曲數量 + 長度 + 播放時間 + 自訂順序 + + 媒體 ID + MIME 類型 + 轉碼器 + 位元率 + 取樣率 + 響度 + 音量 + 檔案大小 + 未知 + 已複製到剪貼簿 + + 編輯歌詞 + 搜尋歌詞 + 您確定要刪除「%s」的已儲存歌詞嗎? + + 編輯歌曲 + 歌曲標題 + 歌曲演出者 + 歌曲標題不能為空。 + 歌曲演出者不能為空。 + 儲存 + + 同步播放清單 + 選擇播放清單 + 編輯播放清單 + 建立播放清單 + 播放清單名稱 + 播放清單名稱不能為空。 + + 編輯演出者 + 演出者名稱 + 演出者名稱不能為空。 + + 重複項目 + 跳過重複項目 + 仍要新增 + 這首歌曲已在您的播放清單中 + 您的播放清單中已有 %d 首重複歌曲 + 注意:這允許與 YouTube Music 同步 + + + %d 首歌曲 + + + %d 位演出者 + + + %d 張專輯 + + + %d 個播放清單 + + + %d 週 + + + %d 個月 + + + %d 年 + + + 已匯入播放清單 + 已從播放清單中移除「%s」 + 已同步播放清單 + 復原 + + 正在播放 + 找不到歌詞 + 睡眠計時器 + 歌曲結束時 + + %d 分鐘 + + 沒有可用的串流 + 沒有網路連線 + 逾時 + 未知的錯誤 + 選單 + 分享清單 + + 倒退 5 秒 + 快轉 5 秒 + + 喜歡 + 全部喜歡 + 取消喜歡 + 全部取消喜歡 + 隨機播放已開啟 + 隨機播放已關閉 + 重複播放模式已關閉 + 重複播放目前歌曲 + 重複播放待播清單 + + 所有歌曲 + 搜尋到的歌曲 + + 音樂播放器 + + 通知 + 防止應用程式被系統終止 + 使用前景通知,防止 Loudly 被系統隨機關閉。 + 需要通知權限 + + 設定 + 外觀 + 主題 + 代理伺服器 + 啟用動態主題 深色主題 - 動態色彩 - Amoled 色彩 - 資料庫 + 開啟 + 關閉 + 跟隨系統 + 純黑 + 應用程式設計 + 應用程式介面 1.0 + 應用程式介面 1.5 + 在 YouTube Music 上聆聽 + 造訪 Loudly + 演出者 + 聆聽 Loudly + 啟用滑動縮圖來切換歌曲 + 自訂導覽分頁 + 播放器 + 播放器文字對齊 + 歌詞文字位置 + 側邊 + 靠左 + 置中 + 播放器樣式 + 舊版播放器介面 + 播放器介面 1.0 + 播放器介面 2.0 + 跟隨主題 + 漸層 + 模糊 + 模糊(動態) + 播放器背景樣式 + 靠右 + 滑桿樣式 + 預設 + 波浪形 + 組合 + 設定縮圖圓角半徑 + 顯示內容篩選器 + 雜項 + 主要 + 自訂自動播放清單 + 顯示「已按讚」自動播放清單 + 顯示「已下載」自動播放清單 + 顯示「熱門」自動播放清單 + 顯示「已快取」自動播放清單 + 顯示「本機」自動播放清單 + 向右滑動歌曲以播放下一首,向左滑動以新增至待播清單 + 預設開啟的分頁 + 預設媒體庫篩選 + 網格儲存格大小 + 顯示底部導覽列標籤 + + + 更新日誌 + 找不到最近的變更 + 最近的變更: + 關閉 + 有新版本可用 + 再次問好 + 贊助 + 官方頻道 + 網站 + 官方網站 ❯ + 原始碼 + 存取應用程式的原始碼 + 問題回報 + 回報錯誤及其他 + 說明 + 如果您想做出貢獻,請點擊此處 ❯ + 開發者 | @RRechz + + 內容 + 登入 + 登入您的 YT Music 播放清單等 + 點擊以顯示權杖 + 再次點擊以複製或編輯 + 這是一種進階登入方法。作為網頁入口的替代方案,您可以直接在此處輸入或更新您的登入權杖。例如,這可以加快在多個裝置上的登入速度。請注意,任何應用程式無法解析的無效權杖格式都將不被接受。 + 從 Spotify 匯入 + Spotify + Discord + 自動下載您喜歡的歌曲 + 與您的 YouTube Music 帳號自動同步 + 將您的播放清單等與您的帳號同步 + 僅 Wi-Fi + 未登入 + 預設內容語言 + 預設內容國家 + 系統預設 + 啟用代理伺服器 + 代理伺服器類型 + 代理伺服器 URL + 重新啟動後生效 + 應用程式語言 + 設定應用程式語言 + 找不到應用程式語言設定,請手動設定 + 預設開啟支援的連結 + 設定支援的連結 + 找不到支援的連結設定,請手動設定 + + 播放器與音訊 + 音質 + 最高 + 自動 + + + 最短播放長度 + 一首歌曲必須播放的最短時間,才會被視為「已播放」 + 待播清單 + 保留待播清單 + 應用程式啟動時還原您上次的待播清單 + 自動載入更多歌曲 + 當到達待播清單末尾時,如果可能,自動新增更多歌曲 + 跳過靜音 + 音量正常化 + 連接藍牙裝置時繼續播放 + 當音量最小時停止播放 + 將播放的歌曲新增至 YouTube Music 歷史記錄 + 發生錯誤時自動跳到下一首 + 確保您的連續播放體驗 + 清除任務時停止音樂 + 等化器 + 歌詞 + 歌詞 + 本機播放器設定 + 儲存空間 + 快取 + 圖片快取 + 歌曲快取 + 最大快取大小 + 無限制 + 啟用音訊卸載 + 使用音訊卸載路徑進行播放。停用此選項可能會增加功耗,但如果您在音訊播放或後製處理方面遇到問題,可能會有所幫助。 + 清除所有下載 + 最大圖片快取大小 + 清除圖片快取 + 最大歌曲快取大小 + 清除歌曲快取 + 已使用 + 您確定要清除所有快取嗎? + 您確定要清除所有下載嗎? + 您確定要清除所有快取的歌曲嗎? + 您確定要清除所有快取的圖片嗎? + 您確定要清除所有快取的翻譯模型嗎? + + 隱私 + 聆聽記錄 + 暫停聆聽記錄 + 清除聆聽記錄 + 您確定要清除所有聆聽記錄嗎? + 暫停搜尋記錄 + 清除搜尋記錄 + 您確定要清除所有搜尋記錄嗎? + 搜尋記錄 + 使用登入資訊瀏覽內容 + 這可能會影響您看到的內容,例如,如果您使用 Premium 帳號登入,則會顯示僅限 Premium 會員的專輯。 + 禁止螢幕截圖 + 開啟此選項後,將停用螢幕截圖和最近任務中的應用程式預覽。 + 啟用 LrcLib 歌詞來源 + 啟用酷狗歌詞來源 + 預設歌詞來源 + 讀取多行歌詞 + 將同步點之間的所有行視為一個歌詞文字 + 移除歌詞周圍的空格 + 字型大小 + 隱藏兒童不宜的內容 + 啟用通知 + + 備份與還原 備份 - 尚不支援 - 關於 - 版本 - 最新發行版 - 為專案加上星號 - 贊助我 :) 還原 - 筆記 - 支援 - - 語言 - Markdown - 工具 - 歷史記錄 - 小工具 - 主題 • 大小 • 字型 - 雲端 - 字型 • 語言 - NextCloud • 本機 • 加密 - 版本 • 開發者 • 授權 - 小工具 • Markdown - 歷史記錄 • 還原 - 已建立 • 全部刪除 - 支援 • 圖片 - 表達您的支持 - 字型 - 圓角 - 選取圓角半徑 - 簡約模式 - 極致 Amoled 色彩 - 隱私權 - 螢幕保護 - 圖片支援 - 不穩定 - 加密貨幣 - 根據系統設定調整應用程式主題 - 在深色與淺色配置之間切換 - 調整色彩以符合您的桌布 - 使用黑色作為背景 - 極致電池最佳化 - 自訂邊框圓角 - 選取檢視樣式 - 從編輯畫面移除頂部按鈕 - 將所有筆記儲存至檔案 - 從備份檔案還原筆記 - 在筆記中啟用 Markdown - 在最近應用程式中隱藏畫面並停用螢幕截圖 - 建置類型 - 原始碼 - 支持者清單 - 電子郵件 - Discord - 錯誤回報與更多 - 已釘選 - 其他 - 複製 - 加密資料庫 - 在備份期間加密資料庫 - 輸入密碼 - 密碼無效或資料庫未加密 - 無效的輸入 - 遞增排序 - 遞減排序 - 依建立時間排序筆記 - 選取筆記 - 不支援的圖片大小(最大 15MB) - 保險庫 - 啟用加密筆記保險庫 - 繼續 - 變更應用程式語言 - 系統語言 - 行為 - 優先使用編輯模式 - 在現有筆記上進入編輯模式 - 密碼無效 - 贊助 - 翻譯 - 貢獻 - 當圖片從圖庫移除時,同時從應用程式儲存空間中刪除 - 圖庫同步 - 僅顯示標題 - 在預覽中僅顯示標題 - 1. 接受條款 - 按下「同意」並使用 Easy Notes,即表示您接受本服務條款。若您不同意,請勿使用本應用程式。 - 2. 授權 - AirNote (Easy Notes) 採用 **GNU 通用公共授權條款第 3 版 (GPLv3)** 散佈。這表示:\n\n- 您可以自由使用、修改與分享本應用程式。\n- 若您散佈本應用程式或任何修改版本,您也必須散佈其原始碼並保留 GPL 授權。 - 3. 應用程式之使用 - 您同意僅出於合法目的並依據本條款使用 AirNote (Easy Notes)。您**不得**:\n\n- _將本應用程式用於任何非法目的_\n- _侵犯他人權利_\n- _干擾本應用程式的功能或安全性_ - 4. 智慧財產權 - AirNote (Easy Notes) 中的所有智慧財產權均為我方或我方授權人所有。您對本應用程式的使用不授予您任何智慧財產權的所有權。 - 5. 免責聲明 - Easy Notes 係「按現狀」提供,不含任何保證。我方不保證本應用程式將無錯誤、安全或不中斷。 - 6. 責任限制 - 對於您因使用 AirNote (Easy Notes) 而導致的任何損害,包括間接、附帶或衍生性損害,我方概不負責。 - 7. 終止 - 若您違反本條款,我方得終止或暫停您對 AirNote (Easy Notes) 的存取。一旦終止,您使用本應用程式的權利將立即終止。 - 8. 條款變更 - 我方得不時更新本服務條款。在任何變更後繼續使用 AirNote (Easy Notes),即表示您接受修訂後的條款。 - 9. 聯絡我們 - 若對本服務條款或 AirNote (Easy Notes) 有任何疑問,請透過以下方式與我們聯絡 - 10. 隱私權政策 - AirNote (Easy Notes) 不會收集任何個人資料。如需詳細資訊,請參閱我方提供的隱私權政策,網址為 - 生效日期 - 同意 - 服務條款 - 無法匯入 - 部分檔案匯入失敗 - 成功匯入 - 匯入 - 將其他文字檔案匯入為筆記 - 編輯筆記時使用等寬字型 - 等寬字型 - 您確定要刪除所選的筆記嗎? - + 匯入播放清單 (m3u) + 匯入播放清單失敗 + 已匯入的播放清單 + 成功建立備份 + 無法建立備份 + 還原備份失敗 + + Discord 整合 + Loudly 使用 KizzyRPC 函式庫來設定您的 Discord 帳號狀態。這會使用到 Discord 閘道連線,可能被視為違反 Discord 的服務條款。但是,目前沒有已知使用者帳號因此被停權的案例。請自行承擔風險。\n\nLoudly 只會擷取您的權杖,其他所有內容都儲存在本機。 + 關閉 + 選項 + 預覽 + 登入失敗 + 登出 + 啟用 Rich Presence + + 關於 + 應用程式版本 + 正在檢查更新… + 版本 %1$s 可供下載。 + 下載中… + 升級 + 重試 + 立即下載 + 應用程式類型 + + 有新版本可用 + 翻譯模型 + 清除翻譯模型 + 歷史記錄持續時間 + + %d 秒 + + 音樂 3x2 + 音樂 2x1 + 播放/暫停 + 版本 · 開發者 · 授權 + 介面 + 內容與帳號 + 播放器 + 系統 + 媒體庫 + 整合 + 系統整合 + 總使用空間 + 目前已用 + 清除快取 + 備份 · 還原 · 匯入 + 歷史記錄 · 搜尋 · 螢幕截圖 + 下載 · 快取 · 圖片 + 聲音 · 待播清單 · 歌詞 + 語言 · 位置 · 代理伺服器 + 登入 · Spotify · Discord + 主題 · 滑桿 · 播放器 + + 個人資料卡片圖片 + 自訂個人資料卡片上的圖片 + 選擇個人資料圖片 + 預設圖片 + 從相簿選擇 + 編輯相片 取消 - 應用程式鎖 - 使用密碼鎖定應用程式 - 設定密碼 - 輸入密碼 - PIN 碼(6 位數字) - 密碼 - 指紋 - 需要驗證 - 已鎖定 - UID - 啟動時要求指紋 - 啟動時要求密碼 - 圖形 - 啟動時要求圖形 - 設定圖形 - 輸入圖形 - 立即鎖定 - 恢復應用程式時立即鎖定 - 停用滑動 - "在編輯模式中停用滑動以預覽" - 調整搜尋列寬度 - 讓搜尋列寬度符合筆記寬度 - 字型大小 - 變更筆記中的文字大小 - 字型大小:%1$d - 預覽 - 這是您選取的字型大小在筆記中的外觀。 - Babel Software 創辦人與 Android 開發者 - 新增資料夾 - 資料夾名稱 - 資料夾色彩 - 新增 - 移至資料夾 - 沒有可用的資料夾 - 刪除資料夾 - 您確定要刪除「%1$s」資料夾嗎?其中的筆記將會移至「所有筆記」區段。 - 編輯資料夾 - 儲存 - 資料夾標籤 - 顯示筆記所屬資料夾的標籤。 - AirNote AI 設定 - 設定 AI · API 設定 - 使用 AirNote API - 使用應用程式內建的 API 體驗 AI 功能(建議) - 您的 API 金鑰(BETA) - 檢查 - 要使用的模型 - 模型選擇(BETA) - AI 的建議 + + 傳送給開發者 關閉 - 所有筆記 - 用 AI 提問、聊天、尋找... - 如何取得您自己的 API 金鑰? - 您可以從 Google AI Studio 產生個人 API 金鑰,並享有慷慨的免費使用額度。此金鑰僅屬於您,您擁有完全的控制權。 - 1. 點擊下方按鈕,前往 Google AI Studio 網站。 - 2. 使用您的 Google 帳戶登入。 - 3. 在開啟的畫面上,點擊「建立 API 金鑰」按鈕。 - 4. 系統可能會要求您建立一個新專案,請按照步驟建立。 - 5. 複製產生的長字元字串。 - 6. 將複製的金鑰貼到下方的文字方塊中,然後按下「檢查」按鈕。 - 前往 Google AI Studio - 請先輸入 API 金鑰。 - API 金鑰有效並已成功註冊!✅ - API 金鑰無效。請檢查後重試。 - 提問 - 就某個主題做筆記 - 產生想法 - 就一個隨機主題給我 5 個有趣的想法 - 新版本 - 更新 - 有可用的更新! - 更新應用程式 - 無法擷取更新日誌。 - 無法接收更新日誌:%1$s - 正在檢查更新... - 有可用更新 - 有新版本可供使用! - 版本 %1$s 可供下載。 - 立即更新 - 下載 - 正在下載... %1$d%% - 安裝 - 安裝 - 更新失敗 - 錯誤 - 再試一次 - 再試一次 - 您的應用程式已是最新版本 - 已更新 - 更新需要從未知來源安裝的權限。 - 找不到下載連結。 - 創新 - บันทึกหมายเหตุ - สร้างใหม่ - - 優化寫作 - 總結 - 縮短 - 加長 - 變更語氣 - - 正式 - 平衡 - 友善 - 找不到使用者 API 金鑰。請在設定中檢查。 - API 請求失敗:%1$s - 您好!有什麼可以協助您的嗎? - 建議 - 列出優缺點 - 建立待辦清單 - 解釋得更簡單 - 建議用於匯入的檔案 - 建議標題 - - 太好了,我們該草擬什麼主題的筆記?請寫下主題。 - - 修正以下文字中的文法和表達錯誤,使其更流暢。僅回答修正後的文字:\n\n---\n\n%1$s - 將以下文字總結為一至兩句話,並保留其主要思想。僅回答總結後的文字:\n\n---\n\n%1$s - 在保留其意義的同時,大幅縮短以下文字。僅回覆縮短後的文字:\n\n---\n\n%1$s - 為以下文字添加創意細節,使其更長。僅回覆擴充後的文字:\n\n---\n\n%1$s - AI 伺服器目前忙碌中。請在幾分鐘後再試。 - - %1$s\n\n---\n\n%2$s - 以專業且正式的語言重寫以下文字。保留其意義,僅變更語氣。僅回覆最終的文字: - 以平衡的語言重寫以下文字,既不過於正式也不過於誠摯。保留其意義。僅回覆結論: - 以更誠摯、溫暖、對話且友善的方式重寫以下文字。保留其意義。僅回覆結論: - - 一位筆記應用程式的使用者正在處理一則標題為「%1$s」的筆記。請建議一些符合此標題且具創意和啟發性的想法或起點。請讓您的回答簡短並以清單格式呈現。 - 繼續撰寫以下文字,並保持其意義的完整性與作者的風格。僅回覆新的文字,請勿重複現有文字:\n\n---\n\n\n標題:%1$s\n\n內容:\n%2$s - 分析以下文字,並撰寫一段關於如何從一個完全不同的角度探討此主題的段落。請勿總結現有文字,而是提供一個替代觀點:\n\n---\n\n\n標題:%1$s\n\n內容:\n%2$s - 分析以下文字,並在「### 優點」和「### 缺點」的標題下列出正面與負面的觀點。僅回答這兩個標題及其項目:\n\n\n---\n\n\n\n%1$s - 分析以下文字。若文字中提及白天要完成的任務,請在每一行開頭加上「[] 」將其轉換為待辦清單。若文字中未提及任務,請僅回答「NO_TASKS」,不要輸入任何其他內容。請偵測文字的語言,並以該語言建立清單:\n\n---\n\n%1$s - 將以下複雜的文字翻譯成大家都能理解的簡單流暢語言。保留文字的主要思想和重要細節。僅回答簡化後的文字:\n\n---\n\n%1$s - 建議 5 個最能貼合以下文字內容且具創意和吸引力的標題。請以編號清單的形式提供您的答案:\n\n---\n\n%1$s - - 使用者想要草擬一則關於「%1$s」主題的筆記。您必須以下列格式提供您的答案:標題:[在此處插入標題]\n\n內容:[在此處插入筆記內容] + 顯示錯誤詳細資訊 + 下載失敗 + 顯示錯誤 + 手動更新 + 詳細資訊請查看設定頁面 + 軟體已是最新版本 + + 應用程式介面 + 從 3 種不同的設計中選擇一種 + 介面版本選擇 + 應用程式的舊版本設計。(應用程式、播放器、迷你播放器) + 以更現代的方式建立的緊湊設計。(應用程式、播放器、迷你播放器) + 最受歡迎的設計。(應用程式、播放器、迷你播放器) + 建立您自己的應用程式介面設計。(應用程式、播放器、迷你播放器) + 自訂介面 + + 其他設定 + 點擊以查看其他各種設定 + + 自動在 45 至 512 Kbps 之間調整品質 + 提供最高 512 Kbps 的品質 + 為平均網速設定 128 至 256 Kbps 的品質。 + 將品質限制在 45 至 52 Kbps 之間以節省數據(數據節省模式) + + 無法匯入 + + 文字對齊 + 設定卡片上文字的位置 + 漸層強度 + 圓角 + 個人資料圖片 + 個人資料卡片 + 移除個人資料圖片 + 顯示文字 + 在個人資料卡片上顯示問候語和使用者名稱的文字 + 點擊以變更 + + 翻譯 (BETA) + 複製 + + 網路錯誤 + 請檢查您的網路連線並重試。 + 不支援的內容 + 您的裝置不支援此歌曲格式。 + 直播錯誤 + 直播串流發生問題。請重新啟動。 + 播放失敗 + 發生未知的錯誤。請重試。 + 正在重試… + 使用 VPN 可能會影響您的連線品質。如果您遇到網路問題,請中斷 VPN 連線或使用代理伺服器。您可以在「設定 >> 內容 >> 代理伺服器」中進行設定。 + + 成功同步本機歌曲 + 本機掃描器 + + 自動掃描 + 開啟應用程式時自動掃描歌曲 + + 附加掃描器設定 + 嚴格檔案名稱 + 啟用後,副檔名將不會被忽略。例如,「Song.ogg」將與「Song.flac」是不同的歌曲。 + 設定掃描器靈敏度 + 符合標題 + 符合標題和演出者 + 符合標題、演出者、專輯 + + 資料夾 + 扁平化子資料夾 + 停用以保留磁碟上的檔案結構 + + 提示與功能 + 提示 · 功能 + 建立您的限時動態 + 用專輯封面的主色調建立時尚的設計,將您正在聽的歌曲分享到社群媒體。 + 可自訂的設定 + 透過將設定畫面上標題卡的背景替換為您相簿中的圖片,讓應用程式成為您自己的。 + 新歌曲清單 + 檢視您目前的播放清單,並透過從播放器畫面上滑輕鬆切換歌曲(Player UI 2.0 獨有的新設計)。 + 重製設定設計 + 透過新的基於卡片的設定設計,更現代、更方便地輕鬆找到您想要的內容。 + 經典音樂小工具 + 從您的主畫面上快速存取所有控制項和歌曲資訊。 + 極簡音樂小工具 + 靈感來自 Android 16 快速設定按鈕設計的「Quik」音樂小工具。 + 分享到限時動態 + 複製歌曲連結 + 連結已複製到剪貼簿! + 在 Loudly 上串流 + + 全選 + 正在擷取所有播放清單… + 匯入所選項目 + 出了點問題。我們和您一樣困惑。 + 嗯,這很尷尬。我們也不知道發生了什麼事。 + 取得所需憑證的說明 + 3. 確保在輸入上述本機路徑後點擊「新增」。 + 5. 接受服務條款並點擊「儲存」。 + 您將被重新導向到一個新頁面。點擊「設定」,然後複製用戶端 ID 和用戶端密鑰,並將其貼到此應用程式下方的文字欄位中。 + 2. 輸入必要的詳細資訊,並使用 http://127.0.0.1:45454 作為重新導向 URI。 + 4. 開啟 Web API。核取方塊現在應該會有一個勾號。 + 現在,點擊下方的「授權並繼續」按鈕,並使用您要從中匯入的帳號登入,然後授權並繼續,這將把您重新導向到一個以 http://127.0.0.1:45454/?code= 開頭的網站。\n\n將整個 URL 貼到下方名為「授權碼」的文字欄位中,然後點擊「驗證」按鈕。 + 1. 造訪 Spotify 開發者儀表板,然後點擊「建立應用程式」。 + 使用 Spotify API 憑證登入 + 授權並繼續 + 授權碼 + 驗證 + 用戶端密鑰 + 用戶端 ID + 正在匯入… + 請不要關閉應用程式或返回。此操作不會在背景執行,請等待其完成!\n如果看起來卡住了,請不要驚慌;有時擷取可能需要一些時間。 + 選擇「喜歡的歌曲」目的地 + 喜歡的歌曲應該匯入到哪裡? + 一個名為「喜歡的歌曲」的新播放清單 + 在預設的「喜歡的歌曲」中 \ No newline at end of file diff --git a/app/src/main/res/values/app_name.xml b/app/src/main/res/values/app_name.xml new file mode 100644 index 0000000..c97fb76 --- /dev/null +++ b/app/src/main/res/values/app_name.xml @@ -0,0 +1,4 @@ + + + Loudly + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..7781ac8 --- /dev/null +++ b/app/src/main/res/values/attrs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index b0925ba..ab64347 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,8 +1,16 @@ - - #332f2f - #fddbd6 - @color/splash_bg_light - #FFFFFF - #121212 + #FFE1F5FE + #FF81D4FA + #FF039BE5 + #FF01579B + #FFFFFF + #4DFFFFFF + + + #FFFFFF + #E0E0E0 + + + #FFFFFF + #80FFFFFF \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 290e644..4db8c59 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,12 +1,10 @@ - 14.dp - 3dp - 20dp - 6dp - 6dp - 12dp - 150.dp - 75.dp - 32.dp + + + 0dp + \ No newline at end of file diff --git a/app/src/main/res/values/ic_banner_background.xml b/app/src/main/res/values/ic_banner_background.xml new file mode 100644 index 0000000..1394fa6 --- /dev/null +++ b/app/src/main/res/values/ic_banner_background.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67535ee..211becd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,646 +1,711 @@ - - AirNote - Notes - New Note - No created notes. - No notes found. - Created Time - Grid view - Column view - Words - Characters - Name - Description - Select all - Delete - Information + + + Welcome to Loudly + Let\'s start personalizing your music experience. + YouTube Music at your fingertips + YT Music account sync + No ADS in playback + Offline music experience + Support Android Auto + Support Discord state + Full open source + Select Your Player UI + Select the in-app player view. You can change this setting later in “Settings > Appearance”. + Setup Complete! + You are ready to explore Loudly. + Finish + Back + Next + Previous + + + Home + Explore + Songs + Artists + Albums + Playlists + + Liked music + Like + Offline music + My top music + Cached music + Local music + + Content filter + + + All time + Past 24 hours + Past week + Past month + Past year + + + + %d selected + + + + Good morning + Good afternoon + Good evening + Good night + Recent activity + History + Your history will show up here + Your stats will show up here + Stats + Show Less + Show More + Mood and Genres + Account + Quick picks + Listen to songs to generate your quick picks + Forgotten favorites + Keep listening + Your YouTube playlists + Similar to + New release albums + No Internet Сoonnection + It looks like you have no Internet connection. Please check your network. + Featured for You + Recommended for you Today + + + Today + Yesterday + This week + Last week + Local + Remote + Local history + Remote history + + + Most played songs + Most played artists + Most played albums + + Search - Settings - Display - System Theme - Dark Theme - Dynamic Colors - Amoled Colors - Database + Search in Loudly + Search library… + Search History + Suggestions + Search Results + Library + Liked + Downloaded + All + Songs + Videos + Albums + Artists + Playlists + Community playlists + Featured playlists + Bookmarked + No results found + + + Library will show up here + Library songs will show up here + Library artists will show up here + Library albums will show up here + Your playlists will show up here + + + From your library + + + Other versions + Favorite + Top Songs + + + Liked songs + Downloaded songs + The playlist is empty + Do you really want to remove all \"%s\" playlist songs from the Downloaded Songs storage? + Do you really want to delete the playlist \"%s\"? + + Disable + + + Retry + Radio + Shuffle + Reset + + + Details + Edit + Start radio + Play + Play next + Add to queue + Add to library + Add all to library + Remove from library + Remove all from library + Download + Downloading + Remove download + Import playlist + Add to playlist + View artist + View album + Refetch + Share + Delete + Remove from history + Remove from playlist + Remove from queue + Remove from cache + Export as M3U file + Search online + Sync + Advanced + Tempo and Pitch + + + Date added + Date updated + Name + Artist + Year + Song count + Length + Play time + Custom order + + + Media id + MIME type + Codecs + Bitrate + Sample rate + Loudness + Volume + File size + Unknown + Copied to clipboard + + Edit lyrics + Search lyrics + Do you really want to delete the stored lyrics for \"%s\"? + + Edit song + Song title + Song artists + Song title cannot be empty. + Song artist cannot be empty. + Save + + Sync Playlist + Choose playlist + Edit playlist + Create playlist + Playlist name + Playlist name cannot be empty. + + Edit artist + Artist name + Artist name cannot be empty. + + Duplicates + Skip duplicates + Add anyway + The song is already in your playlist + %d songs are already in your playlist + Note: This allows for syncing with YouTube Music + + + + %d song + %d songs + + + %d artist + %d artists + + + %d album + %d albums + + + %d playlist + %d playlists + + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + + + + Playlist imported + Removed \"%s\" from playlist + Playlist synced + Undo + + + Now playing + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes + + No stream available + No network connection + Timeout + Unknown error + Menu + Share List + + 5 seconds backward + 5 seconds forward + + + Like + Like all + Remove like + Remove all likes + Shuffle on + Shuffle off + Repeat mode off + Repeat current song + Repeat queue + + + All songs + Searched songs + + + Music Player + + + Notifications + Prevent system killing app + Use a foreground notification to prevent Loudly from being randomly closed by the system. + Keep to Alive Settings + Notification permission is required + Comrade Notification + + Comrade Notifications + Enable Smart Notifications + Loudly provides smart music recommendations based on your preferences. + + Permissions + Physical Activity Permission + This permission is required to detect when you are running or driving and provide relevant music recommendations. + Allow + Allowed + + Types of Suggestions + Driving Detection + It suggests route lists when you use the vehicle. + Running/Walking Detection + It recommends energy lists for your exercise activities. + + DEMO + + + Settings + Appearance + Theme + Proxy + Enable dynamic theme + Dark theme + On + Off + Follow system + Pure black + App design + App UI 1.0 + App UI 1.5 + Listen on YouTube Music + Visit the Loudly + Artist + Listen to Loudly + Enable swipe on thumbnail to change song + Customize navigation tabs + Player + Player text alignment + Lyrics text position + Sided + Left + Center + Mini Player Actions + Player style + Player Old UI + Player UI 1.0 + Player UI 2.0 + None + Follow theme + Gradient + Blur + Blur (Motion) + Player background style + Right + Slider style + Default + Squiggly + Compose + Configure thumbnail corner radius + Show content filter + Misc + Main + Auto playlists customization + Show auto-playlist liked + Show auto-playlist download + Show auto-playlist top + Show auto-playlist cached + Show auto-playlist local + Swipe song to right for play next or left for adding to queue + Default open tab + Default library chips + Grid cell size + Show bottom navigation bar labels + Small + Big + Changelog + No recent changes found + Recent changes: + Close + New version available + Hello Again + Donate + Official Channel + Website + Official website ❯ + Source code + Access the source code of the app + Issues + Bugs Report and More + Help + If you want to contribute, click here ❯ + Developer | @RRechz + + Content + Login + Sign in to your YT Music playlists and more + Tap to show token + Tap again to copy or edit + This is an ADVANCED login method. As an alternative to the web portal, you may directly enter or update your login token here. For example, this can speed up logging in on multiple devices. Please note that any invalid token formats the app fails to parse will not be accepted + Import from spotify + Spotify + Discord + Automatically download your liked songs + Automatically sync with your YouTube Music account + Sync your playlists and more with your account + Wifi only + Not logged in + Default content language + Default content country + System default + Enable proxy + Proxy type + Proxy URL + Restart to take effect + App language + Configure app languages + Couldn\'t find app language settings, please configure them manually + Open supported link by default + Configure supported links + Couldn\'t find supported links settings, please configure them manually + + Player and Audio + Audio quality + Max + Auto + High + Low + Minimum playback duration + The minimum amount of a song that must be played before it is considered \"played\" + Queue + Persistent queue + Restore your last queue when the app starts + Auto load more songs + Automatically add more songs when the end of the queue is reached, if possible + Skip silence + Audio normalization + Continue playback when the bluetooth device is connected + Stop playing song when sound minimum volume + Adding played songs to YouTube-Music history + Auto skip to next song when error occurs + Ensure your continuous playback experience + Stop music on task clear + Equalizer + Lyrics + Lyrics + Local player settings + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Enable offload + Use the offload audio path for audio playback. Disabling this may increase power usage but can be useful if you experience issues with audio playback or post processing + Clear all downloads + WARNING! All downloads will be deleted. Do you confirm? + Currently used Formatted + Caches + Max image cache size + Clear image cache + Max song cache size + Clear song cache + used + Are you sure you want to clear all cached? + Are you sure you want to clear all downloads? + Are you sure you want to clear all cached songs? + Are you sure you want to clear all cached images? + Are you sure you want to clear all cached translation models? + + Privacy + Listen history + Pause listen history + Clear listen history + Are you sure you want to clear all listen history? + Pause search history + Clear search history + Are you sure you want to clear all search history? + Search history + Use login for browsing content + This can influence what content you see and for example shows premium-only albums if you are logged in with a Premium account + Disable screenshot + When this option is on, screenshots and the app\'s view in Recents are disabled. + Enable LrcLib lyrics provider + Enable KuGou lyrics provider + Default lyrics provider + Read multiline lyrics + Treat all the lines between sync points as one lyric text + Remove spaces around lyrics + Font size + Hide explicit content + Enable notifications + + Backup and restore Backup - Not supported yet - About - Version - Latest Release - Star Project - Donate Me :) Restore - Notes - Support - - Language - Markdown - Tools - History - Widgets - Themes • Size • Font - Cloud - Font • Language - NextCloud • Local • Encryption - Version • Developers • License - Widgets • Markdown - History • Revert - Created • Delete All - Support • Images - Show your love - Font - Radius - Select Radius - Minimalistic Mode - Extreme Amoled Colors - Privacy - Screen Protection - Images Support - Unstable - Cryptocurrency - Adapts app theme based on system settings - Toggle between dark and light color schemes - Adjusts colors to match your wallpaper - Use black color for background - Extreme Battery optimization - Customize border radius - Select view style - Remove top buttons from edit screen - Store all notes to file - Restore notes from backup file - Enable markdown in notes - Hide screen in recents and disable screenshots - Build Type - Source Code - Supporters List - Email - Discord - Bug Report and More - Pinned - Others - Copy - Encrypt Database - Encrypt database during backup - Enter password - Invalid input - Sort Ascending - Sort Descending - Sort notes by their creation time - Select Note - Unsupported Image Size (15mb max) - Vault - Enable encrypted notes vault - continue - Change the app language - System Language - Behavior - Prioritize Editing Mode - Enter edit mode on existing notes - Invalid Password - Donate - Translation - Contribution - Deletes images from app storage when they are removed from the gallery - Gallery Sync - Show only title - Show only the title in preview - 1. Acceptance of Terms - By pressing "Agree" and using Easy Notes, you accept these Terms of Service. If you do not agree, do not use the application. - 2. License - AirNote is distributed under the **GNU General Public License (GPL) version 3**. This means:\n\n- You are free to use, modify, and share the application.\n- If you distribute the application or any modified versions, you must also distribute the source code and retain the GPL license. - 3. Use of the Application - You agree to use AirNote only for lawful purposes and in accordance with these Terms. You must **not**:\n\n- _Use the app for any unlawful purpose_\n- _Violate the rights of others_\n- _Disrupt the functionality or security of the app_ - 4. Intellectual Property - All intellectual property rights in AirNote are owned by us or our licensors. Your use of the app does not grant you ownership of any intellectual property rights. - 5. Disclaimer of Warranties - Easy Notes is provided "as is," without any warranties. We do not guarantee that the app will be free from errors, secure, or uninterrupted. - 6. Limitation of Liability - We are not liable for any damages resulting from your use of AirNote, including indirect, incidental, or consequential damages. - 7. Termination - We may terminate or suspend your access to AirNote if you breach these Terms. Upon termination, your right to use the app will end immediately. - 8. Changes to Terms - We may update these Terms of Service from time to time. Your continued use of AirNote (Easy Notes) after any changes constitutes acceptance of the revised terms. - 9. Contact Us - For any questions about these Terms of Service or AirNote, please contact us at - 10. Privacy Policy - AirNote does not collect any personal data. For more information, please refer to our Privacy Policy available at - Effective Date - Agree - Terms of service - Import - Import other text files as notes - Use monospace font while editing note - Monospace font - Are you sure you want to delete the selected notes - Yes + Import a playlist (m3u) + Failed to import playlist + Imported playlist + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + Discord Integration + Loudly uses the KizzyRPC library to set your Discord account\'s status. This involves using the Discord Gateway connection, which may be considered a violation of Discord\'s TOS. However, there are no known cases of user accounts being suspended for this reason. Use at your own risk.\n\nLoudly will only extract your token, and everything else is stored locally. + Dismiss + Options + Preview + Login failed + Logout + Enable Rich Presence + + About + App version + Checking for updates... + version %1$s available for download. + Downloading... + Upgrade + Try Again + Download Now + App Type + + New version available + Translation Models + Clear translation models + History duration + + 1 second + %d seconds + + Music 3x2 + Music 2x1 + Play/Pause + Version · Developers · License + UI + Content & Account + Player + System + Library + Integrations + System Integration + Total used space + Currently used + Clear cache + Backup · Restore · İmport + History · Search · Screenshot + Downloads · Cache · Images + Sound · Queue · Lyrics + Language · Location · Proxy + Log in · Spotify · Discord + Theme · Slider · Player + Comrade Notifications · Comrade Setup + + Customize the appearance of the app and player + Uses color theme of device + Customize your sound and playback experience + Skip the quiet moments in songs + If the application is cleared from the recently used list, it will stop the music + You have successfully logged in! + + Profile Card Picture + Customize the picture on the profile card + Choose profile image + Ready-made images + Select from gallery + Edit Photo Cancel - App lock (Experimental) - Lock app using password - Setup passcode - Enter Passcode - Pin (6 numbers) - Passcode - Fingerprint (Experimental) - Authentication required - is Locked - UID - Ask for fingerprint on start - Ask for passcode on start - Pattern - Ask for pattern on start - Settup pattern - Enter pattern - Lock Immediately - Lock instantly when app is resumed - Disable swipe - "Disable swiping to preview in edit mode " - Scale Search Bar Width - Make search bar match width of notes - Font Size - Change the size of text in notes - Font Size: %1$d - Preview - This is how your notes will look with the selected font size. - Developer | M. Burak Özcan - Babel Software founder and Android developer - Add new folder - Folder name - Folder color - Add - Move to Folder - No folder available - Delete folder - Are you sure you want to delete the ‘%1$s’ folder? The notes in it will be moved to the ‘All Notes’ section. - Edit folder - Save - Folder tag - It shows the label of the folder to which the notes belong. - Kai - Configure AI • API settings - Use AirNote API - Use AI features with the app\'s built-in API (Recommended) - You API key (BETA) - Check - AI Models (BETA) - Model Choice (BETA) - AI\'s Suggestion + + Send to Developer Close - All Notes - Ask, chat, find with AI... - How to Get Your Own API Key? - From Google AI Studio, you can generate a personal API key with a generous free usage limit. This key belongs only to you and you have full control. - 1. Go to the Google AI Studio website by clicking the button below. - 2. Sign in with your Google account. - 3. On the screen that opens, click the \"Create API key\" button. - 4. You may be asked to create a new project, follow the steps to create it. - 5. Copy the generated long character string. - 6. Paste the copied key into the text box below and press the \“Check\” button. - Go to Google AI Studio - You need an API key to use Perplexity\'s chat API. Once you have your key, paste it here. - 1. Go to the Perplexity AI website. - 2. Log in to your account (or create a new account). - 3. Click on your profile icon in the upper right corner and select the ‘API Keys’ option. - 4. Click the ‘Create New API Key’ button. - 5. Give your key a name (e.g., ‘AirNote’) and create it. - 6. Copy the generated key and paste it into the ‘Perplexity API Key’ field on this screen. - 7. Click the ‘Save and Verify’ button. - Go to Perplexity API Settings - Please enter an API key first. - API key valid and successfully registered! ✅ - Invalid API key. Please check and try again. - Ask a question - Make a note about a topic - Generate ideas - Give me 5 interesting ideas on a random topic - New Version - Update - New update available! - Update App - Change log could not be retrieved. - Failed to receive changelog: %1$s - Checking for updates... - Update Available - New Version Available! - Version %1$s is available for download. - Update Now - Download - Downloading... %1$d% - İnstall - İnstall - Update Failed - Error - Try Again - Try Again - Your App is Up to Date - Updated - The update requires permission to install from unknown sources. - Download link not found. - Innovations - Save Note - Re-Generate - An error occurred during image analysis. - What\'s New - Latest changes - Edit - Preview - Search Folders... - New Folder - Choose Icon - Hide - What\'s New? - Ready to Setup! - Great! You\'re using the latest features. - What\'s New in This Version! - Translate - Original language - Translation failed - Gemini API Key - Perplexity API Key - Save And Check - Successfully verified and recorded! - Last Folder (BETA) - When the application opens, it displays the last folder viewed - Change - Translation Modules - Downloaded - Not Downloaded - Download - No available translation language! Please download a language model from Settings > Kai menu - Translate All Note - API Key Icon - Successfully Verified Icon - %1s Delete? - Are you sure you want to remove this language model from your device? - %1s delete the language model - Error - The list of downloaded language models could not be retrieved. - %1s downloaded - %1s could not be downloaded - %1s deleted - %1s could not be deleted - Error while restoring the database: incorrect password! - All files were successfully imported! - An error occurred while importing files. - An error occurred while importing some files. - Please select the text you want to work on! - An unknown error occurred! - An unexpected error occurred: %1s - First, briefly describe what you need to do during the day. - Downloaded language models could not be retrieved: %1s - Translation failed: %1s - Please select the text you want to translate! - Welcome to AirNote DeX - A better note-taking experience on larger screens! - Folders - Search in notes... - Title... - Write your note here... - Create New Note - Start! - To view or edit, select a note from the list on the left. - DeX Version - AI Service Version - Draft Topic Placeholder - Ask AirNote Kai... - New Chat - No Chat History... - Delete Chat? - Are you sure you want to delete this chat? This action cannot be undone! - Note Assistant - Creative Mind - Academic Research - Professional Strategy - Hello! - AirNote DeX (BETA) - UI • DeX AI - DeX Mode UI - Use the desktop interface in landscape mode on large screens - DeX AI Features - Use your AI assistant and its features in desktop mode - Think - Search - Unleash Your Curiosity! - What topic piques your curiosity? - Send - Create - E.g.: Introduction to Quantum Physics - What topic would you like to create a note about? Briefly describe the topic. - A New AI Note Draft - Unleash Your Curiosity - What would you like to ask AirNote AI? - AirNote Kai may make mistakes. - More information - https://github.com/RRechz/airnote-ai - Not set - Image being analyzed... - Back - Dream Journal - New Dream Note - Emotional Analysis - Explain the Symbols - Interpret My Dream - AirNote Kai - Dream Edition - Your dream could not be found! Please describe your dream. - Official | @AirNoteApp - Gemini 2.5 - Gemini 2.0 - Gemini 1.5 - PPLX 7B - PPLX Sonar-M - Active Model: %1$s - No notes matching your search were found. - I couldn\'t find the %1$s folder you were looking for. - I found these notes: \n%1$s - Search on Perplexity... - The Perplexity API key cannot be empty! - Gemini AI Service - Perplexity AI Service - ATTENTION: This API only supports Perplexity Pro user APIs. - Enabled - Enable Perplexity AI - Select security management - Active - Activate - Fingerprint Verification - Verify to enable - Your device does not support fingerprint recognition. - Vault Setup - Set a password for the vault: - Vault Password - Encryption Type: - Vault created! - The password cannot be empty! - Note Lock Password - Set a password to lock your individual notes. - Note Password - Password Updated - Tap to Setup... - Tap to Activate... - App Lock Password - New Password - Password has been set - Enter at least 4 characters - Fingerprint ia Activated! - First, set a note password in the settings! - To lock your notes, first set a password in the settings. - This note is locked but no password has been set. - Unlock Note - Please enter your password to view this note. - Unlock Selected Notes - Confirm - Notes are locked - Notes are unlocked - - - create a note or create note - analyze or analyze the text or analysis - take notes or take note or take down on notes or take down on note - summarize and take notes or summarize it and add it to the notes - prepare a draft or prepare a draft about or prepare a draft text for me or create a draft - Analyze my file/image or analyze the attached file or Analyze my file/image and take notes - The file is empty or cannot be read - - - Add Image - Add Text File (only ".txt") - - Optimize Writing - Summarize - Make it Shorter - Make it longer - Change Tone - - Offical (lang) - Balanced - Friendly - User API key not found. Please check in settings. - API request failed: %1$s - Hello there! How can I help you? - SUGGESTED - List the Pros and Cons - Create To-Do List - Tell It Simpler - Recommended for imported files - Suggest Title - Take Notes from the Image - - Great, what should we create a draft about? Please write the topic. - - Correct the grammar and expression mistakes in the text below to make it more fluent. Answer only the corrected text:\n\n---\n\n%1$s - Summarize the text below in one or two sentences, keeping the main idea. Answer only the summary text:\n\n---\n\n%1$s - Shorten the following text significantly while preserving its meaning. Reply only with the shortened text:\n\n---\n\n%1$s - Add creative details to the text below to make it longer. Only reply with the extended text:\n\n---\n\n%1$s - AI servers are currently busy. Please try again in a few minutes. - - %1$s\n\n---\n\n%2$s - Rewrite the text below in a professional and formal language. Keep the meaning, only change the tone. Only reply with the final text: - Rewrite the text below in a balanced language, neither too formal nor too sincere. Keep the meaning. Only reply with the conclusion: - Rewrite the text below in a more sincere, warm, conversational and friendly way. Keep the meaning. Only reply with the conclusion: - - A note-taking app user is working on a note titled \"%1$s\". Suggest some creative and inspiring ideas or starting points that fit this title. Keep your answer short and in list format. - Continue the text below, keeping the integrity of meaning and the author\'s style. Only reply to the new text, do not repeat the existing text:\n\n---\n\n\nTitle: %1$s\n\nContent:\n%2$s - Analyze the text below and write a paragraph on how to approach this topic from a completely different perspective. Do not summarize the existing text, offer an alternative perspective:\n\n---\n\n\nTitle: %1$s\n\nContent:\n%2$s - Analyze the text below and list the positive and negative points under the headings \'### Pros\' and \'### Cons\'. Answer only these two headings and items:\n\n\n---\n\n\n\n%1$s - Analyze the text below. If the text mentions tasks to be done during the day, turn them into a to-do list by putting \'[] \' at the beginning of each line. If the text does not mention tasks, answer ONLY \'NO_TASKS\' without typing anything else. Detect the language of the text and create the list in that language:\n\n---\n\n%1$s - Translate the complex text below into simple and fluent language that everyone can understand. Keep the main idea and important details of the text. Answer only the simplified text:\n\n---\n\n%1$s - Suggest 5 creative and attention-grabbing titles that best fit the content of the text below. Give your answer as a numbered list:\n\n---\n\n%1$s - - A user wants to draft a note about the topic \"%1$s\". You MUST give your answer in the following format: TITLE: [insert title here]\n\nCONTENT: [insert note content here] - Analyze the image in this attachment like Sherlock Holmes and create a note describing its content. Your answer should include ‘TITLE:’ and ‘CONTENT:’, with a clear distinction between title and content. - - The user is currently referencing a note titled \'%1$s\'. Answer their questions based on the content of this note. The content of the note is below:\n\n---\n%2$s\n--- - Understood. I have received the content of the note titled \'%1$s\' and will respond with reference to this note. - - You are the dream interpreter of Kai AI, integrated into the AirNote app. Your task is to analyze the dreams told to you, explain the symbols, and interpret their psychological or cultural meanings. Talk only and exclusively about dreams, dream symbols, and dream interpretations. Respond to any questions unrelated to dreams with, “I am here solely to interpret dreams.” Here is the dream you need to interpret:\n\n - You are the dream symbol expert for Kai AI, integrated into the AirNote app. Your task is to find the key symbols (objects, people, places, actions) in the dream described to you and list their possible meanings. Do not provide a general dream interpretation; focus solely on the symbols. Only discuss dream symbols. If asked another question, respond with, “I am only here to explain dream symbols.” Here is the dream whose symbols you need to analyze:\n\n - You are the dream therapist of Kai AI, integrated into the AirNote app. Your task is to analyze the dominant emotional tone (fear, happiness, anxiety, etc.) in the dream described to you and explain what these emotions might mean for the dreamer. Refrain from making general interpretations or explaining symbols; focus solely on the emotions evoked by the dream and their possible psychological underpinnings. Discuss only the emotions of the dream. If asked another question, respond with, “I am here solely to perform an emotional analysis of dreams.” Here is the dream you need to analyze:\n\n - - - You are a robotic, expert, and concise note-taking assistant for an app called AirNote. You MUST be direct and factual. - You MUST follow these rules. This is a strict instruction. - - 1. **NORMAL CONVERSATION:** If the user is just chatting or asking for information (like "summarize this"), respond normally. - - 2. **NOTE MODIFICATION:** If the user explicitly asks to modify a note (e.g., "add", "change", "delete", "rename", "update" the referenced note), you MUST NOT reply with conversational text (like "Okay, I've done it."). - You MUST reply ONLY with the specific XML tag for that action. This is the most important rule. - - --- MODIFICATION TAGS --- - - To REPLACE the note\'s entire content: `...new content...` - - To APPEND (add) to the note\'s content: `...content to add...` - - To RENAME the note\'s title: `...new title...` - - To DELETE the note: `CONFIRMED` - - Example: - User: "@ShoppingList'e 'süt' ekle." - Your ONLY response MUST be: `süt` - - Example: - User: "@MyNote'u sil." - Your ONLY response MUST be: `CONFIRMED` - - Do not add any other text outside these tags if you are modifying. - - You are a creative brainstorming partner and writing assistant integrated into an application called AirNote. Your purpose is to inspire users, generate novel ideas, and help them overcome writer\'s block. Your tone should be encouraging, imaginative, and expansive. Feel free to use lists, metaphors, and suggest unconventional approaches. Engage the user in a creative dialogue. - You are an Academic Researcher AI belonging to Kai AI in the AirNote application. Your primary goal is to assist with academic writing. You must be objective, precise, and evidence-based. Structure your responses logically with clear arguments. Avoid colloquialisms and adopt a formal tone. When analyzing text, focus on logical consistency, strength of argumentation, and clarity. Your responses should be well-structured and suitable for academic papers, essays, and research notes. - You are a professional strategy expert belonging to Kai AI in the AirNote application. Your purpose is to assist with business and professional communication. Your responses must be clear, concise, and goal-oriented. Focus on actionable insights and practical solutions. Adopt a professional and confident tone. When generating or modifying text, prioritize clarity, impact, and audience awareness. Your output should be suitable for business reports, professional emails, presentations, and strategic documents. - - - You are Kai, an intelligent automation assistant integrated into the AirNote app. - Your job is to analyze user requests and convert them into a JSON array of actions to be executed. - You MUST ONLY respond with a valid JSON object in the specified format. - - --- CONTEXT --- - If the user provides the content of an existing note (prefixed with "--- CURRENT NOTE CONTEXT ---"), your actions (APPEND, REWRITE, DELETE) MUST target that note. - If no context is provided, actions like CREATE_NOTE or CREATE_FOLDER should be used. - - --- AVAILABLE TOOLS (ACTIONS) --- - - 1. CREATE_NOTE - - Description: Creates a new note. **Use this ONLY if the user explicitly asks to \'create a note\', \'save as a note\', \'not oluştur\', \'kaydet\', or \'taslak hazırla\'.** - - Parameters: - - \"title\" (String): The title of the note. - - \"content\" (String, optional): The content (Markdown formatted) of the note. - - 2. CREATE_TODO_NOTE - - Description: Creates a note containing a to-do list. - - Parameters: - - \"title\" (String): The title of the to-do list. - - \"tasks\" (Array<String>): An array of task strings. - - 3. CREATE_FOLDER - - Description: Creates a new note folder. - - Parameters: - - \"name\" (String): The name of the folder. - - \"iconName\" (String, optional): Default is "Folder". - - 4. MOVE_NOTE_TO_FOLDER - - Description: Moves an existing note (by title) into an existing folder (by name). - - Parameters: - - \"note_title\" (String): The exact title of the note to move. - - \"folder_name\" (String): The exact name of the target folder. - - 5. CHAT - - Description: Respond with a simple chat message. **Use this if the user asks you to \'write\', \'tell\', or \'söyle\' something *without* asking to save it as a note.** - - Parameters: - - \"response\" (String): The text to display to the user. - - 6. APPEND_TO_NOTE - - Description: Appends (adds) text to the end of the CURRENTLY REFERENCED note. - - Parameters: - - \"content\" (String): The text content to append (Markdown formatted). - - 7. REWRITE_NOTE - - Description: Replaces the entire content of the CURRENTLY REFERENCED note. - - Parameters: - - \"new_content\" (String): The new text content (Markdown formatted) for the note. - - 8. DELETE_NOTE - - Description: Deletes the CURRENTLY REFERENCED note. (No parameters needed) - - 9. QUERY_NOTES - - Description: Searches for notes based on criteria. Use this if the user asks to "find", "list", "search", "ara", "bul", "listele". - - Parameters: - - \"search_term\" (String, optional): A keyword to search for in the note\'s title or content. - - \"folder_name\" (String, optional): The name of the folder to search within. - - --- JSON OUTPUT FORMAT --- - You MUST respond ONLY with a raw JSON object matching this structure: - { - \"thought\": \"A brief analysis of the user\'s request and the planned actions.\", - \"actions\": [ - { \"action_type\": \"ACTION_NAME\", \"param1\": \"value1\", ... } - ], - \"response_message\": \"A confirmation message for the user.\" - } - - --- EXAMPLES (No Context) --- - User: \"Bana \'Alışveriş Listesi\' adında bir not oluştur ve içine süt, ekmek ve yumurta ekle.\" - Your Response: - { - \"thought\": \"User wants a new to-do note. I will use CREATE_TODO_NOTE.\", - \"actions\": [ - { \"action_type\": \"CREATE_TODO_NOTE\", \"title\": \"Alışveriş Listesi\", \"tasks\": [\"süt\", \"ekmek\", \"yumurta\"] } - ], - \"response_message\": \"Elbette, \'Alışveriş Listesi\' notunuzu 3 görevle birlikte oluşturdum.\" - } - - User: \"Kuantum fiziği hakkında bir not taslağı oluştur.\" - Your Response: - { - \"thought\": \"User wants a new note with formatted content about quantum physics. I will use CREATE_NOTE.\", - \"actions\": [ - { - \"action_type\": \"CREATE_NOTE\", - \"title\": \"Kuantum Fiziğine Giriş\", - \"content\": \"## Kuantum Fiziği Nedir?\nKuantum fiziği, maddenin ve enerjinin **atom altı** seviyelerdeki davranışını inceleyen bir bilim dalıdır.\n\n### Temel Kavramlar\n* **Kuantizasyon:** Değerlerin kesikli (ayrı) birimler halinde bulunması.\n* **Dalga-Parçacık İkiliği:** Parçacıkların hem dalga hem de parçacık özelliği göstermesi.\n* **Belirsizlik İlkesi:** Bir parçacığın konumunun ve momentumunun aynı anda tam olarak bilinememesi.\" - } - ], - \"response_message\": \"Elbette, \'Kuantum Fiziğine Giriş\' notunuzu temel başlıklarla birlikte oluşturdum.\" - } - - User: \"Bana bahar hakkında bir şiir yaz.\" - Your Response: - { - \"thought\": \"User wants me to write a poem, but did not ask to save it as a note. I will use the CHAT action.\", - \"actions\": [ - { - \"action_type\": \"CHAT\", - "response": "Elbette, işte bahar hakkında kısa bir şiir:\n\nKuşlar cıvıldar, güneş parlar,\nAğaçlar yeşerir, çiçekler açar,\nBahar gelmiş, hoş gelmiş!" - } - ], - "response_message": "Elbette, işte bahar hakkında kısa bir şiir:\n\nKuşlar cıvıldar, güneş parlar,\nAğaçlar yeşerir, çiçekler açar,\nBahar gelmiş, hoş gelmiş!" - } - - User: \"İçinde \'toplantı\' kelimesi geçen notları bul.\" - Your Response: - { - \"thought\": \"User wants to find notes containing the word \'toplantı\'. I will use QUERY_NOTES.\", - \"actions\": [ - { \"action_type\": \"QUERY_NOTES\", \"search_term\": \"toplantı\" } - ], - \"response_message\": \"\'toplantı\' kelimesini içeren notları arıyorum...\" - } - - - --- EXAMPLES (With Context) --- - [CONTEXT INJECTED BY SYSTEM: --- CURRENT NOTE CONTEXT --- Title: \"Alışveriş Listesi\" Content: \"[ ] süt\n[ ] ekmek\"] - User: \"Bu listeye \'peynir\' ekle.\" - Your Response: - { - \"thought\": \"User wants to add \'peynir\' to the referenced note. I will use APPEND_TO_NOTE.\", - \"actions\": [ - { \"action_type\": \"APPEND_TO_NOTE\", \"content\": \"[ ] peynir\" } - ], - \"response_message\": \"Anlaşıldı, \'Alışveriş Listesi\' notunuza \'peynir\' eklendi.\" - } - - [CONTEXT INJECTED BY SYSTEM: --- CURRENT NOTE CONTEXT --- Title: \"Toplantı Notları\" Content: \"Çok uzun bir paragraf...\"] - User: \"Bu notu özetle.\" - Your Response: - { - \"thought\": \"User wants to summarize the referenced note. I will generate a summary and use REWRITE_NOTE.\", - \"actions\": [ - { \"action_type\": \"REWRITE_NOTE\", \"new_content\": \"## Özet\nToplantının ana fikri şuydu...\" } - ], - \"response_message\": \"Notunuzu başarıyla özetledim.\" - } - - - - You are Kai, an intelligent assistant integrated into the AirNote app. Your primary goal is to assist the user with their notes by understanding their intent. You must analyze the user\'s request based on the provided note context and their message. - INTENT 1: CHAT\nIf the user is asking a question about the note, asking for ideas, or wants a summary WITHOUT explicitly asking to change the note\'s content, use this JSON format:\n{\n \"intent\": \"CHAT\",\n \"response\": \"[Your helpful chat response here]\"\n} - INTENT 2: EDIT_NOTE\nIf the user explicitly asks to change the note (e.g., \"delete the last paragraph\", \"add a summary at the end\", \"fix grammar\", \"continue writing\"), use this JSON format:\n{\n \"intent\": \"EDIT_NOTE\",\n \"new_note_content\": \"[The FULL content of the note after applying the user\'s requested changes]\"\n} - IMPORTANT: You must ONLY respond with raw JSON. Do not include any markdown formatting like ```json or ```. - --- CURRENT NOTE CONTENT ---\n\"\"\"%s\"\"\" - --- USER REQUEST ---\n\"\"\"%s\"\"\" - - - I\'ve updated your note as requested. - Received an invalid response from AI. Please try again. - Of course, I updated the content of the \'@%1$s\' note. - Ok, I added the information you requested to the note \'@%1$s\'. - Understood. I changed the title of note \'@%1$s\' to \'@%2$s\'. - I deleted the note \'@%1$s\' as you requested. - I encountered an issue while changing the note. Please try again. - \ No newline at end of file + Show Error Details + Download Failed + Show Error + Manuel Update + Check the settings page for details + Software Up To Date + + App UI + Select one of 3 different designs + UI Version Selection + Old version design of the application. (App, Player, MiniPlayer) + Compact design created with a more modern approach. (App, Player, MiniPlayer) + Most popular design. (App, Player, MiniPlayer) + Create your own app UI design. (App, Player, MiniPlayer) + Customize UI + + Other settings + Click for other various settings + + Automatically adjusts quality between 45 and 512 Kbps + Offers the highest quality up to 512 Kbps + Sets the quality for average internet speeds between 128 and 256 Kbps. + Limits quality to between 45 and 52 Kbps to save data (Data Saver) + + Could not import + + Text Alignment + Set the position of the text on the card + Gradient Intensity + Corner Round + Profile Picture + Profile Card + Remove Profile Picture + Show Texts + Display the greeting and username text on the profile card + Tap to change + + + Translate (BETA) + Copy + \"%1s\" continue from the album? + Continue with artist \"%1s\"? + Would you like to continue with this flow? + Are you ready for some music? 🎧 + Continue from the last song you listened to: %1s + Tap to open Loudly! + The journey begins! 🚗 + Would you like to play your “Road Songs” playlist? + Time to run! 👟 + Keep up the tempo with your “Energy” playlist. + + + Network Error + Check your internet connection and try again. + Unsupported Content + This song format is not supported on your device. + Live Broadcast Error + There is a problem with the live stream. Please restart. + Playback Failed + An unknown error has occurred. Please try again. + Trying again… + Using a VPN may affect your connection quality. If you are experiencing network problems, disconnect your VPN connection or use a proxy. You can configure this in “Settings >> Content >> Proxy.” + + + Sync local songs successfully + Local scanner + + Scan automatically + Automatically scan for songs when opening the app + + + Additional scanner settings + Strict file names + When enabled extensions will NOT be ignored. Ex. "Song.ogg" will be a different song from "Song.flac" + Configure scanner sensitivity + Match title + Match title and artists + Match title, artists, albums + + + Folders + Flatten subfolders + Disable to preserve the file structure as on disk + + + Tips & Features + Tips · Features + Create your story + Share the song you are listening to on social media with a stylish design created with the dominant colors of the album cover. + Customizable Settings + Make the app your own by replacing the background of the title card on the settings screen with an image from your gallery. + New Song List + View your current playlist and easily switch between songs by swiping up from the player screen (new design exclusive to Player UI 2.0) + Remastered Setings Design + Find what you\'re looking for easily with a new card-based settings design that is more modern and convenient. + Classic Music Widget + Quickly access all controls and song information from your home screen. + Minimalist Music Widget + “Quik” music widget inspired by Android 16\'s Quick Settings button design + Share on Story + Copy song link + Link copied to the clipboard! + Streamed on Loudly + + + Select All + Fetching all playlists… + Import Selected Items + Something went wrong. We’re just as confused as you are. + Well, that’s embarrassing. We have no clue what happened either. + Instructions to get required credentials + 3. Make sure to click "Add" once you\'ve entered the local path above. + 5. Accept the terms of service and click "Save". + You’ll be redirected to a new page. Click on Settings, then copy the Client ID and Client Secret, and paste them into the text fields below in this app. + 2. Enter the necessary details and use http://127.0.0.1:45454 as the Redirect URIs. + 4. Toggle Web API. The checkbox should now have a tick mark. + Now, click the Authorize and Continue button below and login with your account from which you want to import from, and Authorize and Continue, which will redirect you to a site which should start from http://127.0.0.1:45454/?code= + +Paste that entire URL in the below text field which says Authorization Code and click the Authenticate button + 1. Visit spotify for developers Spotify for developers dashboard and click on "Create app". + Login with Spotify API Credentials + Authorize and Continue + Authorization Code + Authenticate + Client secret + Client ID + Import in progress… + Don\'t close the app or go back. This operation does\'t run in the background, so stay put until it\'s done! +DO NOT PANIC IF IT LOOKS STUCK; sometimes retrieval may take some time. + Choose \"Liked Songs\" Destination + Where should the liked songs be imported? + A new playlist named "Liked Songs" + In the default "Liked Songs" + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0e9d4f9 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 324d121..6a42395 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,11 +1,17 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml new file mode 100644 index 0000000..5417418 --- /dev/null +++ b/app/src/main/res/values/values.xml @@ -0,0 +1,8 @@ + + + + @drawable/play + @drawable/pause + @drawable/skip_previous + @drawable/skip_next + \ No newline at end of file diff --git a/app/src/main/res/xml-v25/shortcuts.xml b/app/src/main/res/xml-v25/shortcuts.xml new file mode 100644 index 0000000..1e6fe60 --- /dev/null +++ b/app/src/main/res/xml-v25/shortcuts.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/add_note.xml b/app/src/main/res/xml/add_note.xml deleted file mode 100644 index 69cf91f..0000000 --- a/app/src/main/res/xml/add_note.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..0f739ff --- /dev/null +++ b/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index fa0f996..a20e571 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,12 @@ - + - + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..5f0f93e 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,15 +5,25 @@ --> - + + + - \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..1464dd9 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml deleted file mode 100644 index b97ad71..0000000 --- a/app/src/main/res/xml/locales_config.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/notes.xml b/app/src/main/res/xml/notes.xml deleted file mode 100644 index f1e99ea..0000000 --- a/app/src/main/res/xml/notes.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index 5255178..a4d6653 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -1,6 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml new file mode 100644 index 0000000..9863cce --- /dev/null +++ b/app/src/main/res/xml/widget_info.xml @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6d1e08e..319c277 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ hilt = "2.56.2" hiltCompiler = "1.2.0" hiltAndroidCompiler = "2.56.2" coilCompose = "2.7.0" +coil = "2.7.0" hiltNavigationCompose = "1.2.0" kotlin = "2.0.0" ksp = "2.0.0-1.0.21" @@ -16,12 +17,26 @@ activityCompose = "1.10.1" coreKtx = "1.16.0" coreSplashscreen = "1.0.1" compose = "1.7.8" +composeBom = "2025.01.01" material3 = "1.3.2" navigationCompose = "2.9.1" appcompat = "1.7.1" glanceAppwidget = "1.1.1" material3WindowSizeClassAndroid = "1.3.2" roomCompiler = "2.8.3" +media3 = "1.5.1" +palette = "1.0.0" +lifecycleRuntimeKtx = "2.8.7" +ui = "1.7.8" +uiGraphics = "1.7.8" +uiTooling = "1.7.8" +uiToolingPreview = "1.7.8" +uiTestManifest = "1.7.8" +uiTestJunit4 = "1.7.8" +junit = "4.13.2" +androidxJunit = "1.2.1" +espressoCore = "3.6.1" +kotlinxCoroutines = "1.9.0" [libraries] # Room @@ -35,22 +50,48 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = # AndroidX androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "uiGraphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "uiTestManifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "uiTestJunit4" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } + +# Media3 +androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } + +# Palette +androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" } + +# Coroutines +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } # Compose androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "compose" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } #Hilt hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroidCompiler" } hilt-compile = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltCompiler"} +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hiltAndroidCompiler" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroidCompiler" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } # Coil coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } # Glance androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glanceAppwidget" }