├── .editorconfig ├── .gitignore ├── .gitmodules ├── .idea ├── .gitignore ├── codeInsightSettings.xml ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── deploymentTargetDropDown.xml ├── deploymentTargetSelector.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── ktfmt.xml └── migrations.xml ├── LICENSE ├── README.adoc ├── annotations ├── android │ └── util │ │ └── annotations.xml ├── androidx │ └── core │ │ └── graphics │ │ └── annotations.xml └── java │ ├── lang │ └── annotations.xml │ └── util │ └── annotations.xml ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── org │ │ └── sunsetware │ │ └── phocid │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ ├── Apache-2.0.txt │ │ ├── GPL-3.0.txt │ │ ├── LGPL-3.0.txt │ │ ├── LicenseMappings.json │ │ └── Unicode-3.0.txt │ ├── java │ │ └── org │ │ │ └── sunsetware │ │ │ └── phocid │ │ │ ├── Constants.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainAppWidgetReceiver.kt │ │ │ ├── MainApplication.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── PlaybackService.kt │ │ │ ├── UiManager.kt │ │ │ ├── WidgetConfigureActivity.kt │ │ │ ├── data │ │ │ ├── ArtworkLoader.kt │ │ │ ├── DependencyLicense.kt │ │ │ ├── LibraryIndex.kt │ │ │ ├── Lyrics.kt │ │ │ ├── Media3.kt │ │ │ ├── PersistentUiState.kt │ │ │ ├── PlayerManager.kt │ │ │ ├── Playlist.kt │ │ │ ├── Preferences.kt │ │ │ ├── SaveManager.kt │ │ │ ├── Searchable.kt │ │ │ └── Sortable.kt │ │ │ ├── globals │ │ │ ├── GlobalData.kt │ │ │ ├── Strings.kt │ │ │ └── SystemLocale.kt │ │ │ ├── service │ │ │ ├── CustomizedBitmapLoader.kt │ │ │ └── CustomizedPlayer.kt │ │ │ ├── ui │ │ │ ├── components │ │ │ │ ├── AnimatedForwardBackwardTransition.kt │ │ │ │ ├── ArtworkImage.kt │ │ │ │ ├── BinaryDragState.kt │ │ │ │ ├── DefaultPagerState.kt │ │ │ │ ├── DialogBase.kt │ │ │ │ ├── EmptyListIndicator.kt │ │ │ │ ├── FloatingToolbar.kt │ │ │ │ ├── IndefiniteSnackbar.kt │ │ │ │ ├── LibraryListItem.kt │ │ │ │ ├── MultiSelectState.kt │ │ │ │ ├── NegativePadding.kt │ │ │ │ ├── OverflowMenu.kt │ │ │ │ ├── ProgressSlider.kt │ │ │ │ ├── Scrollbar.kt │ │ │ │ ├── SelectBox.kt │ │ │ │ ├── SingleLineText.kt │ │ │ │ ├── SortingOptionPicker.kt │ │ │ │ ├── SteppedSliderWithNumber.kt │ │ │ │ ├── SwipeToDismiss.kt │ │ │ │ ├── TabIndicator.kt │ │ │ │ ├── TrackCarousel.kt │ │ │ │ └── UtilityListItem.kt │ │ │ ├── theme │ │ │ │ ├── Animation.kt │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ └── views │ │ │ │ ├── MenuItem.kt │ │ │ │ ├── PermissionRequestDialog.kt │ │ │ │ ├── SpeedAndPitchDialog.kt │ │ │ │ ├── TimerDialog.kt │ │ │ │ ├── TrackDetailsDialog.kt │ │ │ │ ├── library │ │ │ │ ├── LibraryScreen.kt │ │ │ │ ├── LibraryScreenCollectionView.kt │ │ │ │ ├── LibraryScreenHomeView.kt │ │ │ │ └── LibraryTrackClickAction.kt │ │ │ │ ├── player │ │ │ │ ├── PlayerScreen.kt │ │ │ │ ├── PlayerScreenArtwork.kt │ │ │ │ ├── PlayerScreenControls.kt │ │ │ │ ├── PlayerScreenLayout.kt │ │ │ │ ├── PlayerScreenLayoutDefault.kt │ │ │ │ ├── PlayerScreenLayoutNoQueue.kt │ │ │ │ ├── PlayerScreenLyricsOverlay.kt │ │ │ │ ├── PlayerScreenLyricsView.kt │ │ │ │ ├── PlayerScreenQueue.kt │ │ │ │ └── PlayerScreenTopBar.kt │ │ │ │ ├── playlist │ │ │ │ ├── PlaylistEditScreen.kt │ │ │ │ ├── PlaylistIoScreen.kt │ │ │ │ ├── PlaylistIoSettingsDialog.kt │ │ │ │ ├── PlaylistIoSyncDialogs.kt │ │ │ │ └── PlaylistManagementDialogs.kt │ │ │ │ └── preferences │ │ │ │ ├── PreferencesFolderPickerDialog.kt │ │ │ │ ├── PreferencesIndexingRulesDialogs.kt │ │ │ │ ├── PreferencesLicenseDialog.kt │ │ │ │ ├── PreferencesScreen.kt │ │ │ │ ├── PreferencesSingleChoiceDialog.kt │ │ │ │ ├── PreferencesSortingLocaleDialog.kt │ │ │ │ ├── PreferencesSteppedSliderDialog.kt │ │ │ │ ├── PreferencesTabsDialog.kt │ │ │ │ ├── PreferencesThemeColorDialog.kt │ │ │ │ └── PreferencesThirdPartyLicensesDialog.kt │ │ │ └── utils │ │ │ ├── AsyncCache.kt │ │ │ ├── Boxed.kt │ │ │ ├── Collections.kt │ │ │ ├── Initialism.kt │ │ │ ├── Math.kt │ │ │ ├── Random.kt │ │ │ ├── SafFile.kt │ │ │ ├── Serializers.kt │ │ │ ├── StateFlow.kt │ │ │ └── String.kt │ └── res │ │ ├── drawable-hdpi │ │ └── notification_icon.png │ │ ├── drawable-mdpi │ │ └── notification_icon.png │ │ ├── drawable-xhdpi │ │ └── notification_icon.png │ │ ├── drawable-xxhdpi │ │ └── notification_icon.png │ │ ├── drawable-xxxhdpi │ │ └── notification_icon.png │ │ ├── drawable │ │ ├── player_next.xml │ │ ├── player_pause.xml │ │ ├── player_play.xml │ │ ├── player_previous.xml │ │ ├── player_repeat.xml │ │ ├── player_repeat_one.xml │ │ ├── player_shuffle.xml │ │ ├── shortcut_continue.xml │ │ ├── shortcut_shuffle.xml │ │ ├── widget_preview_extra_large.xml │ │ ├── widget_preview_large.xml │ │ ├── widget_preview_medium.xml │ │ └── widget_preview_small.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_background.webp │ │ ├── ic_launcher_foreground.webp │ │ ├── ic_launcher_monochrome.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_background.webp │ │ ├── ic_launcher_foreground.webp │ │ ├── ic_launcher_monochrome.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_background.webp │ │ ├── ic_launcher_foreground.webp │ │ ├── ic_launcher_monochrome.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_background.webp │ │ ├── ic_launcher_foreground.webp │ │ ├── ic_launcher_monochrome.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ ├── ic_launcher_background.webp │ │ ├── ic_launcher_foreground.webp │ │ ├── ic_launcher_monochrome.webp │ │ └── ic_launcher_round.webp │ │ ├── values-b+zh+Hans │ │ └── strings.xml │ │ ├── values-b+zh+Hant │ │ └── strings.xml │ │ ├── values-v31 │ │ └── themes.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── media3.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ ├── xml-v31 │ │ └── my_app_widget_info.xml │ │ └── xml │ │ ├── automotive_app_desc.xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ ├── my_app_widget_info.xml │ │ └── shortcuts.xml │ └── test │ └── java │ └── org │ └── sunsetware │ └── phocid │ ├── AsyncCacheTest.kt │ ├── DependencyLicenseTest.kt │ ├── InitialismTest.kt │ ├── LibraryIndexTest.kt │ ├── LyricsTest.kt │ ├── PlaylistIoTest.kt │ └── SearchableTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── metadata ├── en-US │ ├── changelogs │ │ └── 20241121.txt │ ├── full_description.txt │ ├── images │ │ ├── icon.png │ │ └── phoneScreenshots │ │ │ ├── 00-screenshot-home-tracks.png │ │ │ ├── 01-screenshot-home-albums.png │ │ │ ├── 02-screenshot-home-folders.png │ │ │ ├── 03-screenshot-search.png │ │ │ └── 04-screenshot-player.png │ ├── short_description.txt │ └── title.txt └── zh-CN │ ├── changelogs │ └── 20241121.txt │ ├── full_description.txt │ └── short_description.txt ├── settings.gradle.kts └── stability_config.conf /.editorconfig: -------------------------------------------------------------------------------- 1 | # This .editorconfig section approximates ktfmt's formatting rules. You can include it in an 2 | # existing .editorconfig file or use it standalone by copying it to /.editorconfig 3 | # and making sure your editor is set to read settings from .editorconfig files. 4 | # 5 | # It includes editor-specific config options for IntelliJ IDEA. 6 | # 7 | # If any option is wrong, PR are welcome 8 | 9 | [{*.kt,*.kts}] 10 | indent_style = space 11 | insert_final_newline = true 12 | max_line_length = 100 13 | indent_size = 4 14 | ij_continuation_indent_size = 4 15 | ij_java_names_count_to_use_import_on_demand = 9999 16 | ij_kotlin_align_in_columns_case_branch = false 17 | ij_kotlin_align_multiline_binary_operation = false 18 | ij_kotlin_align_multiline_extends_list = false 19 | ij_kotlin_align_multiline_method_parentheses = false 20 | ij_kotlin_align_multiline_parameters = true 21 | ij_kotlin_align_multiline_parameters_in_calls = false 22 | ij_kotlin_allow_trailing_comma = true 23 | ij_kotlin_allow_trailing_comma_on_call_site = true 24 | ij_kotlin_assignment_wrap = normal 25 | ij_kotlin_blank_lines_after_class_header = 0 26 | ij_kotlin_blank_lines_around_block_when_branches = 0 27 | ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 28 | ij_kotlin_block_comment_at_first_column = true 29 | ij_kotlin_call_parameters_new_line_after_left_paren = true 30 | ij_kotlin_call_parameters_right_paren_on_new_line = false 31 | ij_kotlin_call_parameters_wrap = on_every_item 32 | ij_kotlin_catch_on_new_line = false 33 | ij_kotlin_class_annotation_wrap = split_into_lines 34 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 35 | ij_kotlin_continuation_indent_for_chained_calls = true 36 | ij_kotlin_continuation_indent_for_expression_bodies = true 37 | ij_kotlin_continuation_indent_in_argument_lists = true 38 | ij_kotlin_continuation_indent_in_elvis = false 39 | ij_kotlin_continuation_indent_in_if_conditions = false 40 | ij_kotlin_continuation_indent_in_parameter_lists = false 41 | ij_kotlin_continuation_indent_in_supertype_lists = false 42 | ij_kotlin_else_on_new_line = false 43 | ij_kotlin_enum_constants_wrap = off 44 | ij_kotlin_extends_list_wrap = normal 45 | ij_kotlin_field_annotation_wrap = off 46 | ij_kotlin_finally_on_new_line = false 47 | ij_kotlin_if_rparen_on_new_line = false 48 | ij_kotlin_import_nested_classes = false 49 | ij_kotlin_imports_layout = * 50 | ij_kotlin_insert_whitespaces_in_simple_one_line_method = true 51 | ij_kotlin_keep_blank_lines_before_right_brace = 2 52 | ij_kotlin_keep_blank_lines_in_code = 2 53 | ij_kotlin_keep_blank_lines_in_declarations = 2 54 | ij_kotlin_keep_first_column_comment = true 55 | ij_kotlin_keep_indents_on_empty_lines = false 56 | ij_kotlin_keep_line_breaks = true 57 | ij_kotlin_lbrace_on_next_line = false 58 | ij_kotlin_line_comment_add_space = false 59 | ij_kotlin_line_comment_at_first_column = true 60 | ij_kotlin_method_annotation_wrap = split_into_lines 61 | ij_kotlin_method_call_chain_wrap = normal 62 | ij_kotlin_method_parameters_new_line_after_left_paren = true 63 | ij_kotlin_method_parameters_right_paren_on_new_line = true 64 | ij_kotlin_method_parameters_wrap = on_every_item 65 | ij_kotlin_name_count_to_use_star_import = 9999 66 | ij_kotlin_name_count_to_use_star_import_for_members = 9999 67 | ij_kotlin_parameter_annotation_wrap = off 68 | ij_kotlin_space_after_comma = true 69 | ij_kotlin_space_after_extend_colon = true 70 | ij_kotlin_space_after_type_colon = true 71 | ij_kotlin_space_before_catch_parentheses = true 72 | ij_kotlin_space_before_comma = false 73 | ij_kotlin_space_before_extend_colon = true 74 | ij_kotlin_space_before_for_parentheses = true 75 | ij_kotlin_space_before_if_parentheses = true 76 | ij_kotlin_space_before_lambda_arrow = true 77 | ij_kotlin_space_before_type_colon = false 78 | ij_kotlin_space_before_when_parentheses = true 79 | ij_kotlin_space_before_while_parentheses = true 80 | ij_kotlin_spaces_around_additive_operators = true 81 | ij_kotlin_spaces_around_assignment_operators = true 82 | ij_kotlin_spaces_around_equality_operators = true 83 | ij_kotlin_spaces_around_function_type_arrow = true 84 | ij_kotlin_spaces_around_logical_operators = true 85 | ij_kotlin_spaces_around_multiplicative_operators = true 86 | ij_kotlin_spaces_around_range = false 87 | ij_kotlin_spaces_around_relational_operators = true 88 | ij_kotlin_spaces_around_unary_operator = false 89 | ij_kotlin_spaces_around_when_arrow = true 90 | ij_kotlin_variable_annotation_wrap = off 91 | ij_kotlin_while_on_new_line = false 92 | ij_kotlin_wrap_elvis_expressions = 1 93 | ij_kotlin_wrap_expression_body_functions = 1 94 | ij_kotlin_wrap_first_method_in_call_chain = false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/deploymentTargetDropDown.xml 2 | .idea/deploymentTargetSelector.xml 3 | .idea/runConfigurations.xml 4 | # https://issuetracker.google.com/issues/346566179 5 | .idea/other.xml 6 | 7 | # Generated third party license report 8 | /app/src/main/assets/open_source_licenses.* 9 | 10 | *.svg 11 | *.png 12 | *.webp 13 | !/app/src/main/res/** 14 | !/metadata/** 15 | 16 | # Created by https://www.toptal.com/developers/gitignore/api/androidstudio,windows,linux,macos 17 | # Edit at https://www.toptal.com/developers/gitignore?templates=androidstudio,windows,linux,macos 18 | 19 | ### Linux ### 20 | *~ 21 | 22 | # temporary files which can be created if a process still has a handle open of a deleted file 23 | .fuse_hidden* 24 | 25 | # KDE directory preferences 26 | .directory 27 | 28 | # Linux trash folder which might appear on any partition or disk 29 | .Trash-* 30 | 31 | # .nfs files are created when an open file is removed but is still being accessed 32 | .nfs* 33 | 34 | ### macOS ### 35 | # General 36 | .DS_Store 37 | .AppleDouble 38 | .LSOverride 39 | 40 | # Icon must end with two \r 41 | Icon 42 | 43 | 44 | # Thumbnails 45 | ._* 46 | 47 | # Files that might appear in the root of a volume 48 | .DocumentRevisions-V100 49 | .fseventsd 50 | .Spotlight-V100 51 | .TemporaryItems 52 | .Trashes 53 | .VolumeIcon.icns 54 | .com.apple.timemachine.donotpresent 55 | 56 | # Directories potentially created on remote AFP share 57 | .AppleDB 58 | .AppleDesktop 59 | Network Trash Folder 60 | Temporary Items 61 | .apdisk 62 | 63 | ### macOS Patch ### 64 | # iCloud generated files 65 | *.icloud 66 | 67 | ### Windows ### 68 | # Windows thumbnail cache files 69 | Thumbs.db 70 | Thumbs.db:encryptable 71 | ehthumbs.db 72 | ehthumbs_vista.db 73 | 74 | # Dump file 75 | *.stackdump 76 | 77 | # Folder config file 78 | [Dd]esktop.ini 79 | 80 | # Recycle Bin used on file shares 81 | $RECYCLE.BIN/ 82 | 83 | # Windows Installer files 84 | *.cab 85 | *.msi 86 | *.msix 87 | *.msm 88 | *.msp 89 | 90 | # Windows shortcuts 91 | *.lnk 92 | 93 | ### AndroidStudio ### 94 | # Covers files to be ignored for android development using Android Studio. 95 | 96 | # Built application files 97 | *.apk 98 | *.ap_ 99 | *.aab 100 | 101 | # Files for the ART/Dalvik VM 102 | *.dex 103 | 104 | # Java class files 105 | *.class 106 | 107 | # Generated files 108 | bin/ 109 | gen/ 110 | out/ 111 | 112 | # Gradle files 113 | .gradle 114 | .gradle/ 115 | build/ 116 | 117 | # Signing files 118 | .signing/ 119 | 120 | # Local configuration file (sdk path, etc) 121 | local.properties 122 | 123 | # Proguard folder generated by Eclipse 124 | proguard/ 125 | 126 | # Log Files 127 | *.log 128 | 129 | # Android Studio 130 | /*/build/ 131 | /*/local.properties 132 | /*/out 133 | /*/*/build 134 | /*/*/production 135 | captures/ 136 | .navigation/ 137 | *.ipr 138 | *.swp 139 | 140 | # Keystore files 141 | *.jks 142 | *.keystore 143 | 144 | # Google Services (e.g. APIs or Firebase) 145 | # google-services.json 146 | 147 | # Android Patch 148 | gen-external-apklibs 149 | 150 | # External native build folder generated in Android Studio 2.2 and later 151 | .externalNativeBuild 152 | 153 | # NDK 154 | obj/ 155 | 156 | # IntelliJ IDEA 157 | *.iml 158 | *.iws 159 | /out/ 160 | 161 | # User-specific configurations 162 | .idea/caches/ 163 | .idea/libraries/ 164 | .idea/shelf/ 165 | .idea/workspace.xml 166 | .idea/tasks.xml 167 | .idea/.name 168 | .idea/compiler.xml 169 | .idea/copyright/profiles_settings.xml 170 | .idea/encodings.xml 171 | .idea/misc.xml 172 | .idea/modules.xml 173 | .idea/scopes/scope_settings.xml 174 | .idea/dictionaries 175 | .idea/vcs.xml 176 | .idea/jsLibraryMappings.xml 177 | .idea/datasources.xml 178 | .idea/dataSources.ids 179 | .idea/sqlDataSources.xml 180 | .idea/dynamic.xml 181 | .idea/uiDesigner.xml 182 | .idea/assetWizardSettings.xml 183 | .idea/gradle.xml 184 | .idea/jarRepositories.xml 185 | .idea/navEditor.xml 186 | 187 | # Legacy Eclipse project files 188 | .classpath 189 | .project 190 | .cproject 191 | .settings/ 192 | 193 | # Mobile Tools for Java (J2ME) 194 | .mtj.tmp/ 195 | 196 | # Package Files # 197 | *.war 198 | *.ear 199 | 200 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) 201 | hs_err_pid* 202 | 203 | ## Plugin-specific files: 204 | 205 | # mpeltonen/sbt-idea plugin 206 | .idea_modules/ 207 | 208 | # JIRA plugin 209 | atlassian-ide-plugin.xml 210 | 211 | # Mongo Explorer plugin 212 | .idea/mongoSettings.xml 213 | 214 | # Crashlytics plugin (for Android Studio and IntelliJ) 215 | com_crashlytics_export_strings.xml 216 | crashlytics.properties 217 | crashlytics-build.properties 218 | fabric.properties 219 | 220 | ### AndroidStudio Patch ### 221 | 222 | !/gradle/wrapper/gradle-wrapper.jar 223 | 224 | # End of https://www.toptal.com/developers/gitignore/api/androidstudio,windows,linux,macos 225 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/OpusMetadataIo"] 2 | path = deps/OpusMetadataIo 3 | url = https://github.com/TJYSunset/OpusMetadataIo 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/codeInsightSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | androidx.media3.common.util.Log 6 | com.ibm.icu.impl.number.Modifier 7 | java.lang.reflect.Modifier 8 | java.nio.file.WatchEvent.Modifier 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/deploymentTargetDropDown.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 79 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/ktfmt.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /annotations/android/util/annotations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /annotations/androidx/core/graphics/annotations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /annotations/java/lang/annotations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /annotations/java/util/annotations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.androidApplication) 3 | alias(libs.plugins.jetbrainsKotlinAndroid) 4 | alias(libs.plugins.compose.compiler) 5 | kotlin("plugin.serialization") version "2.0.0" 6 | id("com.ncorti.ktfmt.gradle") version "0.22.0" 7 | id("com.jaredsburrows.license") version "0.9.8" 8 | } 9 | 10 | android { 11 | namespace = "org.sunsetware.phocid" 12 | compileSdk = 35 13 | 14 | defaultConfig { 15 | applicationId = "org.sunsetware.phocid" 16 | minSdk = 30 17 | targetSdk = 35 18 | versionCode = 20250603 19 | versionName = "20250603" 20 | 21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 22 | vectorDrawables { useSupportLibrary = true } 23 | } 24 | 25 | buildTypes { 26 | debug { isPseudoLocalesEnabled = true } 27 | release { 28 | isShrinkResources = true 29 | isMinifyEnabled = true 30 | proguardFiles( 31 | getDefaultProguardFile("proguard-android-optimize.txt"), 32 | "proguard-rules.pro", 33 | ) 34 | signingConfig = signingConfigs.getByName("debug") 35 | } 36 | } 37 | compileOptions { 38 | sourceCompatibility = JavaVersion.VERSION_1_8 39 | targetCompatibility = JavaVersion.VERSION_1_8 40 | } 41 | kotlinOptions { jvmTarget = "1.8" } 42 | buildFeatures { 43 | compose = true 44 | buildConfig = true 45 | } 46 | composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } 47 | packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } 48 | dependenciesInfo { 49 | // Disables dependency metadata when building APKs. 50 | includeInApk = false 51 | // Disables dependency metadata when building Android App Bundles. 52 | includeInBundle = false 53 | } 54 | } 55 | 56 | dependencies { 57 | implementation(libs.accompanist.permissions) 58 | implementation(libs.androidx.activity.compose) 59 | implementation(libs.androidx.activity.ktx) 60 | implementation(libs.androidx.core.ktx) 61 | implementation(libs.androidx.lifecycle.runtime.compose) 62 | implementation(libs.androidx.lifecycle.runtime.ktx) 63 | implementation(libs.androidx.lifecycle.viewmodel.compose) 64 | implementation(libs.androidx.material.icons.extended) 65 | implementation(libs.androidx.material3) 66 | implementation(libs.androidx.media3.common) 67 | implementation(libs.androidx.media3.exoplayer) 68 | implementation(libs.androidx.media3.session) 69 | implementation(libs.androidx.palette.ktx) 70 | implementation(libs.androidx.ui) 71 | implementation(libs.androidx.ui.graphics) 72 | implementation(libs.androidx.ui.tooling.preview) 73 | implementation(libs.androidx.glance.appwidget) 74 | implementation(libs.androidx.glance.material3) 75 | // TODO: Maybe requiring a dependency just for `FilenameUtils` is too much 76 | implementation(libs.commons.io) 77 | implementation(libs.core.splashscreen) 78 | // ICU can't be mocked by Robolectric, WTF 79 | implementation(libs.icu4j) 80 | implementation(libs.kotlinx.serialization.cbor) 81 | implementation(libs.kotlinx.serialization.json) 82 | implementation(platform(libs.androidx.compose.bom)) 83 | implementation(libs.jaudiotagger) 84 | implementation(libs.reorderable) 85 | //noinspection UseTomlInstead 86 | implementation("org.sunsetware.omio:omio") 87 | 88 | testImplementation(libs.junit) 89 | testImplementation(libs.robolectric) 90 | testImplementation(libs.assertj.core) 91 | 92 | androidTestImplementation(libs.androidx.junit) 93 | androidTestImplementation(libs.androidx.espresso.core) 94 | androidTestImplementation(platform(libs.androidx.compose.bom)) 95 | androidTestImplementation(libs.androidx.ui.test.junit4) 96 | 97 | debugImplementation(libs.androidx.ui.tooling) 98 | debugImplementation(libs.androidx.ui.test.manifest) 99 | } 100 | 101 | tasks.register("customSetup") { dependsOn("licenseReleaseReport") } 102 | 103 | tasks.preBuild { dependsOn("customSetup") } 104 | 105 | tasks.assembleUnitTest { dependsOn("customSetup") } 106 | 107 | composeCompiler { 108 | stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf") 109 | } 110 | 111 | ktfmt { kotlinLangStyle() } 112 | 113 | licenseReport { 114 | // Generate reports 115 | generateCsvReport = false 116 | generateHtmlReport = false 117 | generateJsonReport = true 118 | generateTextReport = false 119 | 120 | // Copy reports - These options are ignored for Java projects 121 | copyCsvReportToAssets = false 122 | copyHtmlReportToAssets = false 123 | copyJsonReportToAssets = true 124 | copyTextReportToAssets = false 125 | useVariantSpecificAssetDirs = false 126 | 127 | // Ignore licenses for certain artifact patterns 128 | ignoredPatterns = setOf() 129 | 130 | // Show versions in the report - default is false 131 | showVersions = true 132 | } 133 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | # Obfuscation provides absolutely zero size reduction. 24 | -dontobfuscate 25 | 26 | # Hack to fix VerifyError for Jetpack Compose. This doesn't seem to impact binary size anyway. 27 | -keep class org.sunsetware.phocid.** { *; } 28 | 29 | # This library uses reflection. 30 | -keep class org.jaudiotagger.** { *; } 31 | -keep class org.jcodec.** { *; } 32 | -dontwarn java.awt.Graphics2D 33 | -dontwarn java.awt.Image 34 | -dontwarn java.awt.geom.AffineTransform 35 | -dontwarn java.awt.image.BufferedImage 36 | -dontwarn java.awt.image.ImageObserver 37 | -dontwarn java.awt.image.RenderedImage 38 | -dontwarn javax.imageio.ImageIO 39 | -dontwarn javax.imageio.ImageWriter 40 | -dontwarn javax.imageio.stream.ImageInputStream 41 | -dontwarn javax.imageio.stream.ImageOutputStream 42 | -dontwarn javax.swing.filechooser.FileFilter 43 | -dontwarn sun.security.action.GetPropertyAction 44 | -------------------------------------------------------------------------------- /app/src/androidTest/java/org/sunsetware/phocid/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("org.sunsetware.phocid", appContext.packageName) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 32 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 67 | 68 | 72 | 73 | 74 | 75 | 76 | 77 | 81 | 82 | 83 | 84 | 85 | 86 | 89 | 90 | 91 | 92 | 95 | 96 | 97 | 100 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /app/src/main/assets/LicenseMappings.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectMappings": { 3 | }, 4 | "urlMappings": { 5 | "http://www.apache.org/licenses/LICENSE-2.0.txt": "Apache-2.0.txt", 6 | "https://www.apache.org/licenses/LICENSE-2.0.txt": "Apache-2.0.txt", 7 | "https://raw.githubusercontent.com/unicode-org/icu/maint/maint-76/LICENSE": "Unicode-3.0.txt", 8 | "http://www.gnu.org/copyleft/lesser.html": "LGPL-3.0.txt" 9 | }, 10 | "otherMappings": [ 11 | { 12 | "first": { 13 | "project": "SquigglyProgress.kt (modified)", 14 | "developers": [ 15 | "The Android Open Source Project" 16 | ], 17 | "url": "https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android14-release/packages/SystemUI/src/com/android/systemui/media/controls/ui/SquigglyProgress.kt" 18 | }, 19 | "second": [ 20 | "Apache-2.0.txt" 21 | ] 22 | }, 23 | { 24 | "first": { 25 | "project": "OpusMetadataIo", 26 | "developers": [ 27 | "tjysunset " 28 | ], 29 | "url": "https://github.com/TJYSunset/OpusMetadataIo" 30 | }, 31 | "second": [ 32 | "Apache-2.0.txt" 33 | ] 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/Constants.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid 2 | 3 | import android.os.Build 4 | import androidx.compose.ui.unit.dp 5 | 6 | val READ_PERMISSION = 7 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) 8 | android.Manifest.permission.READ_MEDIA_AUDIO 9 | else android.Manifest.permission.READ_EXTERNAL_STORAGE 10 | 11 | const val PREFERENCES_FILE_NAME = "preferences" 12 | const val PLAYLISTS_FILE_NAME = "playlists" 13 | const val TRACK_INDEX_FILE_NAME = "trackIndex" 14 | const val PLAYER_STATE_FILE_NAME = "playerState" 15 | const val UI_STATE_FILE_NAME = "uiState" 16 | 17 | const val UNSHUFFLED_INDEX_KEY = "originalIndex" 18 | const val SET_TIMER_COMMAND = "setTimer" 19 | const val TIMER_TARGET_KEY = "timerTarget" 20 | const val TIMER_FINISH_LAST_TRACK_KEY = "timerFinishLastTrack" 21 | const val FILE_PATH_KEY = "filePath" 22 | /** 23 | * Used instead of [androidx.media3.common.MediaMetadata.artworkUri]; Setting the latter would break 24 | * Android Auto 25 | */ 26 | const val URI_KEY = "bitmapUri" 27 | const val AUDIO_SESSION_ID_KEY = "audioSessionId" 28 | 29 | const val ROOT_MEDIA_ID = "root" 30 | 31 | const val SHORTCUT_CONTINUE = "org.sunsetware.phocid.CONTINUE" 32 | const val SHORTCUT_SHUFFLE = "org.sunsetware.phocid.SHUFFLE" 33 | 34 | const val UNKNOWN = "" 35 | 36 | val DRAG_THRESHOLD = 32.dp 37 | const val TNUM = "tnum" 38 | 39 | const val DEPENDENCY_INFOS_FILE_NAME = "open_source_licenses.json" 40 | const val LICENSE_MAPPINGS_FILE_NAME = "LicenseMappings.json" 41 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/data/DependencyLicense.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.data 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.Immutable 5 | import androidx.compose.runtime.Stable 6 | import kotlinx.serialization.SerialName 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.json.Json 9 | import org.sunsetware.phocid.DEPENDENCY_INFOS_FILE_NAME 10 | import org.sunsetware.phocid.LICENSE_MAPPINGS_FILE_NAME 11 | import org.sunsetware.phocid.utils.CaseInsensitiveMap 12 | 13 | @Immutable 14 | @Serializable 15 | data class DependencyInfo( 16 | val project: String, 17 | val description: String? = null, 18 | val version: String? = null, 19 | val developers: List, 20 | val url: String? = null, 21 | val licenses: List? = null, 22 | ) 23 | 24 | @Immutable 25 | @Serializable 26 | data class License(val license: String, @SerialName("license_url") val licenseUrl: String) 27 | 28 | @Serializable 29 | data class LicenseMappings( 30 | val projectMappings: CaseInsensitiveMap>, 31 | val urlMappings: CaseInsensitiveMap, 32 | val otherMappings: List>>, 33 | ) 34 | 35 | @Stable 36 | inline fun listDependencies( 37 | crossinline readFile: (String) -> String 38 | ): List>> { 39 | @Suppress("JSON_FORMAT_REDUNDANT") 40 | val dependencyInfos = 41 | Json { ignoreUnknownKeys = true } 42 | .decodeFromString>(readFile(DEPENDENCY_INFOS_FILE_NAME)) 43 | val licenseMappings = 44 | Json.decodeFromString(readFile(LICENSE_MAPPINGS_FILE_NAME)) 45 | val licenseTexts = 46 | (licenseMappings.projectMappings.values.flatMap { it } + 47 | licenseMappings.urlMappings.values + 48 | licenseMappings.otherMappings.flatMap { it.second }) 49 | .distinct() 50 | .associateWith { readFile(it) } 51 | return dependencyInfos.map { dependency -> 52 | val licenseNames = 53 | licenseMappings.projectMappings[dependency.project] 54 | ?: dependency.licenses!!.map { 55 | requireNotNull(licenseMappings.urlMappings[it.licenseUrl]) { 56 | "No license name found for ${dependency.project}" 57 | } 58 | } 59 | Pair( 60 | dependency, 61 | licenseNames.map { 62 | requireNotNull(licenseTexts[it]) { "No license text found for $it" } 63 | }, 64 | ) 65 | } + 66 | licenseMappings.otherMappings.map { (dependency, licenses) -> 67 | dependency to 68 | licenses.map { 69 | requireNotNull(licenseTexts[it]) { "No license text found for $it" } 70 | } 71 | } 72 | } 73 | 74 | @Stable 75 | fun listDependencies(context: Context): List>> { 76 | return listDependencies { context.assets.open(it).readBytes().decodeToString() } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/data/Lyrics.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.data 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import kotlin.time.Duration 6 | import kotlin.time.Duration.Companion.milliseconds 7 | import kotlin.time.Duration.Companion.minutes 8 | import kotlin.time.Duration.Companion.seconds 9 | import org.sunsetware.phocid.utils.decodeWithCharsetName 10 | import org.sunsetware.phocid.utils.trimAndNormalize 11 | 12 | @Immutable 13 | data class Lyrics(val lines: List>) { 14 | @Stable 15 | fun getLineIndex(time: Duration): Int? { 16 | val search = lines.binarySearchBy(time) { it.first } 17 | return when { 18 | search >= 0 -> search 19 | search < -1 -> -search - 2 20 | else -> null 21 | }?.let { 22 | var result = it 23 | while (result > 0 && lines[result - 1].first == lines[result].first) { 24 | result-- 25 | } 26 | result 27 | } 28 | } 29 | } 30 | 31 | private val lrcLineRegex = 32 | Regex("""^(?(?:\[[0-9]+:[0-9]{1,2}\.[0-9]{2,3}])+)(?.*)$""") 33 | private val lrcTimestampRegex = 34 | Regex( 35 | """\[(?[0-9]+):(?[0-9]{1,2})\.((?[0-9]{3})|(?[0-9]{2}))]""" 36 | ) 37 | 38 | @Stable 39 | fun parseLrc(lrc: ByteArray, charsetName: String?): Lyrics { 40 | return parseLrc(lrc.decodeWithCharsetName(charsetName)) 41 | } 42 | 43 | @Stable 44 | fun parseLrc(lrc: String): Lyrics { 45 | return Lyrics( 46 | lrc.lines() 47 | .flatMap { line -> 48 | lrcLineRegex.matchEntire(line.trimAndNormalize())?.let { m1 -> 49 | val text = m1.groups["text"]!!.value.trim() 50 | lrcTimestampRegex 51 | .findAll(m1.groups["timestamps"]!!.value) 52 | .map { m2 -> 53 | val timestamp = 54 | m2.groups["minutes"]!!.value.toInt().minutes + 55 | m2.groups["seconds"]!!.value.toInt().seconds + 56 | (m2.groups["milliseconds"]?.value?.toInt()?.milliseconds 57 | ?: (m2.groups["centiseconds"]!!.value.toInt() * 10) 58 | .milliseconds) 59 | Pair(timestamp, text) 60 | } 61 | .toList() 62 | } ?: emptyList() 63 | } 64 | .sortedBy { it.first } 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/data/PersistentUiState.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.data 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class PersistentUiState( 7 | val libraryScreenHomeViewPage: Int = 0, 8 | val playerScreenUseLyricsView: Boolean = false, 9 | val playerTimerSettings: PlayerTimerSettings = PlayerTimerSettings(), 10 | val playlistIoSyncHelpShown: Boolean = false, 11 | ) 12 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/data/SaveManager.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalSerializationApi::class) 2 | 3 | package org.sunsetware.phocid.data 4 | 5 | import android.content.Context 6 | import android.util.Log 7 | import java.io.File 8 | import kotlin.reflect.KType 9 | import kotlin.reflect.typeOf 10 | import kotlin.time.Duration.Companion.seconds 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.delay 14 | import kotlinx.coroutines.flow.Flow 15 | import kotlinx.coroutines.flow.cancellable 16 | import kotlinx.coroutines.flow.collect 17 | import kotlinx.coroutines.flow.conflate 18 | import kotlinx.coroutines.flow.onEach 19 | import kotlinx.coroutines.flow.withIndex 20 | import kotlinx.coroutines.launch 21 | import kotlinx.coroutines.withContext 22 | import kotlinx.serialization.ExperimentalSerializationApi 23 | import kotlinx.serialization.SerializationException 24 | import kotlinx.serialization.cbor.Cbor 25 | import kotlinx.serialization.serializer 26 | 27 | /** The initial value in flow will not be saved. */ 28 | class SaveManager( 29 | kType: KType, 30 | context: Context, 31 | coroutineScope: CoroutineScope, 32 | flow: Flow, 33 | fileName: String, 34 | isCache: Boolean, 35 | ) : AutoCloseable { 36 | private val job = 37 | coroutineScope.launch { 38 | withContext(Dispatchers.IO) { 39 | var lastSavedVersion = 0 40 | flow 41 | .withIndex() 42 | .conflate() 43 | .onEach { (version, value) -> 44 | if (lastSavedVersion < version) { 45 | if (saveCbor(kType, context, fileName, isCache, value)) { 46 | lastSavedVersion = version 47 | } 48 | } 49 | delay(1.seconds) 50 | } 51 | .cancellable() 52 | .collect() 53 | } 54 | } 55 | 56 | override fun close() { 57 | job.cancel() 58 | } 59 | } 60 | 61 | inline fun SaveManager( 62 | context: Context, 63 | coroutineScope: CoroutineScope, 64 | flow: Flow, 65 | fileName: String, 66 | isCache: Boolean, 67 | ): SaveManager { 68 | return SaveManager(typeOf(), context, coroutineScope, flow, fileName, isCache) 69 | } 70 | 71 | fun loadCbor(type: KType, context: Context, fileName: String, isCache: Boolean): Any? { 72 | val directory = if (isCache) context.cacheDir else context.filesDir 73 | val file = File(directory, fileName) 74 | val backupFile = File(directory, "$fileName.bak") 75 | return try { 76 | Cbor { ignoreUnknownKeys = true } 77 | .decodeFromByteArray(Cbor.serializersModule.serializer(type), file.readBytes()) 78 | } catch (ex: Exception) { 79 | if (ex is SerializationException || ex is IllegalArgumentException) { 80 | Log.e("loadCbor", "$fileName is corrupted, loading backup") 81 | try { 82 | Cbor { ignoreUnknownKeys = true } 83 | .decodeFromByteArray( 84 | Cbor.serializersModule.serializer(type), 85 | backupFile.readBytes(), 86 | ) 87 | } catch (ex2: Exception) { 88 | Log.e("loadCbor", "$fileName's backup is corrupted", ex2) 89 | null 90 | } 91 | } else { 92 | Log.e("loadCbor", "Can't load $fileName", ex) 93 | null 94 | } 95 | } 96 | } 97 | 98 | inline fun loadCbor(context: Context, fileName: String, isCache: Boolean): T? { 99 | return loadCbor(typeOf(), context, fileName, isCache) as T? 100 | } 101 | 102 | fun saveCbor( 103 | type: KType, 104 | context: Context, 105 | fileName: String, 106 | isCache: Boolean, 107 | value: Any, 108 | ): Boolean { 109 | val directory = if (isCache) context.cacheDir else context.filesDir 110 | val file = File(directory, fileName) 111 | val backupFile = File(directory, "$fileName.bak") 112 | try { 113 | file.copyTo(backupFile, true) 114 | } catch (ex: Exception) { 115 | Log.e("saveCbor", "Can't create backup for $fileName", ex) 116 | } 117 | try { 118 | file.writeBytes(Cbor.encodeToByteArray(Cbor.serializersModule.serializer(type), value)) 119 | return true 120 | } catch (ex: Exception) { 121 | Log.e("saveCbor", "Can't save $fileName", ex) 122 | return false 123 | } 124 | } 125 | 126 | inline fun saveCbor( 127 | context: Context, 128 | fileName: String, 129 | isCache: Boolean, 130 | value: T, 131 | ): Boolean { 132 | return saveCbor(typeOf(), context, fileName, isCache, value) 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/data/Searchable.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.data 2 | 3 | import androidx.compose.runtime.Stable 4 | import com.ibm.icu.text.RuleBasedCollator 5 | import com.ibm.icu.text.StringSearch 6 | import java.text.StringCharacterIterator 7 | 8 | interface Searchable { 9 | val searchableStrings: List 10 | } 11 | 12 | @Stable 13 | fun Iterable.search(query: String, collator: RuleBasedCollator): List { 14 | return if (query.isEmpty()) { 15 | this.toList() 16 | } else { 17 | filter { searchable -> 18 | searchable.searchableStrings.any { 19 | if (it.isEmpty()) false 20 | else 21 | StringSearch(query, StringCharacterIterator(it), collator).first() != 22 | StringSearch.DONE 23 | } 24 | } 25 | } 26 | } 27 | 28 | @Stable 29 | fun Iterable.search( 30 | query: String, 31 | collator: RuleBasedCollator, 32 | selector: (T) -> Searchable, 33 | ): List { 34 | return if (query.isEmpty()) { 35 | this.toList() 36 | } else { 37 | filter { item -> 38 | selector(item).searchableStrings.any { 39 | if (it.isEmpty()) false 40 | else 41 | StringSearch(query, StringCharacterIterator(it), collator).first() != 42 | StringSearch.DONE 43 | } 44 | } 45 | } 46 | } 47 | 48 | @Stable 49 | fun Iterable.searchIndices( 50 | query: String, 51 | collator: RuleBasedCollator, 52 | selector: (T) -> Searchable, 53 | ): Set { 54 | return if (query.isEmpty()) { 55 | emptySet() 56 | } else { 57 | mapIndexedNotNull { index, item -> 58 | if ( 59 | selector(item).searchableStrings.any { 60 | if (it.isEmpty()) false 61 | else 62 | StringSearch(query, StringCharacterIterator(it), collator).first() != 63 | StringSearch.DONE 64 | } 65 | ) 66 | index 67 | else null 68 | } 69 | .toSet() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/globals/GlobalData.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.globals 2 | 3 | import java.util.concurrent.atomic.AtomicBoolean 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | import org.sunsetware.phocid.data.LibraryIndex 7 | import org.sunsetware.phocid.data.PlayerState 8 | import org.sunsetware.phocid.data.PlayerTransientState 9 | import org.sunsetware.phocid.data.PlaylistManager 10 | import org.sunsetware.phocid.data.Preferences 11 | import org.sunsetware.phocid.data.UnfilteredTrackIndex 12 | 13 | /** 14 | * These are meant for sharing data between contexts. End consumers should not read these directly! 15 | * 16 | * Initialized and saved by [org.sunsetware.phocid.MainApplication]. 17 | */ 18 | object GlobalData { 19 | val initialized = AtomicBoolean(false) 20 | 21 | @Volatile lateinit var preferences: MutableStateFlow 22 | @Volatile lateinit var unfilteredTrackIndex: MutableStateFlow 23 | @Volatile lateinit var playerState: MutableStateFlow 24 | 25 | val playerTransientState = MutableStateFlow(PlayerTransientState()) 26 | 27 | @Volatile lateinit var libraryIndex: StateFlow 28 | 29 | @Volatile lateinit var playlistManager: PlaylistManager 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/globals/Strings.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.globals 2 | 3 | import android.util.Log 4 | import androidx.compose.runtime.Stable 5 | import kotlin.time.Duration 6 | import org.sunsetware.phocid.R 7 | import org.sunsetware.phocid.utils.icuFormat 8 | 9 | @Stable 10 | interface StringSource { 11 | @Stable operator fun get(id: Int): String 12 | 13 | @Stable 14 | fun conjoin(strings: Iterable): String { 15 | return strings.filterNotNull().joinToString(get(R.string.symbol_conjunction)) 16 | } 17 | 18 | @Stable 19 | fun conjoin(vararg strings: String?): String { 20 | return conjoin(strings.asIterable()) 21 | } 22 | 23 | @Stable 24 | fun separate(strings: Iterable): String { 25 | return strings.filterNotNull().joinToString(get(R.string.symbol_separator)) 26 | } 27 | 28 | @Stable 29 | fun separate(vararg strings: String?): String { 30 | return separate(strings.asIterable()) 31 | } 32 | } 33 | 34 | /** This is only meant to be set by [org.sunsetware.phocid.MainApplication]! */ 35 | @Volatile 36 | var Strings = 37 | object : StringSource { 38 | override fun get(id: Int): String { 39 | Log.e("Phocid", "Accessing string resource $id before initialization") 40 | return "" 41 | } 42 | } 43 | 44 | fun Duration.format(): String { 45 | return absoluteValue.toComponents { hours, minutes, seconds, _ -> 46 | if (isNegative()) { 47 | if (hours > 0) 48 | Strings[R.string.duration_negative_hours_minutes_seconds].icuFormat( 49 | hours, 50 | minutes, 51 | seconds, 52 | ) 53 | else Strings[R.string.duration_negative_minutes_seconds].icuFormat(minutes, seconds) 54 | } else { 55 | if (hours > 0) 56 | Strings[R.string.duration_hours_minutes_seconds].icuFormat(hours, minutes, seconds) 57 | else Strings[R.string.duration_minutes_seconds].icuFormat(minutes, seconds) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/globals/SystemLocale.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.globals 2 | 3 | import java.util.Locale 4 | 5 | /** 6 | * The actual system locale, because [Locale.getDefault] will be set to the string resource locale. 7 | * 8 | * This is only meant to be set by [org.sunsetware.phocid.MainActivity]! 9 | */ 10 | @Volatile var SystemLocale: Locale = Locale.getDefault() 11 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/service/CustomizedBitmapLoader.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.service 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import android.net.Uri 6 | import androidx.core.net.toUri 7 | import androidx.media3.common.C 8 | import androidx.media3.common.MediaMetadata 9 | import androidx.media3.common.util.BitmapLoader 10 | import androidx.media3.common.util.UnstableApi 11 | import androidx.media3.common.util.Util 12 | import androidx.media3.datasource.BitmapUtil 13 | import androidx.media3.datasource.DataSourceUtil 14 | import androidx.media3.datasource.DataSpec 15 | import androidx.media3.datasource.DefaultDataSource 16 | import com.google.common.base.Supplier 17 | import com.google.common.base.Suppliers 18 | import com.google.common.util.concurrent.ListenableFuture 19 | import com.google.common.util.concurrent.ListeningExecutorService 20 | import com.google.common.util.concurrent.MoreExecutors 21 | import java.util.concurrent.Executors 22 | import org.sunsetware.phocid.FILE_PATH_KEY 23 | import org.sunsetware.phocid.URI_KEY 24 | import org.sunsetware.phocid.data.loadArtwork 25 | 26 | @UnstableApi 27 | class CustomizedBitmapLoader(private val context: Context) : BitmapLoader { 28 | private val listeningExecutorService = requireNotNull(DefaultExecutorService.get()) 29 | private val dataSourceFactory = DefaultDataSource.Factory(context) 30 | 31 | override fun supportsMimeType(mimeType: String): Boolean { 32 | return Util.isBitmapFactorySupportedMimeType(mimeType) 33 | } 34 | 35 | override fun decodeBitmap(data: ByteArray): ListenableFuture { 36 | return listeningExecutorService.submit { 37 | BitmapUtil.decode(data, data.size, null, C.LENGTH_UNSET) 38 | } 39 | } 40 | 41 | override fun loadBitmap(uri: Uri): ListenableFuture { 42 | return listeningExecutorService.submit { 43 | val dataSource = dataSourceFactory.createDataSource() 44 | try { 45 | val dataSpec = DataSpec(uri) 46 | dataSource.open(dataSpec) 47 | val readData = DataSourceUtil.readToEnd(dataSource) 48 | BitmapUtil.decode(readData, readData.size, null, C.LENGTH_UNSET) 49 | } finally { 50 | dataSource.close() 51 | } 52 | } 53 | } 54 | 55 | override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture? { 56 | val uri = metadata.extras?.getString(URI_KEY)?.toUri() 57 | return if (uri != null) 58 | listeningExecutorService.submit { 59 | loadArtwork(context, uri, metadata.extras?.getString(FILE_PATH_KEY), true) 60 | .let(::requireNotNull) 61 | } 62 | else { 63 | null 64 | } 65 | } 66 | } 67 | 68 | private val DefaultExecutorService = 69 | Suppliers.memoize( 70 | Supplier { MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()) } 71 | ) 72 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/service/CustomizedPlayer.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.service 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.media3.common.AudioAttributes 6 | import androidx.media3.common.C 7 | import androidx.media3.common.ForwardingPlayer 8 | import androidx.media3.common.Player 9 | import androidx.media3.common.TrackSelectionParameters.AudioOffloadPreferences 10 | import androidx.media3.common.util.UnstableApi 11 | import androidx.media3.exoplayer.ExoPlayer 12 | import org.sunsetware.phocid.data.getUnshuffledIndex 13 | import org.sunsetware.phocid.data.setUnshuffledIndex 14 | import org.sunsetware.phocid.utils.Random 15 | 16 | @UnstableApi 17 | class CustomizedPlayer(val inner: ExoPlayer) : ForwardingPlayer(inner) { 18 | private val listeners = mutableSetOf() 19 | private var shuffle = false 20 | 21 | override fun addListener(listener: Player.Listener) { 22 | listeners.add(listener) 23 | super.addListener(listener) 24 | } 25 | 26 | override fun removeListener(listener: Player.Listener) { 27 | listeners.remove(listener) 28 | super.removeListener(listener) 29 | } 30 | 31 | override fun getShuffleModeEnabled(): Boolean { 32 | return shuffle 33 | } 34 | 35 | override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { 36 | var raiseEvent = true 37 | if (shuffleModeEnabled && !shuffle) { 38 | enableShuffle() 39 | } else if (!shuffleModeEnabled && shuffle) { 40 | disableShuffle() 41 | } else { 42 | raiseEvent = false 43 | } 44 | 45 | shuffle = shuffleModeEnabled 46 | 47 | if (raiseEvent) { 48 | for (listener in listeners) { 49 | listener.onShuffleModeEnabledChanged(shuffleModeEnabled) 50 | } 51 | } 52 | } 53 | 54 | fun enableShuffle() { 55 | if (currentTimeline.isEmpty) return 56 | 57 | val currentIndex = currentMediaItemIndex 58 | val itemCount = mediaItemCount 59 | val shuffledPlayQueue = 60 | (0.. index to mediaItem.setUnshuffledIndex(index) } 63 | .filter { it.first != currentIndex } 64 | .shuffled(Random) 65 | .map { it.second } 66 | replaceMediaItems(currentIndex + 1, itemCount, shuffledPlayQueue) 67 | removeMediaItems(0, currentIndex) 68 | replaceMediaItem(0, currentMediaItem!!.setUnshuffledIndex(currentIndex)) 69 | } 70 | 71 | fun disableShuffle() { 72 | if (currentTimeline.isEmpty) return 73 | 74 | val currentIndex = currentMediaItemIndex 75 | val itemCount = mediaItemCount 76 | val unshuffledIndex = currentMediaItem!!.getUnshuffledIndex() 77 | if (unshuffledIndex == null) { 78 | Log.e("Phocid", "Current track has no unshuffled index when disabling shuffle") 79 | replaceMediaItems( 80 | 0, 81 | itemCount, 82 | (0.. 89 | mediaItem.getUnshuffledIndex()?.let { Pair(mediaItem, it) } 90 | } 91 | .sortedBy { it.second } 92 | .map { it.first } 93 | replaceMediaItem(currentIndex, currentMediaItem!!.setUnshuffledIndex(null)) 94 | replaceMediaItems( 95 | currentIndex + 1, 96 | itemCount, 97 | unshuffledPlayQueue.subList(unshuffledIndex + 1, unshuffledPlayQueue.size).map { 98 | it.setUnshuffledIndex(null) 99 | }, 100 | ) 101 | replaceMediaItems( 102 | 0, 103 | currentIndex, 104 | unshuffledPlayQueue.subList(0, unshuffledIndex).map { it.setUnshuffledIndex(null) }, 105 | ) 106 | } 107 | } 108 | } 109 | 110 | @UnstableApi 111 | fun CustomizedPlayer(context: Context): CustomizedPlayer { 112 | return ExoPlayer.Builder(context) 113 | .setAudioAttributes( 114 | AudioAttributes.Builder() 115 | .setUsage(C.USAGE_MEDIA) 116 | .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) 117 | .build(), 118 | true, 119 | ) 120 | .setHandleAudioBecomingNoisy(true) 121 | .setWakeMode(C.WAKE_MODE_LOCAL) 122 | .build() 123 | .apply { 124 | trackSelectionParameters = 125 | trackSelectionParameters 126 | .buildUpon() 127 | .setAudioOffloadPreferences( 128 | AudioOffloadPreferences.Builder() 129 | .setAudioOffloadMode(AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED) 130 | .build() 131 | ) 132 | .build() 133 | } 134 | .let { CustomizedPlayer(it) } 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/AnimatedForwardBackwardTransition.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.LaunchedEffect 7 | import androidx.compose.runtime.Stable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.key 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.graphicsLayer 15 | import androidx.compose.ui.platform.LocalLayoutDirection 16 | import androidx.compose.ui.unit.LayoutDirection 17 | import kotlin.math.absoluteValue 18 | import org.sunsetware.phocid.ui.theme.emphasizedEnter 19 | import org.sunsetware.phocid.ui.theme.emphasizedExit 20 | 21 | const val FORWARD_BACKWARD_SLIDE_FRACTION = 0.5f 22 | 23 | @Stable 24 | fun forwardBackwardTransitionAlpha(position: Float, index: Int): Float { 25 | return (1 - (position - index).absoluteValue * 2).coerceAtLeast(0f) 26 | } 27 | 28 | @Stable 29 | fun forwardBackwardTransitionTranslation( 30 | position: Float, 31 | index: Int, 32 | width: Float, 33 | ltr: Boolean, 34 | ): Float { 35 | return (index - position).coerceIn(-1f, 1f) * 36 | width * 37 | FORWARD_BACKWARD_SLIDE_FRACTION * 38 | (if (ltr) 1 else -1) 39 | } 40 | 41 | @Composable 42 | inline fun AnimatedForwardBackwardTransition( 43 | stack: List, 44 | modifier: Modifier = Modifier, 45 | slide: Boolean = true, 46 | keepRoot: Boolean = true, 47 | crossinline content: @Composable (T?) -> Unit, 48 | ) { 49 | val position = remember { Animatable(0f) } 50 | var lastAndCurrentStack by remember { mutableStateOf(stack to stack) } 51 | var largerStack by remember { mutableStateOf(stack) } 52 | 53 | LaunchedEffect(stack) { 54 | lastAndCurrentStack = lastAndCurrentStack.second to stack 55 | largerStack = 56 | if (lastAndCurrentStack.first.size > lastAndCurrentStack.second.size) 57 | lastAndCurrentStack.first 58 | else lastAndCurrentStack.second 59 | } 60 | 61 | LaunchedEffect(lastAndCurrentStack.second.size) { 62 | val size = lastAndCurrentStack.second.size 63 | if (position.value < size) { 64 | position.animateTo(size.toFloat(), emphasizedEnter()) 65 | } else { 66 | position.animateTo(size.toFloat(), emphasizedExit()) 67 | } 68 | } 69 | 70 | Box(modifier = modifier) { 71 | val rootAlpha = forwardBackwardTransitionAlpha(position.value, 0) 72 | val ltr = LocalLayoutDirection.current == LayoutDirection.Ltr 73 | if (keepRoot || rootAlpha > 0) { 74 | Box( 75 | modifier = 76 | Modifier.graphicsLayer { 77 | alpha = rootAlpha 78 | if (slide) { 79 | translationX = 80 | forwardBackwardTransitionTranslation( 81 | position.value, 82 | 0, 83 | size.width, 84 | ltr, 85 | ) 86 | } 87 | } 88 | ) { 89 | content(null) 90 | } 91 | } else { 92 | Box {} 93 | } 94 | 95 | largerStack.forEachIndexed { index, item -> 96 | val alpha = forwardBackwardTransitionAlpha(position.value, index + 1) 97 | key(index + 1) { 98 | if (alpha > 0) { 99 | Box( 100 | modifier = 101 | Modifier.graphicsLayer { 102 | this.alpha = alpha 103 | 104 | if (slide) { 105 | translationX = 106 | forwardBackwardTransitionTranslation( 107 | position.value, 108 | index + 1, 109 | size.width, 110 | ltr, 111 | ) 112 | } 113 | } 114 | ) { 115 | content(item) 116 | } 117 | } 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/BinaryDragState.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.animation.core.AnimationSpec 5 | import androidx.compose.runtime.Stable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.runtime.mutableFloatStateOf 8 | import androidx.compose.runtime.setValue 9 | import androidx.compose.ui.unit.Density 10 | import java.lang.ref.WeakReference 11 | import java.util.concurrent.atomic.AtomicBoolean 12 | import kotlin.math.round 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.flow.asStateFlow 16 | import kotlinx.coroutines.flow.update 17 | import kotlinx.coroutines.launch 18 | import org.sunsetware.phocid.DRAG_THRESHOLD 19 | import org.sunsetware.phocid.ui.theme.emphasizedStandard 20 | 21 | /** 22 | * Reminder: [androidx.compose.foundation.gestures.AnchoredDraggableState] and other official APIs 23 | * are unusable traps, don't bother "migrating". 24 | */ 25 | @Stable 26 | class BinaryDragState( 27 | /** 28 | * Must be a [CoroutineScope] from a composition context (i.e. not the view model scope). 29 | * 30 | * Reassign this property on activity recreation. 31 | */ 32 | var coroutineScope: WeakReference = WeakReference(null), 33 | initialValue: Float = 0f, 34 | val onSnapToZero: () -> Unit = {}, 35 | val onSnapToOne: () -> Unit = {}, 36 | val reversed: Boolean = false, 37 | val animationSpec: AnimationSpec = emphasizedStandard(), 38 | ) { 39 | private val _position = Animatable(initialValue) 40 | val position by _position.asState() 41 | 42 | private val _targetValue = MutableStateFlow(initialValue) 43 | val targetValue = _targetValue.asStateFlow() 44 | 45 | var length by mutableFloatStateOf(0f) 46 | 47 | @Volatile private var dragTotal = 0f 48 | @Volatile private var dragInitialPosition = initialValue 49 | 50 | fun onDragStart(lock: DragLock) { 51 | dragTotal = 0f 52 | dragInitialPosition = _position.value 53 | lock.isDragging.set(true) 54 | } 55 | 56 | fun onDrag(lock: DragLock, delta: Float) { 57 | dragTotal += delta * (if (reversed) 1 else -1) 58 | coroutineScope.get()?.launch { 59 | if (lock.isDragging.get()) { 60 | _position.snapTo( 61 | (dragInitialPosition + dragTotal / length).coerceIn(0f, 1f).takeIf { 62 | it.isFinite() 63 | } ?: dragInitialPosition 64 | ) 65 | } 66 | } 67 | } 68 | 69 | fun onDragEnd(lock: DragLock, density: Density) { 70 | lock.isDragging.set(false) 71 | if (dragTotal == 0f) return 72 | with(density) { 73 | if (dragTotal > DRAG_THRESHOLD.toPx()) { 74 | animateTo(1f) 75 | } else if (dragTotal < -DRAG_THRESHOLD.toPx()) { 76 | animateTo(0f) 77 | } else { 78 | val target = round(_position.value) 79 | animateTo(target) 80 | } 81 | } 82 | dragTotal = 0f 83 | } 84 | 85 | fun animateTo(value: Float) { 86 | coroutineScope.get()?.launch { 87 | _position.animateTo(value, animationSpec) 88 | if (value == 0f) onSnapToZero() else if (value == 1f) onSnapToOne() 89 | } 90 | _targetValue.update { value } 91 | } 92 | 93 | fun snapTo(value: Float) { 94 | coroutineScope.get()?.launch { 95 | _position.snapTo(value) 96 | if (value == 0f) onSnapToZero() else if (value == 1f) onSnapToOne() 97 | } 98 | _targetValue.update { value } 99 | } 100 | } 101 | 102 | /** This is used to prevent out-of-order execution of [BinaryDragState.onDragEnd] etc. */ 103 | @Stable 104 | class DragLock { 105 | val isDragging = AtomicBoolean(false) 106 | } 107 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/DefaultPagerState.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.pager.PagerState 4 | import androidx.compose.runtime.mutableStateOf 5 | 6 | class DefaultPagerState( 7 | currentPage: Int = 0, 8 | currentPageOffsetFraction: Float = 0f, 9 | updatedPageCount: () -> Int, 10 | ) : PagerState(currentPage, currentPageOffsetFraction) { 11 | var pageCountState = mutableStateOf(updatedPageCount) 12 | override val pageCount: Int 13 | get() = pageCountState.value.invoke() 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/DialogBase.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package org.sunsetware.phocid.ui.components 4 | 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.wrapContentHeight 9 | import androidx.compose.material3.AlertDialog 10 | import androidx.compose.material3.AlertDialogDefaults 11 | import androidx.compose.material3.BasicAlertDialog 12 | import androidx.compose.material3.ExperimentalMaterial3Api 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Surface 15 | import androidx.compose.material3.Text 16 | import androidx.compose.material3.TextButton 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.window.DialogProperties 21 | import org.sunsetware.phocid.R 22 | import org.sunsetware.phocid.globals.Strings 23 | 24 | // Don't mark these functions as inline unless you want a "Compose internal error" 25 | 26 | @Composable 27 | fun DialogBase( 28 | title: String, 29 | onConfirm: () -> Unit, 30 | onDismiss: () -> Unit, 31 | confirmText: String = Strings[R.string.commons_ok], 32 | dismissText: String = Strings[R.string.commons_cancel], 33 | confirmEnabled: Boolean = true, 34 | properties: DialogProperties = DialogProperties(), 35 | content: @Composable () -> Unit, 36 | ) { 37 | AlertDialog( 38 | title = { Text(title) }, 39 | text = { 40 | Box(modifier = Modifier.negativePadding(horizontal = 24.dp).fillMaxWidth()) { 41 | content() 42 | } 43 | }, 44 | confirmButton = { 45 | TextButton(onClick = onConfirm, enabled = confirmEnabled) { Text(confirmText) } 46 | }, 47 | dismissButton = { TextButton(onClick = onDismiss) { Text(dismissText) } }, 48 | onDismissRequest = onDismiss, 49 | containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, 50 | modifier = Modifier.padding(vertical = 16.dp), 51 | properties = properties, 52 | ) 53 | } 54 | 55 | @Composable 56 | fun DialogBase( 57 | title: String, 58 | onConfirmOrDismiss: () -> Unit, 59 | confirmText: String = Strings[R.string.commons_ok], 60 | properties: DialogProperties = DialogProperties(), 61 | content: @Composable () -> Unit, 62 | ) { 63 | AlertDialog( 64 | title = { Text(title) }, 65 | text = { 66 | Box(modifier = Modifier.negativePadding(horizontal = 24.dp).fillMaxWidth()) { 67 | content() 68 | } 69 | }, 70 | confirmButton = { TextButton(onClick = onConfirmOrDismiss) { Text(confirmText) } }, 71 | onDismissRequest = onConfirmOrDismiss, 72 | containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, 73 | modifier = Modifier.padding(vertical = 16.dp), 74 | properties = properties, 75 | ) 76 | } 77 | 78 | @Composable 79 | fun DialogBase( 80 | onDismiss: () -> Unit, 81 | properties: DialogProperties = DialogProperties(), 82 | content: @Composable () -> Unit, 83 | ) { 84 | BasicAlertDialog( 85 | onDismissRequest = onDismiss, 86 | modifier = Modifier.padding(vertical = 16.dp), 87 | properties = properties, 88 | ) { 89 | Surface( 90 | shape = AlertDialogDefaults.shape, 91 | color = MaterialTheme.colorScheme.surfaceContainerHigh, 92 | contentColor = AlertDialogDefaults.textContentColor, 93 | tonalElevation = AlertDialogDefaults.TonalElevation, 94 | modifier = Modifier.wrapContentHeight(), 95 | ) { 96 | content() 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/EmptyListIndicator.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Upcoming 9 | import androidx.compose.material3.Icon 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.Text 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.unit.dp 17 | import org.sunsetware.phocid.R 18 | import org.sunsetware.phocid.globals.Strings 19 | import org.sunsetware.phocid.ui.theme.Typography 20 | 21 | @Composable 22 | fun EmptyListIndicator() { 23 | Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { 24 | Column(horizontalAlignment = Alignment.CenterHorizontally) { 25 | Icon( 26 | Icons.Filled.Upcoming, 27 | contentDescription = null, 28 | modifier = Modifier.size(56.dp), 29 | tint = MaterialTheme.colorScheme.onSurfaceVariant, 30 | ) 31 | Text( 32 | Strings[R.string.list_empty], 33 | style = Typography.bodyMedium, 34 | textAlign = TextAlign.Center, 35 | color = MaterialTheme.colorScheme.onSurfaceVariant, 36 | ) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/FloatingToolbar.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalLayoutApi::class) 2 | 3 | package org.sunsetware.phocid.ui.components 4 | 5 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 6 | import androidx.compose.foundation.layout.FlowRow 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material3.CardColors 11 | import androidx.compose.material3.CardDefaults 12 | import androidx.compose.material3.ElevatedCard 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.IconButton 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.unit.dp 20 | import org.sunsetware.phocid.ui.views.MenuItem 21 | 22 | @Composable 23 | fun FloatingToolbar(items: List) { 24 | ElevatedCard( 25 | modifier = Modifier.padding(horizontal = 16.dp), 26 | shape = RoundedCornerShape(24.dp), 27 | colors = 28 | CardColors( 29 | containerColor = MaterialTheme.colorScheme.secondary, 30 | contentColor = MaterialTheme.colorScheme.onSecondary, 31 | disabledContainerColor = Color.Unspecified, 32 | disabledContentColor = Color.Unspecified, 33 | ), 34 | elevation = CardDefaults.elevatedCardElevation(6.dp, 6.dp, 6.dp, 6.dp, 6.dp, 6.dp), 35 | ) { 36 | FlowRow { 37 | items.forEach { item -> 38 | IconButton(onClick = item.onClick, modifier = Modifier.size(48.dp)) { 39 | Icon(item.icon, item.text) 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/IndefiniteSnackbar.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.material3.CardColors 10 | import androidx.compose.material3.CardDefaults 11 | import androidx.compose.material3.ElevatedCard 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.unit.dp 19 | import org.sunsetware.phocid.ui.theme.Typography 20 | 21 | @Composable 22 | fun IndefiniteSnackbar(text: String) { 23 | ElevatedCard( 24 | modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth().height(48.dp), 25 | shape = RoundedCornerShape(4.dp), 26 | colors = 27 | CardColors( 28 | containerColor = MaterialTheme.colorScheme.inverseSurface, 29 | contentColor = MaterialTheme.colorScheme.inverseOnSurface, 30 | disabledContainerColor = Color.Unspecified, 31 | disabledContentColor = Color.Unspecified, 32 | ), 33 | elevation = CardDefaults.cardElevation(6.dp, 6.dp, 6.dp, 6.dp, 6.dp, 6.dp), 34 | ) { 35 | Box( 36 | modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), 37 | contentAlignment = Alignment.CenterStart, 38 | ) { 39 | Text(text, style = Typography.bodyMedium) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/MultiSelectState.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package org.sunsetware.phocid.ui.components 4 | 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.combinedClickable 7 | import androidx.compose.runtime.Immutable 8 | import androidx.compose.runtime.Stable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.hapticfeedback.HapticFeedback 11 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 12 | import java.util.concurrent.atomic.AtomicInteger 13 | import kotlin.math.max 14 | import kotlin.math.min 15 | import kotlinx.coroutines.CoroutineScope 16 | import kotlinx.coroutines.flow.MutableStateFlow 17 | import kotlinx.coroutines.flow.StateFlow 18 | import kotlinx.coroutines.flow.asStateFlow 19 | import kotlinx.coroutines.flow.collect 20 | import kotlinx.coroutines.flow.onEach 21 | import kotlinx.coroutines.flow.update 22 | import kotlinx.coroutines.launch 23 | import org.sunsetware.phocid.utils.replace 24 | 25 | data class Selectable(val value: T, val selected: Boolean) 26 | 27 | @Immutable 28 | class SelectableList(items: List>) : List> by items { 29 | val selection = items.filter { it.selected }.map { it.value } 30 | } 31 | 32 | fun List>.toSelectableList(): SelectableList { 33 | return SelectableList(this) 34 | } 35 | 36 | interface MultiSelectManager { 37 | fun toggleSelect(index: Int) 38 | 39 | fun selectTo(index: Int) 40 | 41 | fun selectAll() 42 | 43 | fun selectInverse() 44 | 45 | fun clearSelection() 46 | } 47 | 48 | /** 49 | * Selection is invalidated upon source change. 50 | * 51 | * @param dataSource items must be unique. 52 | */ 53 | class MultiSelectState( 54 | coroutineScope: CoroutineScope, 55 | private val dataSource: StateFlow>, 56 | ) : AutoCloseable, MultiSelectManager { 57 | private val _items = MutableStateFlow(SelectableList(emptyList())) 58 | val items = _items.asStateFlow() 59 | private val lastSelectionIndex = AtomicInteger(-1) 60 | private val syncJob = 61 | coroutineScope.launch { 62 | dataSource 63 | .onEach { source -> 64 | _items.update { 65 | source.map { value -> Selectable(value, false) }.toSelectableList() 66 | } 67 | lastSelectionIndex.set(-1) 68 | } 69 | .collect() 70 | } 71 | 72 | override fun close() { 73 | syncJob.cancel() 74 | } 75 | 76 | override fun toggleSelect(index: Int) { 77 | _items.update { items -> 78 | items.replace(index) { it.copy(selected = !it.selected) }.toSelectableList() 79 | } 80 | lastSelectionIndex.set(index) 81 | } 82 | 83 | override fun selectTo(index: Int) { 84 | _items.update { items -> 85 | val lastIndex = lastSelectionIndex.get() 86 | if (lastIndex < 0) { 87 | items.replace(index) { it.copy(selected = true) }.toSelectableList() 88 | } else { 89 | val lower = min(index, lastIndex) 90 | val upper = max(index, lastIndex) 91 | items 92 | .mapIndexed { index, item -> item.copy(selected = index in lower..upper) } 93 | .toSelectableList() 94 | } 95 | } 96 | lastSelectionIndex.set(index) 97 | } 98 | 99 | override fun selectAll() { 100 | _items.update { items -> items.map { it.copy(selected = true) }.toSelectableList() } 101 | } 102 | 103 | override fun selectInverse() { 104 | var cleared = false 105 | _items.update { items -> 106 | val result = items.map { it.copy(selected = !it.selected) }.toSelectableList() 107 | cleared = !result.selection.isNotEmpty() 108 | result 109 | } 110 | if (cleared) lastSelectionIndex.set(-1) 111 | } 112 | 113 | override fun clearSelection() { 114 | _items.update { items -> items.map { it.copy(selected = false) }.toSelectableList() } 115 | lastSelectionIndex.set(-1) 116 | } 117 | } 118 | 119 | @Stable 120 | inline fun Modifier.multiSelectClickable( 121 | items: SelectableList, 122 | index: Int, 123 | multiSelectManager: MultiSelectManager, 124 | haptics: HapticFeedback, 125 | crossinline onClick: () -> Unit, 126 | ): Modifier { 127 | return combinedClickable( 128 | onClick = { 129 | if (items.selection.isNotEmpty()) { 130 | multiSelectManager.toggleSelect(index) 131 | } else { 132 | onClick() 133 | } 134 | }, 135 | onLongClick = { 136 | haptics.performHapticFeedback(HapticFeedbackType.LongPress) 137 | if (items.selection.isNotEmpty()) { 138 | multiSelectManager.selectTo(index) 139 | } else { 140 | multiSelectManager.toggleSelect(index) 141 | } 142 | }, 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/NegativePadding.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.ui.Modifier 4 | import androidx.compose.ui.layout.layout 5 | import androidx.compose.ui.unit.Dp 6 | import androidx.compose.ui.unit.dp 7 | 8 | fun Modifier.negativePadding( 9 | start: Dp = 0.dp, 10 | top: Dp = 0.dp, 11 | end: Dp = 0.dp, 12 | bottom: Dp = 0.dp, 13 | ): Modifier { 14 | return layout { measurable, constraints -> 15 | val overriddenWidth = constraints.maxWidth + (start + end).roundToPx() 16 | val overriddenHeight = constraints.maxHeight + (top + bottom).roundToPx() 17 | val placeable = 18 | measurable.measure( 19 | constraints.copy(maxWidth = overriddenWidth, maxHeight = overriddenHeight) 20 | ) 21 | layout(placeable.width, placeable.height) { 22 | placeable.placeRelative( 23 | ((end - start) / 2).roundToPx(), 24 | ((bottom - top) / 2).roundToPx(), 25 | ) 26 | } 27 | } 28 | } 29 | 30 | fun Modifier.negativePadding(all: Dp): Modifier { 31 | return negativePadding(all, all, all, all) 32 | } 33 | 34 | fun Modifier.negativePadding(horizontal: Dp = 0.dp, vertical: Dp = 0.dp): Modifier { 35 | return negativePadding(horizontal, vertical, horizontal, vertical) 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/OverflowMenu.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.MoreVert 7 | import androidx.compose.material3.DropdownMenu 8 | import androidx.compose.material3.DropdownMenuItem 9 | import androidx.compose.material3.HorizontalDivider 10 | import androidx.compose.material3.Icon 11 | import androidx.compose.material3.IconButton 12 | import androidx.compose.material3.LocalContentColor 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.MutableState 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.unit.dp 24 | import org.sunsetware.phocid.R 25 | import org.sunsetware.phocid.globals.Strings 26 | import org.sunsetware.phocid.ui.views.MenuItem 27 | 28 | @Composable 29 | fun OverflowMenu( 30 | items: List, 31 | modifier: Modifier = Modifier, 32 | state: MutableState = remember { mutableStateOf(false) }, 33 | ) { 34 | var expanded by state 35 | 36 | Box(modifier = modifier) { 37 | IconButton(onClick = { expanded = !expanded }) { 38 | Icon(Icons.Filled.MoreVert, contentDescription = Strings[R.string.commons_more]) 39 | } 40 | 41 | DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { 42 | items.forEach { 43 | when (it) { 44 | is MenuItem.Button -> { 45 | DropdownMenuItem( 46 | text = { 47 | Text( 48 | it.text, 49 | color = 50 | if (it.dangerous) MaterialTheme.colorScheme.error 51 | else Color.Unspecified, 52 | ) 53 | }, 54 | leadingIcon = { 55 | Icon( 56 | it.icon, 57 | contentDescription = null, 58 | tint = 59 | if (it.dangerous) MaterialTheme.colorScheme.error 60 | else LocalContentColor.current, 61 | ) 62 | }, 63 | onClick = { 64 | it.onClick() 65 | expanded = false 66 | }, 67 | ) 68 | } 69 | is MenuItem.Divider -> { 70 | HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/SelectBox.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.BorderStroke 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.defaultMinSize 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.shape.CircleShape 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.ArrowDropDown 13 | import androidx.compose.material.icons.filled.ArrowDropUp 14 | import androidx.compose.material3.DropdownMenu 15 | import androidx.compose.material3.DropdownMenuItem 16 | import androidx.compose.material3.Icon 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Surface 19 | import androidx.compose.material3.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.runtime.getValue 22 | import androidx.compose.runtime.mutableIntStateOf 23 | import androidx.compose.runtime.mutableStateOf 24 | import androidx.compose.runtime.remember 25 | import androidx.compose.runtime.saveable.rememberSaveable 26 | import androidx.compose.runtime.setValue 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.layout.onSizeChanged 30 | import androidx.compose.ui.platform.LocalDensity 31 | import androidx.compose.ui.text.style.TextOverflow 32 | import androidx.compose.ui.unit.dp 33 | import org.sunsetware.phocid.ui.theme.Typography 34 | 35 | @Composable 36 | fun SelectBox( 37 | items: List, 38 | activeIndex: Int, 39 | onSetActiveIndex: (Int) -> Unit, 40 | modifier: Modifier = Modifier, 41 | ) { 42 | val density = LocalDensity.current 43 | var expanded by rememberSaveable { mutableStateOf(false) } 44 | var width by remember { mutableIntStateOf(0) } 45 | Surface( 46 | modifier = modifier.height(40.dp).onSizeChanged { width = it.width }, 47 | shape = CircleShape, 48 | border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), 49 | onClick = { expanded = true }, 50 | ) { 51 | Row( 52 | modifier = Modifier.padding(start = 16.dp, end = 12.dp), 53 | verticalAlignment = Alignment.CenterVertically, 54 | ) { 55 | SingleLineText( 56 | items[activeIndex], 57 | style = Typography.labelLarge, 58 | overflow = TextOverflow.Ellipsis, 59 | modifier = Modifier.weight(1f), 60 | ) 61 | Icon( 62 | if (expanded) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, 63 | null, 64 | tint = MaterialTheme.colorScheme.primary, 65 | ) 66 | } 67 | DropdownMenu( 68 | expanded = expanded, 69 | onDismissRequest = { expanded = false }, 70 | modifier = Modifier.defaultMinSize(minWidth = with(density) { width.toDp() }), 71 | shape = RoundedCornerShape(20.dp), 72 | containerColor = MaterialTheme.colorScheme.surface, 73 | ) { 74 | items.forEachIndexed { index, text -> 75 | DropdownMenuItem( 76 | text = { Text(text) }, 77 | onClick = { 78 | onSetActiveIndex(index) 79 | expanded = false 80 | }, 81 | contentPadding = PaddingValues(horizontal = 16.dp), 82 | ) 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/SingleLineText.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.layout.requiredHeight 4 | import androidx.compose.material3.LocalTextStyle 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.platform.LocalDensity 10 | import androidx.compose.ui.text.TextLayoutResult 11 | import androidx.compose.ui.text.TextStyle 12 | import androidx.compose.ui.text.style.TextAlign 13 | import androidx.compose.ui.text.style.TextDecoration 14 | import androidx.compose.ui.text.style.TextOverflow 15 | import androidx.compose.ui.unit.TextUnit 16 | import androidx.compose.ui.unit.isUnspecified 17 | 18 | /** Prevents single line [Text] from having variable height on taller scripts (e.g. CJK). */ 19 | @Composable 20 | fun SingleLineText( 21 | text: String, 22 | modifier: Modifier = Modifier, 23 | color: Color = Color.Unspecified, 24 | letterSpacing: TextUnit = TextUnit.Unspecified, 25 | textDecoration: TextDecoration? = null, 26 | textAlign: TextAlign? = null, 27 | lineHeight: TextUnit = TextUnit.Unspecified, 28 | overflow: TextOverflow = TextOverflow.Clip, 29 | softWrap: Boolean = true, 30 | onTextLayout: ((TextLayoutResult) -> Unit)? = null, 31 | style: TextStyle = LocalTextStyle.current, 32 | ) { 33 | Text( 34 | text = text, 35 | modifier = 36 | modifier.requiredHeight( 37 | with(LocalDensity.current) { 38 | (if (!lineHeight.isUnspecified) lineHeight else style.lineHeight).toDp() 39 | } 40 | ), 41 | color = color, 42 | letterSpacing = letterSpacing, 43 | textDecoration = textDecoration, 44 | textAlign = textAlign, 45 | lineHeight = lineHeight, 46 | overflow = overflow, 47 | softWrap = softWrap, 48 | maxLines = 1, 49 | minLines = 1, 50 | onTextLayout = onTextLayout, 51 | style = style, 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/SortingOptionPicker.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.material3.SegmentedButton 8 | import androidx.compose.material3.SegmentedButtonDefaults 9 | import androidx.compose.material3.SingleChoiceSegmentedButtonRow 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import org.sunsetware.phocid.R 15 | import org.sunsetware.phocid.UNKNOWN 16 | import org.sunsetware.phocid.data.SortingOption 17 | import org.sunsetware.phocid.globals.Strings 18 | 19 | @Composable 20 | inline fun SortingOptionPicker( 21 | sortingOptions: Map, 22 | activeSortingOptionId: String, 23 | sortAscending: Boolean, 24 | crossinline onSetSortingOption: (String) -> Unit, 25 | crossinline onSetSortAscending: (Boolean) -> Unit, 26 | modifier: Modifier = Modifier, 27 | ) { 28 | Column(modifier) { 29 | SelectBox( 30 | items = 31 | sortingOptions.values.map { value -> 32 | value.stringId?.let { Strings[it] } ?: UNKNOWN 33 | }, 34 | activeIndex = sortingOptions.keys.indexOf(activeSortingOptionId), 35 | onSetActiveIndex = { 36 | onSetSortingOption(sortingOptions.keys.asIterable().elementAt(it)) 37 | }, 38 | modifier = Modifier.fillMaxWidth(), 39 | ) 40 | Spacer(modifier = Modifier.height(16.dp)) 41 | SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { 42 | SegmentedButton( 43 | selected = sortAscending, 44 | onClick = { onSetSortAscending(true) }, 45 | shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), 46 | ) { 47 | Text(Strings[R.string.sorting_ascending]) 48 | } 49 | SegmentedButton( 50 | selected = !sortAscending, 51 | onClick = { onSetSortAscending(false) }, 52 | shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), 53 | ) { 54 | Text(Strings[R.string.sorting_descending]) 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/SteppedSliderWithNumber.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package org.sunsetware.phocid.ui.components 4 | 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.material.icons.Icons 9 | import androidx.compose.material.icons.automirrored.filled.Undo 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.IconButton 13 | import androidx.compose.material3.Slider 14 | import androidx.compose.material3.SliderDefaults 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.graphics.Color 19 | import org.sunsetware.phocid.R 20 | import org.sunsetware.phocid.TNUM 21 | import org.sunsetware.phocid.globals.Strings 22 | import org.sunsetware.phocid.ui.theme.Typography 23 | 24 | @Composable 25 | fun SteppedSliderWithNumber( 26 | number: String, 27 | value: Float, 28 | onValueChange: (Float) -> Unit, 29 | steps: Int, 30 | valueRange: ClosedFloatingPointRange, 31 | modifier: Modifier = Modifier, 32 | onReset: (() -> Unit)? = null, 33 | numberColor: Color = Color.Unspecified, 34 | enabled: Boolean = true, 35 | onValueChangeFinished: (() -> Unit)? = null, 36 | ) { 37 | Column(modifier = modifier) { 38 | Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { 39 | SingleLineText( 40 | number, 41 | style = Typography.headlineLarge.copy(fontFeatureSettings = TNUM), 42 | color = numberColor, 43 | modifier = Modifier.weight(1f), 44 | ) 45 | if (onReset != null) { 46 | IconButton(onClick = onReset, enabled = enabled) { 47 | Icon(Icons.AutoMirrored.Filled.Undo, Strings[R.string.commons_reset]) 48 | } 49 | } 50 | } 51 | Slider( 52 | value, 53 | onValueChange, 54 | valueRange = valueRange, 55 | steps = steps, 56 | enabled = enabled, 57 | onValueChangeFinished = onValueChangeFinished, 58 | modifier = Modifier.fillMaxWidth(), 59 | track = { SliderDefaults.Track(it, drawTick = { _, _ -> }) }, 60 | ) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/SwipeToDismiss.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.foundation.gestures.detectHorizontalDragGestures 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.BoxScope 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.runtime.remember 10 | import androidx.compose.runtime.rememberCoroutineScope 11 | import androidx.compose.runtime.rememberUpdatedState 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.graphicsLayer 14 | import androidx.compose.ui.input.pointer.pointerInput 15 | import kotlin.math.absoluteValue 16 | import kotlin.math.sign 17 | import kotlinx.coroutines.Dispatchers 18 | import kotlinx.coroutines.launch 19 | import org.sunsetware.phocid.DRAG_THRESHOLD 20 | import org.sunsetware.phocid.ui.theme.emphasizedExit 21 | 22 | /** Yes, [androidx.compose.material3.SwipeToDismissBox] is yet another Google's useless s***. */ 23 | @Composable 24 | inline fun SwipeToDismiss( 25 | key: T, 26 | enabled: Boolean, 27 | crossinline onDismiss: (T) -> Unit, 28 | crossinline content: @Composable BoxScope.() -> Unit, 29 | ) { 30 | val coroutineScope = rememberCoroutineScope() 31 | val dispatcher = Dispatchers.Main.limitedParallelism(1) 32 | val updatedKey by rememberUpdatedState(key) 33 | val offset = remember { Animatable(0f) } 34 | 35 | Box( 36 | modifier = 37 | if (enabled) { 38 | Modifier.pointerInput(Unit) { 39 | detectHorizontalDragGestures( 40 | onDragStart = {}, 41 | onDragCancel = { 42 | coroutineScope.launch(dispatcher) { 43 | offset.animateTo(0f, emphasizedExit()) 44 | } 45 | }, 46 | onDragEnd = { 47 | coroutineScope.launch(dispatcher) { 48 | val value = offset.value 49 | if ( 50 | value >= DRAG_THRESHOLD.toPx() || 51 | value <= -DRAG_THRESHOLD.toPx() 52 | ) { 53 | offset.animateTo(value.sign * size.width, emphasizedExit()) 54 | onDismiss(updatedKey) 55 | } else { 56 | offset.animateTo(0f, emphasizedExit()) 57 | } 58 | } 59 | }, 60 | ) { change, dragAmount -> 61 | coroutineScope.launch(dispatcher) { 62 | offset.snapTo(offset.value + dragAmount) 63 | } 64 | } 65 | } 66 | } else { 67 | Modifier 68 | } 69 | .graphicsLayer { 70 | translationX = offset.value 71 | alpha = 72 | (1 - (offset.value / size.width).absoluteValue) 73 | .takeIf { it.isFinite() } 74 | ?.coerceIn(0f, 1f) ?: 1f 75 | } 76 | ) { 77 | content() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/TabIndicator.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package org.sunsetware.phocid.ui.components 4 | 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.height 8 | import androidx.compose.foundation.pager.PagerState 9 | import androidx.compose.foundation.shape.RoundedCornerShape 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.MaterialTheme 12 | import androidx.compose.material3.TabIndicatorScope 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.unit.dp 16 | import androidx.compose.ui.unit.lerp 17 | import kotlin.math.abs 18 | 19 | @Composable 20 | fun TabIndicatorScope.TabIndicator(pagerState: PagerState) { 21 | val total = abs(pagerState.targetPage - pagerState.currentPage) 22 | val traveled = abs(pagerState.currentPageOffsetFraction) 23 | val morph = (traveled / total).takeIf { it.isFinite() } ?: 0f 24 | Box( 25 | modifier = 26 | Modifier.height(3.dp) 27 | .tabIndicatorLayout { measurable, constraints, tabPositions -> 28 | val settledPosition = 29 | tabPositions[pagerState.currentPage.coerceIn(0, tabPositions.size - 1)] 30 | val targetPosition = 31 | tabPositions[pagerState.targetPage.coerceIn(0, tabPositions.size - 1)] 32 | val width = 33 | (lerp(settledPosition.contentWidth, targetPosition.contentWidth, morph) - 34 | 4.dp) 35 | .roundToPx() 36 | val placeable = 37 | measurable.measure(constraints.copy(minWidth = width, maxWidth = width)) 38 | layout(placeable.width, placeable.height) { 39 | placeable.placeRelative( 40 | lerp(settledPosition.left, targetPosition.left, morph).roundToPx(), 41 | constraints.maxHeight - placeable.height, 42 | ) 43 | } 44 | } 45 | .background( 46 | color = MaterialTheme.colorScheme.primary, 47 | shape = RoundedCornerShape(3.dp, 3.dp, 0.dp, 0.dp), 48 | ) 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/components/UtilityListItem.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.components 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.defaultMinSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.width 12 | import androidx.compose.material3.Checkbox 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.RadioButton 15 | import androidx.compose.material3.Switch 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.unit.dp 21 | import org.sunsetware.phocid.ui.theme.Typography 22 | 23 | @Composable 24 | fun UtilityListHeader(text: String) { 25 | Box( 26 | modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp), 27 | contentAlignment = Alignment.CenterStart, 28 | ) { 29 | Text(text, style = Typography.labelLarge, color = MaterialTheme.colorScheme.primary) 30 | } 31 | } 32 | 33 | @Composable 34 | inline fun UtilityListItem( 35 | title: String, 36 | modifier: Modifier = Modifier, 37 | subtitle: String? = null, 38 | crossinline lead: @Composable () -> Unit = {}, 39 | crossinline actions: @Composable () -> Unit = {}, 40 | ) { 41 | Row( 42 | modifier = 43 | modifier 44 | .fillMaxWidth() 45 | .defaultMinSize(minHeight = if (subtitle != null) 72.dp else 56.dp) 46 | .padding(horizontal = 24.dp, vertical = 8.dp), 47 | verticalAlignment = Alignment.CenterVertically, 48 | ) { 49 | lead() 50 | Column(modifier = Modifier.weight(1f)) { 51 | Text(text = title, style = Typography.bodyLarge) 52 | if (subtitle != null) { 53 | Text( 54 | text = subtitle, 55 | style = Typography.bodySmall, 56 | color = MaterialTheme.colorScheme.onSurfaceVariant, 57 | ) 58 | } 59 | } 60 | actions() 61 | } 62 | } 63 | 64 | @Composable 65 | fun UtilityRadioButtonListItem( 66 | text: String, 67 | selected: Boolean, 68 | onSelect: () -> Unit, 69 | modifier: Modifier = Modifier, 70 | ) { 71 | Row( 72 | modifier = 73 | modifier 74 | .fillMaxWidth() 75 | .clickable(onClick = onSelect) 76 | .defaultMinSize(minHeight = 56.dp) 77 | .padding(end = 24.dp, top = 8.dp, bottom = 8.dp), 78 | verticalAlignment = Alignment.CenterVertically, 79 | ) { 80 | RadioButton( 81 | selected = selected, 82 | onClick = onSelect, 83 | modifier = Modifier.padding(start = 12.dp, end = 4.dp), 84 | ) 85 | Text(text = text, style = Typography.bodyLarge) 86 | } 87 | } 88 | 89 | @Composable 90 | inline fun UtilityCheckBoxListItem( 91 | text: String, 92 | checked: Boolean, 93 | crossinline onCheckedChange: (Boolean) -> Unit, 94 | modifier: Modifier = Modifier, 95 | textModifier: Modifier = Modifier, 96 | enabled: Boolean = true, 97 | crossinline actions: @Composable () -> Unit = {}, 98 | ) { 99 | Row( 100 | modifier = 101 | modifier 102 | .fillMaxWidth() 103 | .defaultMinSize(minHeight = 56.dp) 104 | .let { if (enabled) it.clickable(onClick = { onCheckedChange(!checked) }) else it } 105 | .padding(end = 12.dp, top = 8.dp, bottom = 8.dp), 106 | verticalAlignment = Alignment.CenterVertically, 107 | ) { 108 | Checkbox( 109 | checked = checked, 110 | onCheckedChange = { onCheckedChange(it) }, 111 | modifier = Modifier.padding(start = 12.dp, end = 4.dp), 112 | enabled = enabled, 113 | ) 114 | Text(text = text, style = Typography.bodyLarge, modifier = textModifier.weight(1f)) 115 | actions() 116 | } 117 | } 118 | 119 | @Composable 120 | inline fun UtilitySwitchListItem( 121 | title: String, 122 | checked: Boolean, 123 | crossinline onCheckedChange: (Boolean) -> Unit, 124 | modifier: Modifier = Modifier, 125 | subtitle: String? = null, 126 | ) { 127 | Row( 128 | modifier = 129 | modifier 130 | .fillMaxWidth() 131 | .clickable(onClick = { onCheckedChange(!checked) }) 132 | .defaultMinSize(minHeight = if (subtitle != null) 72.dp else 56.dp) 133 | .padding(horizontal = 24.dp, vertical = 8.dp), 134 | verticalAlignment = Alignment.CenterVertically, 135 | ) { 136 | Column(modifier = Modifier.weight(1f)) { 137 | Text(text = title, style = Typography.bodyLarge) 138 | if (subtitle != null) { 139 | Text( 140 | text = subtitle, 141 | style = Typography.bodySmall, 142 | color = MaterialTheme.colorScheme.onSurfaceVariant, 143 | ) 144 | } 145 | } 146 | Spacer(modifier = Modifier.width(16.dp)) 147 | Switch(checked, { onCheckedChange(it) }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/theme/Animation.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.theme 2 | 3 | import android.view.animation.PathInterpolator 4 | import androidx.compose.animation.core.Easing 5 | import androidx.compose.animation.core.TweenSpec 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.animation.expandVertically 8 | import androidx.compose.animation.fadeIn 9 | import androidx.compose.animation.fadeOut 10 | import androidx.compose.animation.scaleIn 11 | import androidx.compose.animation.shrinkVertically 12 | import androidx.compose.runtime.Immutable 13 | import androidx.compose.ui.Alignment 14 | import androidx.core.graphics.PathParser 15 | 16 | const val ENTER_DURATION = 500 17 | const val EXIT_DURATION = 200 18 | const val STANDARD_DURATION = 300 19 | 20 | fun emphasized(durationMillis: Int, delayMillis: Int = 0): TweenSpec { 21 | return tween( 22 | durationMillis = durationMillis, 23 | delayMillis = delayMillis, 24 | easing = EmphasizedEasing(), 25 | ) 26 | } 27 | 28 | fun emphasizedEnter(delayMillis: Int = 0): TweenSpec { 29 | return emphasized(ENTER_DURATION, delayMillis) 30 | } 31 | 32 | fun emphasizedExit(delayMillis: Int = 0): TweenSpec { 33 | return emphasized(EXIT_DURATION, delayMillis) 34 | } 35 | 36 | fun emphasizedStandard(delayMillis: Int = 0): TweenSpec { 37 | return emphasized(STANDARD_DURATION, delayMillis) 38 | } 39 | 40 | // region AnimatedVisibility 41 | 42 | val EnterFromTop = 43 | fadeIn(emphasizedEnter()) + expandVertically(emphasizedEnter(), expandFrom = Alignment.Top) 44 | 45 | val EnterFromBottom = 46 | fadeIn(emphasizedEnter()) + expandVertically(emphasizedEnter(), expandFrom = Alignment.Bottom) 47 | 48 | val ExitToTop = 49 | shrinkVertically(emphasizedExit(), shrinkTowards = Alignment.Top) + fadeOut(emphasizedExit()) 50 | 51 | val ExitToBottom = 52 | shrinkVertically(emphasizedExit(), shrinkTowards = Alignment.Bottom) + fadeOut(emphasizedExit()) 53 | 54 | val AnimatedContentEnter = 55 | fadeIn(animationSpec = tween(220, delayMillis = 90)) + 56 | scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) 57 | 58 | val AnimatedContentExit = fadeOut(animationSpec = tween(90)) 59 | 60 | // endregion 61 | 62 | @Immutable 63 | class EmphasizedEasing : Easing { 64 | override fun transform(fraction: Float): Float { 65 | return emphasizedInterpolator.getInterpolation(fraction) 66 | } 67 | 68 | override fun equals(other: Any?): Boolean { 69 | return other is EmphasizedEasing 70 | } 71 | 72 | override fun hashCode(): Int { 73 | return 0 74 | } 75 | } 76 | 77 | private val emphasizedInterpolator = 78 | PathInterpolator( 79 | PathParser.createPathFromPathData( 80 | "M 0,0 C 0.05, 0, 0.133333, 0.06, 0.166666, 0.4 C 0.208333, 0.82, 0.25, 1, 1, 1" 81 | ) 82 | ) 83 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.ui.text.TextStyle 6 | import androidx.compose.ui.text.style.LineHeightStyle 7 | 8 | // Set of Material typography styles to start with 9 | val Typography = typography(1.5f) 10 | 11 | private fun transform(textStyle: TextStyle, lineHeightMultiplier: Float): TextStyle { 12 | return textStyle.copy( 13 | lineHeight = textStyle.fontSize * lineHeightMultiplier, 14 | lineHeightStyle = 15 | LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None), 16 | ) 17 | } 18 | 19 | @Suppress("SameParameterValue") 20 | private fun typography(lineHeightMultiplier: Float): Typography { 21 | val default = Typography() 22 | return Typography( 23 | displayLarge = transform(default.displayLarge, lineHeightMultiplier), 24 | displayMedium = transform(default.displayMedium, lineHeightMultiplier), 25 | displaySmall = transform(default.displaySmall, lineHeightMultiplier), 26 | headlineLarge = transform(default.headlineLarge, lineHeightMultiplier), 27 | headlineMedium = transform(default.headlineMedium, lineHeightMultiplier), 28 | headlineSmall = transform(default.headlineSmall, lineHeightMultiplier), 29 | titleLarge = transform(default.titleLarge, lineHeightMultiplier), 30 | titleMedium = transform(default.titleMedium, lineHeightMultiplier), 31 | titleSmall = transform(default.titleSmall, lineHeightMultiplier), 32 | bodyLarge = transform(default.bodyLarge, lineHeightMultiplier), 33 | bodyMedium = transform(default.bodyMedium, lineHeightMultiplier), 34 | bodySmall = transform(default.bodySmall, lineHeightMultiplier), 35 | labelLarge = transform(default.labelLarge, lineHeightMultiplier), 36 | labelMedium = transform(default.labelMedium, lineHeightMultiplier), 37 | labelSmall = transform(default.labelSmall, lineHeightMultiplier), 38 | ) 39 | } 40 | 41 | @Stable 42 | fun TextStyle.toGlanceStyle(): androidx.glance.text.TextStyle { 43 | return androidx.glance.text.TextStyle(fontSize = fontSize) 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/PermissionRequestDialog.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalPermissionsApi::class) 2 | 3 | package org.sunsetware.phocid.ui.views 4 | 5 | import android.app.Activity 6 | import android.content.ContextWrapper 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.LaunchedEffect 11 | import androidx.compose.runtime.Stable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.platform.LocalContext 14 | import androidx.compose.ui.unit.dp 15 | import androidx.compose.ui.window.DialogProperties 16 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 17 | import com.google.accompanist.permissions.MultiplePermissionsState 18 | import org.sunsetware.phocid.Dialog 19 | import org.sunsetware.phocid.MainViewModel 20 | import org.sunsetware.phocid.R 21 | import org.sunsetware.phocid.globals.Strings 22 | import org.sunsetware.phocid.ui.components.DialogBase 23 | 24 | @Stable 25 | class PermissionRequestDialog( 26 | private val permissions: MultiplePermissionsState, 27 | private val onPermissionGranted: () -> Unit, 28 | ) : Dialog() { 29 | @Composable 30 | override fun Compose(viewModel: MainViewModel) { 31 | val context = LocalContext.current 32 | DialogBase( 33 | title = Strings[R.string.permission_dialog_title], 34 | onConfirm = { permissions.launchMultiplePermissionRequest() }, 35 | onDismiss = { 36 | // https://github.com/google/accompanist/blob/a9506584939ed9c79890adaaeb58de01ed0bb823/permissions/src/main/java/com/google/accompanist/permissions/PermissionsUtil.kt#L132 37 | var ctx = context 38 | while (ctx is ContextWrapper) { 39 | if (ctx is Activity) break 40 | ctx = ctx.baseContext 41 | } 42 | (ctx as? Activity)?.finishAffinity() 43 | }, 44 | confirmText = Strings[R.string.permission_dialog_grant], 45 | dismissText = Strings[R.string.commons_quit], 46 | properties = DialogProperties(dismissOnClickOutside = false), 47 | ) { 48 | Text( 49 | Strings[R.string.permission_dialog_body], 50 | modifier = Modifier.padding(horizontal = 24.dp), 51 | ) 52 | 53 | LaunchedEffect(permissions.allPermissionsGranted) { 54 | if (permissions.allPermissionsGranted) { 55 | onPermissionGranted() 56 | viewModel.uiManager.closeDialog() 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/SpeedAndPitchDialog.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package org.sunsetware.phocid.ui.views 4 | 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material3.ExperimentalMaterial3Api 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.Stable 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.mutableIntStateOf 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.runtime.setValue 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.unit.dp 18 | import kotlin.math.log 19 | import kotlin.math.pow 20 | import org.sunsetware.phocid.Dialog 21 | import org.sunsetware.phocid.MainViewModel 22 | import org.sunsetware.phocid.R 23 | import org.sunsetware.phocid.globals.Strings 24 | import org.sunsetware.phocid.ui.components.DialogBase 25 | import org.sunsetware.phocid.ui.components.SteppedSliderWithNumber 26 | import org.sunsetware.phocid.ui.components.UtilityCheckBoxListItem 27 | import org.sunsetware.phocid.ui.components.UtilityListHeader 28 | import org.sunsetware.phocid.utils.icuFormat 29 | import org.sunsetware.phocid.utils.roundToIntOrZero 30 | 31 | @Stable 32 | class SpeedAndPitchDialog() : Dialog() { 33 | @Composable 34 | override fun Compose(viewModel: MainViewModel) { 35 | val playerManager = viewModel.playerManager 36 | var newSpeedTimes100 by remember { 37 | mutableIntStateOf((viewModel.playerManager.state.value.speed * 100).roundToIntOrZero()) 38 | } 39 | var resample by remember { 40 | mutableStateOf( 41 | viewModel.playerManager.state.value.let { it.speed == it.pitch && it.speed != 1f } 42 | ) 43 | } 44 | var newPitchSemitones by remember { 45 | mutableIntStateOf( 46 | if (resample) 0 47 | else (log(viewModel.playerManager.state.value.pitch, 2f) * 12).roundToIntOrZero() 48 | ) 49 | } 50 | DialogBase( 51 | title = Strings[R.string.player_speed_and_pitch], 52 | onConfirm = { 53 | playerManager.setSpeedAndPitch( 54 | newSpeedTimes100 / 100f, 55 | if (resample) newSpeedTimes100 / 100f else 2f.pow(newPitchSemitones / 12f), 56 | ) 57 | viewModel.uiManager.closeDialog() 58 | }, 59 | onDismiss = { viewModel.uiManager.closeDialog() }, 60 | ) { 61 | Column { 62 | UtilityListHeader(Strings[R.string.player_speed_and_pitch_speed]) 63 | SteppedSliderWithNumber( 64 | number = 65 | Strings[R.string.player_speed_and_pitch_speed_number].icuFormat( 66 | newSpeedTimes100 / 100f 67 | ), 68 | onReset = { newSpeedTimes100 = 100 }, 69 | value = newSpeedTimes100.toFloat(), 70 | onValueChange = { newSpeedTimes100 = it.roundToIntOrZero() }, 71 | steps = 300 - 10 - 1, 72 | valueRange = 10f..300f, 73 | modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), 74 | ) 75 | UtilityListHeader(Strings[R.string.player_speed_and_pitch_pitch]) 76 | SteppedSliderWithNumber( 77 | number = 78 | if (resample) 79 | Strings[R.string.player_speed_and_pitch_speed_number].icuFormat( 80 | newSpeedTimes100 / 100f 81 | ) 82 | else 83 | Strings[R.string.player_speed_and_pitch_pitch_number].icuFormat( 84 | newPitchSemitones 85 | ), 86 | onReset = { newPitchSemitones = 0 }, 87 | value = 88 | if (resample) newSpeedTimes100.toFloat() else newPitchSemitones.toFloat(), 89 | onValueChange = { 90 | if (!resample) { 91 | newPitchSemitones = it.roundToIntOrZero() 92 | } 93 | }, 94 | steps = if (resample) 300 - 10 - 1 else 24 - (-24) - 1, 95 | valueRange = if (resample) 10f..300f else -24f..24f, 96 | modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), 97 | enabled = !resample, 98 | ) 99 | UtilityCheckBoxListItem( 100 | Strings[R.string.player_speed_and_pitch_match_pitch_to_speed], 101 | resample, 102 | { resample = it }, 103 | ) 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/library/LibraryTrackClickAction.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.library 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.automirrored.filled.PlaylistPlay 5 | import androidx.compose.material.icons.filled.Add 6 | import androidx.compose.material.icons.filled.ChevronRight 7 | import androidx.compose.material.icons.filled.PlayArrow 8 | import androidx.compose.ui.graphics.vector.ImageVector 9 | import kotlinx.serialization.Serializable 10 | import org.sunsetware.phocid.R 11 | import org.sunsetware.phocid.UiManager 12 | import org.sunsetware.phocid.data.PlayerManager 13 | import org.sunsetware.phocid.data.Track 14 | import org.sunsetware.phocid.globals.Strings 15 | import org.sunsetware.phocid.utils.icuFormat 16 | 17 | @Serializable 18 | enum class LibraryTrackClickAction( 19 | val stringId: Int, 20 | val icon: ImageVector?, 21 | val invoke: 22 | ( 23 | tracks: List, index: Int, playerManager: PlayerManager, uiManager: UiManager, 24 | ) -> Unit, 25 | ) { 26 | OPEN_MENU(R.string.preferences_library_track_click_action_open_menu, null, { _, _, _, _ -> }), 27 | PLAY_ALL( 28 | R.string.track_play_all, 29 | Icons.AutoMirrored.Filled.PlaylistPlay, 30 | { tracks, index, playerManager, uiManager -> playerManager.setTracks(tracks, index) }, 31 | ), 32 | PLAY( 33 | R.string.track_play, 34 | Icons.Filled.PlayArrow, 35 | { tracks, index, playerManager, uiManager -> 36 | playerManager.setTracks(listOf(tracks[index]), null) 37 | }, 38 | ), 39 | PLAY_NEXT( 40 | R.string.track_play_next, 41 | Icons.Filled.ChevronRight, 42 | { tracks, index, playerManager, uiManager -> 43 | playerManager.playNext(listOf(tracks[index])) 44 | uiManager.toast(Strings[R.string.toast_track_queued].icuFormat(1)) 45 | }, 46 | ), 47 | ADD_TO_QUEUE( 48 | R.string.track_add_to_queue, 49 | Icons.Filled.Add, 50 | { tracks, index, playerManager, uiManager -> 51 | playerManager.addTracks(listOf(tracks[index])) 52 | uiManager.toast(Strings[R.string.toast_track_queued].icuFormat(1)) 53 | }, 54 | ), 55 | } 56 | 57 | inline fun LibraryTrackClickAction.invokeOrOpenMenu( 58 | tracks: List, 59 | index: Int, 60 | playerManager: PlayerManager, 61 | uiManager: UiManager, 62 | onOpenMenu: () -> Unit, 63 | ) { 64 | if (this == LibraryTrackClickAction.OPEN_MENU) onOpenMenu() 65 | else invoke(tracks, index, playerManager, uiManager) 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/player/PlayerScreenArtwork.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.player 2 | 3 | import androidx.compose.foundation.gestures.detectVerticalDragGestures 4 | import androidx.compose.foundation.layout.aspectRatio 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.Immutable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.platform.LocalDensity 11 | import androidx.compose.ui.unit.dp 12 | import androidx.media3.common.Player 13 | import org.sunsetware.phocid.data.ArtworkColorPreference 14 | import org.sunsetware.phocid.data.HighResArtworkPreference 15 | import org.sunsetware.phocid.data.PlayerState 16 | import org.sunsetware.phocid.data.Track 17 | import org.sunsetware.phocid.ui.components.Artwork 18 | import org.sunsetware.phocid.ui.components.ArtworkCache 19 | import org.sunsetware.phocid.ui.components.ArtworkImage 20 | import org.sunsetware.phocid.ui.components.BinaryDragState 21 | import org.sunsetware.phocid.ui.components.DragLock 22 | import org.sunsetware.phocid.ui.components.TrackCarousel 23 | 24 | @Immutable 25 | sealed class PlayerScreenArtwork { 26 | @Composable 27 | abstract fun Compose( 28 | playerTransientStateVersion: Long, 29 | carouselArtworkCache: ArtworkCache, 30 | highResArtworkPreference: HighResArtworkPreference, 31 | artworkColorPreference: ArtworkColorPreference, 32 | playerState: PlayerState, 33 | playerScreenDragState: BinaryDragState, 34 | dragLock: DragLock, 35 | onGetTrackAtIndex: (PlayerState, Int) -> Track, 36 | onPrevious: () -> Unit, 37 | onNext: () -> Unit, 38 | ) 39 | } 40 | 41 | @Immutable 42 | object PlayerScreenArtworkDefault : PlayerScreenArtwork() { 43 | @Composable 44 | override fun Compose( 45 | playerTransientStateVersion: Long, 46 | carouselArtworkCache: ArtworkCache, 47 | highResArtworkPreference: HighResArtworkPreference, 48 | artworkColorPreference: ArtworkColorPreference, 49 | playerState: PlayerState, 50 | playerScreenDragState: BinaryDragState, 51 | dragLock: DragLock, 52 | onGetTrackAtIndex: (PlayerState, Int) -> Track, 53 | onPrevious: () -> Unit, 54 | onNext: () -> Unit, 55 | ) { 56 | val density = LocalDensity.current 57 | TrackCarousel( 58 | state = playerState, 59 | key = playerTransientStateVersion, 60 | countSelector = { it.actualPlayQueue.size }, 61 | indexSelector = { it.currentIndex }, 62 | repeatSelector = { it.repeat != Player.REPEAT_MODE_OFF }, 63 | indexEqualitySelector = { state, index -> 64 | if (state.shuffle) state.unshuffledPlayQueueMapping!!.indexOf(index) else index 65 | }, 66 | tapKey = Unit, 67 | onTap = {}, 68 | onVerticalDrag = { 69 | detectVerticalDragGestures( 70 | onDragStart = { playerScreenDragState.onDragStart(dragLock) }, 71 | onDragCancel = { playerScreenDragState.onDragEnd(dragLock, density) }, 72 | onDragEnd = { playerScreenDragState.onDragEnd(dragLock, density) }, 73 | ) { _, dragAmount -> 74 | playerScreenDragState.onDrag(dragLock, dragAmount) 75 | } 76 | }, 77 | onPrevious = onPrevious, 78 | onNext = onNext, 79 | modifier = Modifier.aspectRatio(1f, matchHeightConstraintsFirst = true), 80 | ) { state, index -> 81 | ArtworkImage( 82 | artwork = Artwork.Track(onGetTrackAtIndex(state, index)), 83 | artworkColorPreference = artworkColorPreference, 84 | shape = RoundedCornerShape(0.dp), 85 | modifier = Modifier.fillMaxSize(), 86 | highRes = highResArtworkPreference.player, 87 | highResCache = carouselArtworkCache, 88 | ) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/player/PlayerScreenLayout.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.player 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import androidx.compose.ui.layout.Measurable 6 | import androidx.compose.ui.layout.Placeable 7 | import androidx.compose.ui.unit.Density 8 | import org.sunsetware.phocid.ui.components.BinaryDragState 9 | 10 | @Immutable 11 | sealed class PlayerScreenLayout { 12 | /** 13 | * [queueDragState]'s [BinaryDragState.length] must be updated here. 14 | * 15 | * @param lyricsViewVisibility 0-0.5: [scrimLyrics]'s transition; 0.5-1: [lyricsView]'s 16 | * transition 17 | */ 18 | abstract fun Placeable.PlacementScope.place( 19 | topBarStandalone: Measurable, 20 | topBarOverlay: Measurable, 21 | artwork: Measurable, 22 | lyricsView: Measurable, 23 | lyricsOverlay: Measurable, 24 | controls: Measurable, 25 | queue: Measurable, 26 | scrimQueue: Measurable, 27 | scrimLyrics: Measurable, 28 | width: Int, 29 | height: Int, 30 | density: Density, 31 | queueDragState: BinaryDragState, 32 | lyricsViewVisibility: Float, 33 | ) 34 | } 35 | 36 | enum class AspectRatio { 37 | LANDSCAPE, 38 | SQUARE, 39 | PORTRAIT, 40 | } 41 | 42 | @Stable 43 | fun aspectRatio(width: Int, height: Int, threshold: Float): AspectRatio { 44 | return when { 45 | width.toFloat() / height >= threshold -> AspectRatio.LANDSCAPE 46 | height.toFloat() / width >= threshold -> AspectRatio.PORTRAIT 47 | else -> AspectRatio.SQUARE 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/playlist/PlaylistIoSyncDialogs.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.playlist 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.Stable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 16 | import org.sunsetware.phocid.Dialog 17 | import org.sunsetware.phocid.MainViewModel 18 | import org.sunsetware.phocid.R 19 | import org.sunsetware.phocid.globals.Strings 20 | import org.sunsetware.phocid.ui.components.DialogBase 21 | import org.sunsetware.phocid.ui.components.EmptyListIndicator 22 | 23 | @Stable 24 | class PlaylistIoSyncLogDialog : Dialog() { 25 | @Composable 26 | override fun Compose(viewModel: MainViewModel) { 27 | val syncLog by viewModel.playlistManager.syncLog.collectAsStateWithLifecycle() 28 | 29 | DialogBase( 30 | title = Strings[R.string.playlist_io_sync_log], 31 | onConfirmOrDismiss = { viewModel.uiManager.closeDialog() }, 32 | ) { 33 | if (syncLog == null) { 34 | EmptyListIndicator() 35 | } else { 36 | Text( 37 | syncLog!!, 38 | modifier = 39 | Modifier.fillMaxWidth() 40 | .verticalScroll(rememberScrollState()) 41 | .padding(horizontal = 24.dp), 42 | ) 43 | } 44 | } 45 | } 46 | } 47 | 48 | @Stable 49 | class PlaylistIoSyncHelpDialog() : Dialog() { 50 | @Composable 51 | override fun Compose(viewModel: MainViewModel) { 52 | DialogBase( 53 | title = Strings[R.string.playlist_io_sync_help], 54 | onConfirmOrDismiss = { viewModel.uiManager.closeDialog() }, 55 | ) { 56 | Column( 57 | modifier = 58 | Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 24.dp), 59 | verticalArrangement = Arrangement.spacedBy(16.dp), 60 | ) { 61 | Text(Strings[R.string.playlist_io_sync_help_body]) 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/preferences/PreferencesLicenseDialog.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.preferences 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.horizontalScroll 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.rememberScrollState 7 | import androidx.compose.foundation.verticalScroll 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.Stable 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.unit.dp 16 | import org.sunsetware.phocid.Dialog 17 | import org.sunsetware.phocid.MainViewModel 18 | import org.sunsetware.phocid.R 19 | import org.sunsetware.phocid.globals.Strings 20 | import org.sunsetware.phocid.ui.components.DialogBase 21 | 22 | @Stable 23 | class PreferencesLicenseDialog() : Dialog() { 24 | @Composable 25 | override fun Compose(viewModel: MainViewModel) { 26 | DialogBase( 27 | title = Strings[R.string.preferences_license], 28 | onConfirmOrDismiss = { viewModel.uiManager.closeDialog() }, 29 | ) { 30 | val context = LocalContext.current 31 | val text = remember { 32 | context.getString(R.string.app_copyright) + 33 | "\n\n---\n" + 34 | context.assets.open("GPL-3.0.txt").readBytes().decodeToString() 35 | } 36 | Text( 37 | text, 38 | modifier = 39 | Modifier.horizontalScroll(rememberScrollState()) 40 | .verticalScroll(rememberScrollState()) 41 | .background(MaterialTheme.colorScheme.surfaceContainer) 42 | .padding(24.dp), 43 | ) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/preferences/PreferencesSingleChoiceDialog.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.preferences 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.foundation.lazy.items 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.Stable 7 | import androidx.compose.runtime.getValue 8 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 9 | import org.sunsetware.phocid.Dialog 10 | import org.sunsetware.phocid.MainViewModel 11 | import org.sunsetware.phocid.data.Preferences 12 | import org.sunsetware.phocid.ui.components.DialogBase 13 | import org.sunsetware.phocid.ui.components.UtilityRadioButtonListItem 14 | 15 | @Stable 16 | class PreferencesSingleChoiceDialog( 17 | val title: String, 18 | val options: List>, 19 | val activeOption: (Preferences) -> T, 20 | val updatePreferences: (Preferences, T) -> Preferences, 21 | ) : Dialog() { 22 | @Composable 23 | override fun Compose(viewModel: MainViewModel) { 24 | val preferences by viewModel.preferences.collectAsStateWithLifecycle() 25 | DialogBase(title = title, onConfirmOrDismiss = { viewModel.uiManager.closeDialog() }) { 26 | LazyColumn { 27 | items(options) { (option, name) -> 28 | UtilityRadioButtonListItem( 29 | text = name, 30 | selected = activeOption(preferences) == option, 31 | onSelect = { viewModel.updatePreferences { updatePreferences(it, option) } }, 32 | ) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/preferences/PreferencesSortingLocaleDialog.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.preferences 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.foundation.lazy.itemsIndexed 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.Stable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.runtime.mutableIntStateOf 9 | import androidx.compose.runtime.saveable.rememberSaveable 10 | import androidx.compose.runtime.setValue 11 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 12 | import com.ibm.icu.text.Collator 13 | import org.sunsetware.phocid.Dialog 14 | import org.sunsetware.phocid.MainViewModel 15 | import org.sunsetware.phocid.R 16 | import org.sunsetware.phocid.globals.Strings 17 | import org.sunsetware.phocid.ui.components.DialogBase 18 | import org.sunsetware.phocid.ui.components.UtilityRadioButtonListItem 19 | 20 | @Stable 21 | class PreferencesSortingLocaleDialog : Dialog() { 22 | @Composable 23 | override fun Compose(viewModel: MainViewModel) { 24 | val preferences by viewModel.preferences.collectAsStateWithLifecycle() 25 | val availableLocales = rememberSaveable { 26 | listOf(null) + 27 | Collator.getAvailableLocales() 28 | .sortedBy { it.toLanguageTag() } 29 | .filter { it.language != "zh" || it.country.isEmpty() } 30 | } 31 | var selectedIndex by rememberSaveable { 32 | mutableIntStateOf(availableLocales.indexOf(preferences.sortingLocale).coerceAtLeast(0)) 33 | } 34 | DialogBase( 35 | title = Strings[R.string.preferences_sorting_language], 36 | onConfirm = { 37 | viewModel.updatePreferences { 38 | it.copy( 39 | sortingLocaleLanguageTag = availableLocales[selectedIndex]?.toLanguageTag() 40 | ) 41 | } 42 | viewModel.uiManager.closeDialog() 43 | }, 44 | onDismiss = { viewModel.uiManager.closeDialog() }, 45 | ) { 46 | LazyColumn { 47 | itemsIndexed(availableLocales) { index, locale -> 48 | UtilityRadioButtonListItem( 49 | text = 50 | locale?.let { "${it.displayName} (${it.toLanguageTag()})" } 51 | ?: Strings[R.string.preferences_sorting_language_system], 52 | selected = selectedIndex == index, 53 | onSelect = { selectedIndex = index }, 54 | ) 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/preferences/PreferencesSteppedSliderDialog.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalMaterial3Api::class) 2 | 3 | package org.sunsetware.phocid.ui.views.preferences 4 | 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.ExperimentalMaterial3Api 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.Stable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableIntStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.unit.dp 15 | import org.sunsetware.phocid.Dialog 16 | import org.sunsetware.phocid.MainViewModel 17 | import org.sunsetware.phocid.ui.components.DialogBase 18 | import org.sunsetware.phocid.ui.components.SteppedSliderWithNumber 19 | import org.sunsetware.phocid.utils.roundToIntOrZero 20 | 21 | @Stable 22 | class PreferencesSteppedSliderDialog( 23 | private val title: String, 24 | private val initialValue: (MainViewModel) -> Int, 25 | private val defaultValue: Int, 26 | private val min: Int, 27 | private val max: Int, 28 | private val numberFormatter: (Int) -> String, 29 | private val onSetValue: (MainViewModel, Int) -> Unit, 30 | ) : Dialog() { 31 | @Composable 32 | override fun Compose(viewModel: MainViewModel) { 33 | var value by remember { mutableIntStateOf(initialValue(viewModel)) } 34 | DialogBase( 35 | title = title, 36 | onConfirm = { 37 | onSetValue(viewModel, value) 38 | viewModel.uiManager.closeDialog() 39 | }, 40 | onDismiss = { viewModel.uiManager.closeDialog() }, 41 | ) { 42 | SteppedSliderWithNumber( 43 | number = numberFormatter(value), 44 | onReset = { value = defaultValue }, 45 | value = value.toFloat(), 46 | onValueChange = { value = it.roundToIntOrZero() }, 47 | steps = max - min - 1, 48 | valueRange = min.toFloat()..max.toFloat(), 49 | modifier = Modifier.padding(horizontal = 24.dp), 50 | ) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/ui/views/preferences/PreferencesThirdPartyLicensesDialog.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.ui.views.preferences 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.horizontalScroll 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.foundation.rememberScrollState 10 | import androidx.compose.foundation.verticalScroll 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.ArrowDropDown 13 | import androidx.compose.material.icons.filled.ArrowDropUp 14 | import androidx.compose.material3.Icon 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.runtime.mutableStateOf 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.runtime.setValue 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.platform.LocalContext 24 | import androidx.compose.ui.unit.dp 25 | import org.sunsetware.phocid.Dialog 26 | import org.sunsetware.phocid.MainViewModel 27 | import org.sunsetware.phocid.R 28 | import org.sunsetware.phocid.data.listDependencies 29 | import org.sunsetware.phocid.globals.Strings 30 | import org.sunsetware.phocid.ui.components.DialogBase 31 | import org.sunsetware.phocid.ui.components.UtilityListItem 32 | 33 | class PreferencesThirdPartyLicensesDialog : Dialog() { 34 | @Composable 35 | override fun Compose(viewModel: MainViewModel) { 36 | val context = LocalContext.current 37 | val dependencies = remember { listDependencies(context) } 38 | var expandedIndex by remember { mutableStateOf(null as Int?) } 39 | DialogBase( 40 | Strings[R.string.preferences_third_party_licenses], 41 | onConfirmOrDismiss = { viewModel.uiManager.closeDialog() }, 42 | ) { 43 | // Don't use LazyColumn here unless you want a "Compose internal error" in release build 44 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 45 | dependencies.forEachIndexed { index, (dependency, licenseTexts) -> 46 | with(dependency) { 47 | Expander( 48 | project, 49 | version, 50 | expandedIndex == index, 51 | { expandedIndex = if (expandedIndex != index) index else null }, 52 | ) { 53 | Column( 54 | modifier = 55 | Modifier.fillMaxWidth() 56 | .horizontalScroll(rememberScrollState()) 57 | .background(MaterialTheme.colorScheme.surfaceContainer) 58 | .padding(24.dp) 59 | ) { 60 | Text("© " + developers.joinToString(", ")) 61 | if (url != null) Text(url) 62 | licenseTexts.forEach { Text(it) } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | @Composable 72 | private fun Expander( 73 | title: String, 74 | subtitle: String?, 75 | expanded: Boolean, 76 | onToggleExpansion: () -> Unit, 77 | content: @Composable () -> Unit, 78 | ) { 79 | Column { 80 | UtilityListItem( 81 | title = title, 82 | subtitle = subtitle, 83 | modifier = Modifier.clickable(onClick = onToggleExpansion), 84 | actions = { 85 | Icon( 86 | if (expanded) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, 87 | null, 88 | tint = MaterialTheme.colorScheme.primary, 89 | ) 90 | }, 91 | ) 92 | if (expanded) { 93 | content() 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/AsyncCache.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.utils 2 | 3 | import java.util.concurrent.ConcurrentHashMap 4 | import java.util.concurrent.atomic.AtomicReference 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.CoroutineStart 7 | import kotlinx.coroutines.Deferred 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.async 11 | import kotlinx.coroutines.launch 12 | import kotlinx.coroutines.runBlocking 13 | import kotlinx.coroutines.sync.Mutex 14 | import kotlinx.coroutines.sync.withLock 15 | import kotlinx.coroutines.withContext 16 | 17 | /** This cache is only designed to have a very small size, due to lookup having O(n) complexity. */ 18 | class AsyncCache( 19 | private val coroutineScope: CoroutineScope, 20 | val size: Int, 21 | ) { 22 | private val mutex = Mutex() 23 | private val evictionJob = AtomicReference(null as Job?) 24 | private val creationJobs = ConcurrentHashMap>() 25 | private val entries = mutableListOf>() 26 | 27 | val actualSize 28 | get() = entries.size 29 | 30 | suspend fun getOrPut(key: TKey, create: (TKey) -> TValue): TValue { 31 | val existing = 32 | mutex.withLock { 33 | entries 34 | .indexOfFirst { it.first == key } 35 | .takeIf { it >= 0 } 36 | ?.let { entries.removeAt(it) } 37 | ?.apply { entries += this } 38 | ?.second 39 | } 40 | if (existing != null) { 41 | return existing 42 | } 43 | 44 | val newCreationJob = 45 | coroutineScope.async(start = CoroutineStart.LAZY) { 46 | withContext(Dispatchers.IO) { 47 | val value = create(key) 48 | mutex.withLock { 49 | entries.removeIf { it.first == key } 50 | entries += key to value 51 | } 52 | creationJobs.remove(key) 53 | value 54 | } 55 | } 56 | val creationJob = creationJobs.getOrPut(key) { newCreationJob } 57 | if (creationJob != newCreationJob) { 58 | newCreationJob.cancel() 59 | } 60 | 61 | val newEvitionJob = getEvictionJob() 62 | if (evictionJob.compareAndSet(null, newEvitionJob)) { 63 | newEvitionJob.start() 64 | } else { 65 | newEvitionJob.cancel() 66 | } 67 | 68 | return creationJob.await() 69 | } 70 | 71 | fun get(key: TKey): TValue? { 72 | return runBlocking { 73 | mutex.withLock { 74 | entries 75 | .indexOfFirst { it.first == key } 76 | .takeIf { it >= 0 } 77 | ?.let { entries.removeAt(it) } 78 | ?.apply { entries += this } 79 | ?.second 80 | } 81 | } 82 | } 83 | 84 | private fun getEvictionJob(): Job { 85 | return coroutineScope.launch(start = CoroutineStart.LAZY) { 86 | withContext(Dispatchers.IO) { 87 | mutex.withLock { 88 | if (entries.size > size) { 89 | entries.subList(0, entries.size - size).clear() 90 | } 91 | 92 | evictionJob.set(null) 93 | } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/Boxed.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.utils 2 | 3 | data class Boxed(val value: T) 4 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/Collections.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.utils 2 | 3 | import kotlin.time.Duration 4 | 5 | fun Iterable.replace(index: Int, value: T): List { 6 | return mapIndexed { i, old -> if (index == i) value else old } 7 | } 8 | 9 | inline fun Iterable.replace(index: Int, crossinline transform: (T) -> T): List { 10 | return mapIndexed { i, old -> if (index == i) transform(old) else old } 11 | } 12 | 13 | fun Iterable.removeAt(index: Int): List { 14 | return filterIndexed { i, _ -> i != index } 15 | } 16 | 17 | fun List.swap(indexA: Int, indexB: Int): List { 18 | val a = this[indexA] 19 | val b = this[indexB] 20 | return mapIndexed { index, value -> 21 | when (index) { 22 | indexA -> b 23 | indexB -> a 24 | else -> value 25 | } 26 | } 27 | } 28 | 29 | inline fun Iterable.mode(selector: (T) -> R): R { 30 | return groupBy { selector(it) }.maxBy { it.value.size }.key 31 | } 32 | 33 | fun Iterable.mode(): T { 34 | return mode { it } 35 | } 36 | 37 | inline fun Iterable.modeOrNull(selector: (T) -> R): R? { 38 | return groupBy { selector(it) }.maxByOrNull { it.value.size }?.key 39 | } 40 | 41 | fun Iterable.modeOrNull(): T? { 42 | return modeOrNull { it } 43 | } 44 | 45 | inline fun Iterable.modeOfNotNullOrNull(selector: (T) -> R): R? { 46 | return groupBy { selector(it) }.filter { it.key != null }.maxByOrNull { it.value.size }?.key 47 | } 48 | 49 | /** Not named `sumOf` because [OverloadResolutionByLambdaReturnType] doesn't work. */ 50 | inline fun Iterable.sumOfDuration(transform: (T) -> Duration): Duration { 51 | return map(transform).fold(Duration.ZERO, Duration::plus) 52 | } 53 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/Initialism.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.utils 2 | 3 | import com.ibm.icu.lang.UCharacter 4 | import com.ibm.icu.text.Transliterator 5 | import com.ibm.icu.text.UnicodeSet 6 | import java.util.Locale 7 | 8 | /** Input is assumed to be NFC normalized. */ 9 | fun String.initialLetter(locale: Locale): String { 10 | val first = firstCharacter() 11 | return when { 12 | first == null -> symbolInitial 13 | hanSet.contains(first) -> first.initialHan(locale) 14 | kanaSet.contains(first) -> first.initialKana() 15 | letterSet.contains(first) -> UCharacter.toUpperCase(locale, first) 16 | else -> symbolInitial 17 | } 18 | } 19 | 20 | private const val symbolInitial = "#" 21 | 22 | private val hanSet = UnicodeSet("[:Hani:]").freeze() 23 | private val kanaSet = UnicodeSet("[[:Hira:]+[:Kana:]]").freeze() 24 | private val letterSet = UnicodeSet("[:Letter:]").freeze() 25 | 26 | private val pinyinTransliterator = 27 | Transliterator.getInstance("Han-Latin; NFD; [:Mark:] Remove; NFC; Lower") 28 | 29 | private fun String.initialHan(locale: Locale): String { 30 | // ICU only has Han -> Pinyin conversion, so we can't get a meaningful result for 31 | // locales other than zh; 32 | // CLDR sorts all zh-Hant locales by stroke, so they should be also excluded 33 | return if ( 34 | locale.language == "zh" && 35 | locale.toLanguageTag().let { 36 | it.contains("Hans") || 37 | !it.contains("Hant") && 38 | !it.contains("HK") && 39 | !it.contains("MO") && 40 | !it.contains("TW") 41 | } 42 | ) 43 | pinyinTransliterator.transliterate(this).firstCharacter() ?: symbolInitial 44 | else this 45 | } 46 | 47 | private val kanaTransliterator = 48 | Transliterator.getInstance("NFD; [:Mark:] Remove; NFC; Any-Latin; [:^Letter:] Remove; Lower") 49 | 50 | private fun String.initialKana(): String { 51 | val s = kanaTransliterator.transliterate(this) 52 | val c = s.firstCharacter() 53 | return when (s) { 54 | "a", 55 | "i", 56 | "u", 57 | "e", 58 | "o" -> "あ" 59 | "n" -> "ん" 60 | else -> 61 | when (c) { 62 | "k" -> "か" 63 | "s" -> "さ" 64 | "t", 65 | "c" -> "た" 66 | "n" -> "な" 67 | "h", 68 | "f" -> "は" 69 | "m" -> "ま" 70 | "y" -> "や" 71 | "r" -> "ら" 72 | "w" -> "わ" 73 | else -> this 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/Math.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.utils 2 | 3 | import kotlin.math.roundToInt 4 | 5 | fun Int.wrap(other: Int, repeat: Boolean): Int? { 6 | return if (other > 0) { 7 | if (repeat) mod(other) else this.takeIf { it in 0.. Boolean, 23 | ): Map? { 24 | val resolver = context.contentResolver 25 | val stack = mutableListOf(null as String? to uri) 26 | val results = mutableMapOf() 27 | 28 | // Android API source used a suspicious try catch here, keeping it just in case 29 | try { 30 | while (stack.isNotEmpty()) { 31 | val (currentPrefix, currentUri) = stack.removeAt(stack.size - 1) 32 | val treeUri = 33 | DocumentsContract.buildDocumentUriUsingTree( 34 | currentUri, 35 | DocumentsContract.getTreeDocumentId(currentUri), 36 | ) 37 | val childrenUri = 38 | DocumentsContract.buildChildDocumentsUriUsingTree( 39 | treeUri, 40 | DocumentsContract.getDocumentId( 41 | if (DocumentsContract.isDocumentUri(context, currentUri)) { 42 | DocumentsContract.buildDocumentUriUsingTree( 43 | currentUri, 44 | DocumentsContract.getDocumentId(currentUri), 45 | ) 46 | } else treeUri 47 | ), 48 | ) 49 | requireNotNull( 50 | resolver.query( 51 | childrenUri, 52 | arrayOf( 53 | DocumentsContract.Document.COLUMN_MIME_TYPE, 54 | DocumentsContract.Document.COLUMN_DOCUMENT_ID, 55 | DocumentsContract.Document.COLUMN_DISPLAY_NAME, 56 | DocumentsContract.Document.COLUMN_LAST_MODIFIED, 57 | ), 58 | null, 59 | null, 60 | null, 61 | ) 62 | ) 63 | .use { cursor -> 64 | while (cursor.moveToNext()) { 65 | val mimeType = cursor.getString(0) 66 | val documentId = cursor.getString(1) 67 | val name = cursor.getString(2) 68 | val relativePath = currentPrefix?.let { "$it/$name" } ?: name 69 | val documentUri = 70 | DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) 71 | 72 | if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { 73 | if (recursive) { 74 | stack.add(relativePath to documentUri) 75 | } 76 | } else { 77 | val lastModified = cursor.getLongOrNull(3) 78 | val file = SafFile(documentUri, name, relativePath, lastModified) 79 | if (filter(file)) { 80 | results[relativePath] = file 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } catch (ex: Exception) { 87 | Log.e("Phocid", "Error listing files for $uri", ex) 88 | return null 89 | } 90 | 91 | return results 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/Serializers.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.utils 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.graphics.toArgb 5 | import java.util.UUID 6 | import kotlinx.serialization.KSerializer 7 | import kotlinx.serialization.descriptors.PrimitiveKind 8 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 9 | import kotlinx.serialization.descriptors.SerialDescriptor 10 | import kotlinx.serialization.encoding.Decoder 11 | import kotlinx.serialization.encoding.Encoder 12 | 13 | object UUIDSerializer : KSerializer { 14 | override val descriptor: SerialDescriptor = 15 | PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) 16 | 17 | override fun serialize(encoder: Encoder, value: UUID) { 18 | encoder.encodeString(value.toString()) 19 | } 20 | 21 | override fun deserialize(decoder: Decoder): UUID { 22 | return UUID.fromString(decoder.decodeString()) 23 | } 24 | } 25 | 26 | object ColorSerializer : KSerializer { 27 | override val descriptor: SerialDescriptor = 28 | PrimitiveSerialDescriptor("Color", PrimitiveKind.INT) 29 | 30 | override fun serialize(encoder: Encoder, value: Color) { 31 | encoder.encodeInt(value.toArgb()) 32 | } 33 | 34 | override fun deserialize(decoder: Decoder): Color { 35 | return Color(decoder.decodeInt()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/StateFlow.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalCoroutinesApi::class) 2 | 3 | package org.sunsetware.phocid.utils 4 | 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.SharingStarted 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.flow.combine 11 | import kotlinx.coroutines.flow.flatMapLatest 12 | import kotlinx.coroutines.flow.map 13 | import kotlinx.coroutines.flow.runningReduce 14 | import kotlinx.coroutines.flow.stateIn 15 | 16 | const val STOP_TIMEOUT = 5000L 17 | 18 | /** 19 | * [kotlinx.coroutines#2514](https://github.com/Kotlin/kotlinx.coroutines/issues/2514#issuecomment-944078630) 20 | */ 21 | inline fun StateFlow.map( 22 | coroutineScope: CoroutineScope, 23 | hot: Boolean = false, 24 | crossinline transform: (value: T) -> R, 25 | ): StateFlow { 26 | return map { transform(it) } 27 | .stateIn( 28 | coroutineScope, 29 | if (hot) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(STOP_TIMEOUT), 30 | transform(value), 31 | ) 32 | } 33 | 34 | inline fun StateFlow.combine( 35 | coroutineScope: CoroutineScope, 36 | flow: StateFlow, 37 | hot: Boolean = false, 38 | crossinline transform: (a: T1, b: T2) -> R, 39 | ): StateFlow { 40 | return combine(flow) { a, b -> transform(a, b) } 41 | .stateIn( 42 | coroutineScope, 43 | if (hot) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(STOP_TIMEOUT), 44 | transform(value, flow.value), 45 | ) 46 | } 47 | 48 | inline fun StateFlow.combine( 49 | coroutineScope: CoroutineScope, 50 | flow2: StateFlow, 51 | flow3: StateFlow, 52 | hot: Boolean = false, 53 | crossinline transform: (a: T1, b: T2, c: T3) -> R, 54 | ): StateFlow { 55 | return combine(flow2) { a, b -> Pair(a, b) } 56 | .combine(flow3) { (a, b), c -> transform(a, b, c) } 57 | .stateIn( 58 | coroutineScope, 59 | if (hot) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(STOP_TIMEOUT), 60 | transform(value, flow2.value, flow3.value), 61 | ) 62 | } 63 | 64 | inline fun StateFlow.combine( 65 | coroutineScope: CoroutineScope, 66 | flow2: StateFlow, 67 | flow3: StateFlow, 68 | flow4: StateFlow, 69 | hot: Boolean = false, 70 | crossinline transform: (a: T1, b: T2, c: T3, d: T4) -> R, 71 | ): StateFlow { 72 | return combine(flow2) { a, b -> Pair(a, b) } 73 | .combine(flow3) { (a, b), c -> Triple(a, b, c) } 74 | .combine(flow4) { (a, b, c), d -> transform(a, b, c, d) } 75 | .stateIn( 76 | coroutineScope, 77 | if (hot) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(STOP_TIMEOUT), 78 | transform(value, flow2.value, flow3.value, flow4.value), 79 | ) 80 | } 81 | 82 | fun List>.combine( 83 | coroutineScope: CoroutineScope, 84 | hot: Boolean = false, 85 | ): StateFlow> { 86 | return if (isEmpty()) { 87 | MutableStateFlow(emptyList()) 88 | } else { 89 | drop(1).fold(this[0].map(coroutineScope, hot) { listOf(it) }) { flowA, flowB -> 90 | flowA.combine(coroutineScope, flowB, hot) { a, b -> a + b } 91 | } 92 | } 93 | } 94 | 95 | inline fun StateFlow.flatMapLatest( 96 | coroutineScope: CoroutineScope, 97 | hot: Boolean = false, 98 | crossinline transform: (value: T) -> StateFlow, 99 | ): StateFlow { 100 | return flatMapLatest { transform(it) } 101 | .stateIn( 102 | coroutineScope, 103 | if (hot) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(STOP_TIMEOUT), 104 | transform(value).value, 105 | ) 106 | } 107 | 108 | fun StateFlow.runningReduce( 109 | coroutineScope: CoroutineScope, 110 | hot: Boolean = false, 111 | operation: (accumulator: T, value: T) -> T, 112 | ): StateFlow { 113 | return runningReduce(operation) 114 | .stateIn( 115 | coroutineScope, 116 | if (hot) SharingStarted.Eagerly else SharingStarted.WhileSubscribed(STOP_TIMEOUT), 117 | value, 118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /app/src/main/java/org/sunsetware/phocid/utils/String.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid.utils 2 | 3 | import android.util.Log 4 | import com.ibm.icu.lang.UCharacter 5 | import com.ibm.icu.text.CharsetDetector 6 | import com.ibm.icu.text.MessageFormat 7 | import com.ibm.icu.text.Normalizer2 8 | import java.nio.ByteBuffer 9 | import java.nio.charset.Charset 10 | import kotlinx.serialization.KSerializer 11 | import kotlinx.serialization.Serializable 12 | import kotlinx.serialization.builtins.MapSerializer 13 | import kotlinx.serialization.builtins.serializer 14 | import kotlinx.serialization.descriptors.SerialDescriptor 15 | import kotlinx.serialization.encoding.Decoder 16 | import kotlinx.serialization.encoding.Encoder 17 | 18 | private val casefolder = Normalizer2.getNFKCCasefoldInstance() 19 | 20 | fun String.trimAndNormalize(): String { 21 | return Normalizer2.getNFCInstance().normalize(this.trim()) 22 | } 23 | 24 | fun String.icuFormat(vararg args: Any?): String { 25 | return try { 26 | MessageFormat.format(this, *args) 27 | } catch (ex: Exception) { 28 | Log.e("Phocid", "Can't format string \"$this\" with (${args.joinToString(", ")})", ex) 29 | this 30 | } 31 | } 32 | 33 | fun String.firstCharacter(): String? { 34 | return if (isEmpty()) null else UCharacter.toString(codePointAt(0)) 35 | } 36 | 37 | fun ByteArray.decodeWithCharsetName(charsetName: String?): String { 38 | return if (charsetName != null && Charset.isSupported(charsetName)) { 39 | Charset.forName(charsetName).decode(ByteBuffer.wrap(this)).toString() 40 | } else { 41 | CharsetDetector().setText(this).detect().string 42 | } 43 | } 44 | 45 | fun Iterable.distinctCaseInsensitive(): List { 46 | return groupBy { casefolder.normalize(it) }.map { it.value.mode() } 47 | } 48 | 49 | @Serializable(with = CaseInsensitiveMapSerializer::class) 50 | class CaseInsensitiveMap private constructor(private val inner: Map) : 51 | Map { 52 | constructor( 53 | map: Map, 54 | combinator: (List) -> T, 55 | ) : this( 56 | map.map { (key, value) -> Pair(casefolder.normalize(key), value) } 57 | .groupBy({ it.first }, { it.second }) 58 | .map { Pair(it.key, combinator(it.value)) } 59 | .toMap() 60 | ) 61 | 62 | override val entries 63 | get() = inner.entries 64 | 65 | override val keys 66 | get() = inner.keys 67 | 68 | override val size 69 | get() = inner.size 70 | 71 | override val values 72 | get() = inner.values 73 | 74 | override fun isEmpty(): Boolean { 75 | return inner.isEmpty() 76 | } 77 | 78 | override fun get(key: String): T? { 79 | return inner[casefolder.normalize(key)] 80 | } 81 | 82 | override fun containsValue(value: T): Boolean { 83 | return inner.containsValue(value) 84 | } 85 | 86 | override fun containsKey(key: String): Boolean { 87 | return inner.containsKey(casefolder.normalize(key)) 88 | } 89 | 90 | fun map(transform: (Map.Entry) -> R): CaseInsensitiveMap { 91 | return CaseInsensitiveMap(inner.mapValues(transform)) 92 | } 93 | 94 | companion object { 95 | fun noMerge(map: Map): CaseInsensitiveMap { 96 | return CaseInsensitiveMap(map.mapKeys { casefolder.normalize(it.key) }) 97 | } 98 | } 99 | } 100 | 101 | class CaseInsensitiveMapSerializer(valueSerializer: KSerializer) : 102 | KSerializer> { 103 | private val surrogateSerializer = MapSerializer(String.serializer(), valueSerializer) 104 | override val descriptor: SerialDescriptor = surrogateSerializer.descriptor 105 | 106 | override fun serialize(encoder: Encoder, value: CaseInsensitiveMap) { 107 | encoder.encodeSerializableValue(surrogateSerializer, value) 108 | } 109 | 110 | override fun deserialize(decoder: Decoder): CaseInsensitiveMap { 111 | return CaseInsensitiveMap(decoder.decodeSerializableValue(surrogateSerializer)) { 112 | if (it.size != 1) { 113 | throw IllegalArgumentException( 114 | "Deserializing CaseInsensitiveMap with conflicting keys" 115 | ) 116 | } 117 | it.first() 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-hdpi/notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/drawable-hdpi/notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-mdpi/notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/drawable-mdpi/notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xhdpi/notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/drawable-xhdpi/notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/drawable-xxhdpi/notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxxhdpi/notification_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/drawable-xxxhdpi/notification_icon.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_next.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_pause.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_previous.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_repeat.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_repeat_one.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/player_shuffle.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shortcut_continue.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shortcut_shuffle.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/widget_preview_small.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 13 | 17 | 21 | 22 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values-v31/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FF312C50 4 | #FF605790 5 | #FFFFFFFF 6 | #FF605790 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/media3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | @drawable/notification_icon 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/xml-v31/my_app_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/automotive_app_desc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/xml/my_app_widget_info.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/src/main/res/xml/shortcuts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 13 | 14 | 20 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/test/java/org/sunsetware/phocid/AsyncCacheTest.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid 2 | 3 | import java.util.concurrent.atomic.AtomicInteger 4 | import kotlinx.coroutines.delay 5 | import kotlinx.coroutines.runBlocking 6 | import org.assertj.core.api.Assertions.* 7 | import org.junit.Test 8 | import org.sunsetware.phocid.utils.AsyncCache 9 | import org.sunsetware.phocid.utils.Random 10 | 11 | class AsyncCacheTest { 12 | @Test 13 | fun testBasicFunctionality() { 14 | runBlocking { 15 | val cache = AsyncCache(this, 4) 16 | 17 | for (i in 0..<100) { 18 | assertThat(cache.getOrPut(i) { i }).isEqualTo(i) 19 | } 20 | for (i in 0..<100) { 21 | assertThat(cache.getOrPut(i) { 0 }).isEqualTo(0) 22 | } 23 | } 24 | } 25 | 26 | @Test 27 | fun testNoDuplicateCreation() { 28 | repeat(10) { 29 | runBlocking { 30 | val cache = AsyncCache(this, 4) 31 | 32 | val createCount = AtomicInteger(0) 33 | repeat(100) { 34 | cache.getOrPut(0) { 35 | Thread.sleep(Random.nextLong(0, 100)) 36 | createCount.incrementAndGet() 37 | } 38 | } 39 | 40 | assertThat(createCount.get()).isEqualTo(1) 41 | assertThat(cache.getOrPut(0) { 0 }).isEqualTo(1) 42 | } 43 | } 44 | } 45 | 46 | @Test 47 | fun testEviction() { 48 | repeat(10) { 49 | runBlocking { 50 | val cache = AsyncCache(this, 4) 51 | 52 | for (i in 0..<100) { 53 | cache.getOrPut(i) { i } 54 | } 55 | 56 | delay(100) 57 | 58 | assertThat(cache.actualSize).isBetween(4, 5) 59 | 60 | for (i in 100 - 4..<100) { 61 | assertThat(cache.get(i)).isNotNull() 62 | } 63 | assertThat(cache.get(0)).isNull() 64 | } 65 | } 66 | } 67 | 68 | @Test 69 | fun testRenewal() { 70 | repeat(10) { 71 | runBlocking { 72 | val cache = AsyncCache(this, 4) 73 | 74 | for (i in 0..<100) { 75 | cache.getOrPut(i) { i } 76 | cache.get(0) 77 | } 78 | 79 | delay(100) 80 | 81 | assertThat(cache.get(0)).isNotNull() 82 | } 83 | 84 | runBlocking { 85 | val cache = AsyncCache(this, 4) 86 | 87 | for (i in 0..<100) { 88 | cache.getOrPut(i) { i } 89 | cache.getOrPut(0) { i } 90 | } 91 | 92 | delay(100) 93 | 94 | assertThat(cache.get(0)).isEqualTo(0) 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/src/test/java/org/sunsetware/phocid/DependencyLicenseTest.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid 2 | 3 | import java.io.File 4 | import org.apache.commons.io.FilenameUtils 5 | import org.junit.Test 6 | import org.sunsetware.phocid.data.listDependencies 7 | 8 | class DependencyLicenseTest { 9 | @Test 10 | fun noMissingLicenseText() { 11 | listDependencies { File(FilenameUtils.concat("src/main/assets", it)).readText() } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/src/test/java/org/sunsetware/phocid/InitialismTest.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid 2 | 3 | import com.ibm.icu.lang.UCharacter 4 | import java.util.Locale 5 | import kotlin.streams.toList 6 | import org.assertj.core.api.Assertions.* 7 | import org.junit.Test 8 | import org.sunsetware.phocid.utils.initialLetter 9 | 10 | class InitialismTest { 11 | @Test 12 | fun testInitialLetter() { 13 | assertThat("".initialLetter(Locale.ROOT)).isEqualTo("#") 14 | assertThat(" ABC".initialLetter(Locale.ROOT)).isEqualTo("#") 15 | assertThat(".ABC".initialLetter(Locale.ROOT)).isEqualTo("#") 16 | assertThat("123".initialLetter(Locale.ROOT)).isEqualTo("#") 17 | assertThat("😄".initialLetter(Locale.ROOT)).isEqualTo("#") 18 | assertThat("ABC".initialLetter(Locale.ROOT)).isEqualTo("A") 19 | assertThat("abc".initialLetter(Locale.ROOT)).isEqualTo("A") 20 | assertThat("abc".initialLetter(Locale.ROOT)).isEqualTo("A") 21 | assertThat("àbĆ".initialLetter(Locale.ROOT)).isEqualTo("À") 22 | assertThat("汉字".initialLetter(Locale.CHINESE)).isEqualTo("h") 23 | assertThat("汉字".initialLetter(Locale.SIMPLIFIED_CHINESE)).isEqualTo("h") 24 | assertThat("漢字".initialLetter(Locale.CHINESE)).isEqualTo("h") 25 | assertThat("\uD883\uDEDD".initialLetter(Locale.CHINESE)).isEqualTo("b") 26 | assertThat("漢字".initialLetter(Locale.TRADITIONAL_CHINESE)).isEqualTo("漢") 27 | assertThat("漢字".initialLetter(Locale.JAPANESE)).isEqualTo("漢") 28 | assertThat( 29 | "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわゐゑをん" 30 | .codePoints() 31 | .toList() 32 | .joinToString("") { UCharacter.toString(it).initialLetter(Locale.ROOT) } 33 | ) 34 | .isEqualTo("あああああかかかかかさささささたたたたたなななななはははははまままままやややらららららわわわわん") 35 | assertThat( 36 | "びぴヒビピヒぁぃぅぇぉゔ".codePoints().toList().joinToString("") { 37 | UCharacter.toString(it).initialLetter(Locale.ROOT) 38 | } 39 | ) 40 | .isEqualTo("ははははははああああああ") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/test/java/org/sunsetware/phocid/LyricsTest.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid 2 | 3 | import kotlin.time.Duration 4 | import kotlin.time.Duration.Companion.milliseconds 5 | import kotlin.time.Duration.Companion.minutes 6 | import kotlin.time.Duration.Companion.seconds 7 | import org.assertj.core.api.Assertions.* 8 | import org.junit.Test 9 | import org.sunsetware.phocid.data.Lyrics 10 | import org.sunsetware.phocid.data.parseLrc 11 | 12 | class LyricsTest { 13 | @Test 14 | fun testParseLrc() { 15 | assertThat( 16 | parseLrc( 17 | listOf( 18 | "invalid", 19 | "[in:va:lid]", 20 | "[0:1,2]invalid", 21 | "", 22 | " ", 23 | "[00:11.22]line1", 24 | " [11:22.333] line2 ", 25 | "[00:00.00][22:33.444]line3", 26 | "[33:44.555] ", 27 | ) 28 | .joinToString("\r\n") 29 | ) 30 | .lines 31 | ) 32 | .containsExactly( 33 | timestamp(0, 0, 0) to "line3", 34 | timestamp(0, 11, 220) to "line1", 35 | timestamp(11, 22, 333) to "line2", 36 | timestamp(22, 33, 444) to "line3", 37 | timestamp(33, 44, 555) to "", 38 | ) 39 | } 40 | 41 | @Test 42 | fun testGetLineIndex() { 43 | val lyrics = 44 | Lyrics( 45 | listOf( 46 | timestamp(0, 0, 1) to "line0", 47 | timestamp(1, 0, 1) to "line1", 48 | timestamp(2, 0, 1) to "line2", 49 | timestamp(2, 0, 1) to "line3", 50 | timestamp(4, 0, 1) to "line4", 51 | ) 52 | ) 53 | assertThat(lyrics.getLineIndex(timestamp(0, 0, 0))).isEqualTo(null) 54 | assertThat(lyrics.getLineIndex(timestamp(0, 0, 1))).isEqualTo(0) 55 | assertThat(lyrics.getLineIndex(timestamp(0, 1, 1))).isEqualTo(0) 56 | assertThat(lyrics.getLineIndex(timestamp(1, 1, 1))).isEqualTo(1) 57 | assertThat(lyrics.getLineIndex(timestamp(2, 0, 1))).isEqualTo(2) 58 | assertThat(lyrics.getLineIndex(timestamp(2, 1, 1))).isEqualTo(2) 59 | assertThat(lyrics.getLineIndex(timestamp(4, 0, 1))).isEqualTo(4) 60 | assertThat(lyrics.getLineIndex(timestamp(5, 0, 1))).isEqualTo(4) 61 | } 62 | 63 | fun timestamp(minutes: Int, seconds: Int, milliseconds: Int): Duration { 64 | return minutes.minutes + seconds.seconds + milliseconds.milliseconds 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/test/java/org/sunsetware/phocid/SearchableTest.kt: -------------------------------------------------------------------------------- 1 | package org.sunsetware.phocid 2 | 3 | import com.ibm.icu.text.Collator 4 | import com.ibm.icu.text.RuleBasedCollator 5 | import java.util.Locale 6 | import org.assertj.core.api.Assertions.* 7 | import org.junit.Test 8 | import org.sunsetware.phocid.data.Searchable 9 | import org.sunsetware.phocid.data.search 10 | 11 | class SearchableTest { 12 | 13 | private val collator = Collator.getInstance(Locale.ROOT) as RuleBasedCollator 14 | 15 | data class TestSearchable(override val searchableStrings: List) : Searchable 16 | 17 | @Test 18 | fun search_EmptyQuery() { 19 | val targets = listOf(TestSearchable(listOf("a", "b")), TestSearchable(listOf("c", "d"))) 20 | val result = targets.search("", collator) 21 | assertThat(result).isEqualTo(targets) 22 | } 23 | 24 | @Test 25 | fun search_NoMatch() { 26 | val targets = listOf(TestSearchable(listOf("a", "b")), TestSearchable(listOf("c", "d"))) 27 | val result = targets.search("e", collator) 28 | assertThat(result).isEmpty() 29 | } 30 | 31 | @Test 32 | fun search_OneMatch() { 33 | val targets = listOf(TestSearchable(listOf("a", "b")), TestSearchable(listOf("c", "d"))) 34 | val result = targets.search("a", collator) 35 | assertThat(result).isEqualTo(listOf(targets[0])) 36 | } 37 | 38 | @Test 39 | fun search_TargetsWithEmptyString() { 40 | val targets = listOf(TestSearchable(listOf("", "a")), TestSearchable(listOf("", ""))) 41 | val result = targets.search("a", collator) 42 | assertThat(result).isEqualTo(listOf(targets[0])) 43 | } 44 | 45 | @Test 46 | fun search_NoTargets() { 47 | val targets = emptyList() 48 | val result = targets.search("a", collator) 49 | assertThat(result).isEmpty() 50 | } 51 | 52 | @Test 53 | fun searchWithSelector_EmptyQuery() { 54 | val targets = 55 | listOf("1" to TestSearchable(listOf("a", "b")), "2" to TestSearchable(listOf("c", "d"))) 56 | val result = targets.search("", collator) { it.second } 57 | assertThat(result).isEqualTo(targets) 58 | } 59 | 60 | @Test 61 | fun searchWithSelector_NoMatch() { 62 | val targets = 63 | listOf("1" to TestSearchable(listOf("a", "b")), "2" to TestSearchable(listOf("c", "d"))) 64 | val result = targets.search("e", collator) { it.second } 65 | assertThat(result).isEmpty() 66 | } 67 | 68 | @Test 69 | fun searchWithSelector_OneMatch() { 70 | val targets = 71 | listOf("1" to TestSearchable(listOf("a", "b")), "2" to TestSearchable(listOf("c", "d"))) 72 | val result = targets.search("a", collator) { it.second } 73 | assertThat(result).isEqualTo(listOf(targets[0])) 74 | } 75 | 76 | @Test 77 | fun searchWithSelector_TargetsWithEmptyString() { 78 | val targets = 79 | listOf("1" to TestSearchable(listOf("", "a")), "2" to TestSearchable(listOf("", ""))) 80 | val result = targets.search("a", collator) { it.second } 81 | assertThat(result).isEqualTo(listOf(targets[0])) 82 | } 83 | 84 | @Test 85 | fun searchWithSelector_NoTargets() { 86 | val targets = emptyList>() 87 | val result = targets.search("a", collator) { it.second } 88 | assertThat(result).isEmpty() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | plugins { 3 | alias(libs.plugins.androidApplication) apply false 4 | alias(libs.plugins.jetbrainsKotlinAndroid) apply false 5 | alias(libs.plugins.compose.compiler) apply false 6 | } 7 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. For more details, visit 12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | accompanistPermissions = "0.34.0" 3 | activityKtx = "1.10.1" 4 | agp = "8.10.1" 5 | assertjCore = "3.27.2" 6 | commonsIo = "2.19.0" 7 | coreSplashscreenVersion = "1.0.1" 8 | glanceAppwidget = "1.1.1" 9 | icu4j = "76.1" 10 | junit = "4.13.2" 11 | kotlin = "2.0.21" 12 | coreKtx = "1.16.0" 13 | junitVersion = "1.2.1" 14 | espressoCore = "3.6.1" 15 | kotlinxSerializationCbor = "1.8.1" 16 | kotlinxSerializationJson = "1.8.1" 17 | lifecycleRuntimeKtx = "2.9.0" 18 | activityCompose = "1.10.1" 19 | composeBom = "2025.05.01" 20 | lifecycleViewmodelCompose = "2.9.0" 21 | materialIconsExtended = "1.7.8" 22 | media3Exoplayer = "1.7.1" 23 | media3Common = "1.7.1" 24 | media3Session = "1.7.1" 25 | paletteKtx = "1.0.0" 26 | reorderable = "2.4.2" 27 | robolectric = "4.14.1" 28 | jaudiotagger = "3.0.1" 29 | 30 | [libraries] 31 | accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } 32 | androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" } 33 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 34 | androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glanceAppwidget" } 35 | androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glanceAppwidget" } 36 | androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelCompose" } 37 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } 38 | androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3Common" } 39 | androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } 40 | androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } 41 | assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertjCore" } 42 | commons-io = { module = "commons-io:commons-io", version.ref = "commonsIo" } 43 | core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreenVersion" } 44 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 45 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } 46 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } 47 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } 48 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 49 | androidx-ui = { group = "androidx.compose.ui", name = "ui" } 50 | androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 51 | androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 52 | androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 53 | androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 54 | androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 55 | androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 56 | icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } 57 | jaudiotagger = { group = "net.jthink", name = "jaudiotagger", version.ref = "jaudiotagger" } 58 | junit = { module = "junit:junit", version.ref = "junit" } 59 | kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerializationCbor" } 60 | androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsExtended" } 61 | androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3Session" } 62 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } 63 | reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } 64 | robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } 65 | 66 | [plugins] 67 | androidApplication = { id = "com.android.application", version.ref = "agp" } 68 | jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 69 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 70 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 5 | networkTimeout=10000 6 | validateDistributionUrl=true 7 | zipStoreBase=GRADLE_USER_HOME 8 | zipStorePath=wrapper/dists 9 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /metadata/en-US/changelogs/20241121.txt: -------------------------------------------------------------------------------- 1 | Initial release -------------------------------------------------------------------------------- /metadata/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | This project aims to be a replacement with personal tweaks for the now-defunct Phonograph music player, but has no connections with it. 2 | 3 | Features 4 | 5 |
    6 |
  • Familiar user experience
  • 7 |
  • Better metadata support; Namely, multiple artists.
  • 8 |
  • Better sorting when your music library differs from your system language
  • 9 |
  • Regex-based blocklist
  • 10 |
  • Playback speed and pitch control
  • 11 |
  • More lyrics styles
  • 12 |
  • Optional less-vibrant colors
  • 13 |
  • Brand new design based on Material Design 3
  • 14 |
15 | -------------------------------------------------------------------------------- /metadata/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/metadata/en-US/images/icon.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/00-screenshot-home-tracks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/metadata/en-US/images/phoneScreenshots/00-screenshot-home-tracks.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/01-screenshot-home-albums.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/metadata/en-US/images/phoneScreenshots/01-screenshot-home-albums.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/02-screenshot-home-folders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/metadata/en-US/images/phoneScreenshots/02-screenshot-home-folders.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/03-screenshot-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/metadata/en-US/images/phoneScreenshots/03-screenshot-search.png -------------------------------------------------------------------------------- /metadata/en-US/images/phoneScreenshots/04-screenshot-player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TJYSunset/Phocid/29f47aba8a1559d05debaf237f04fe596a6e1ae6/metadata/en-US/images/phoneScreenshots/04-screenshot-player.png -------------------------------------------------------------------------------- /metadata/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | A modern offline music player with familiar designs -------------------------------------------------------------------------------- /metadata/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Phocid -------------------------------------------------------------------------------- /metadata/zh-CN/changelogs/20241121.txt: -------------------------------------------------------------------------------- 1 | 首个版本 -------------------------------------------------------------------------------- /metadata/zh-CN/full_description.txt: -------------------------------------------------------------------------------- 1 | 本项目旨在为现已停止开发的Phonograph播放器提供一个替代品。本项目与Phonograph没有任何联系。 2 | 3 | 功能 4 | 5 |
    6 |
  • 熟悉的用户体验
  • 7 |
  • 支持多个艺术家标签
  • 8 |
  • 支持按其他语言规则进行排序
  • 9 |
  • 基于正则表达式的高级黑名单
  • 10 |
  • 变速与变调
  • 11 |
  • 双行歌词
  • 12 |
  • 可选低饱和度颜色
  • 13 |
  • 全新Material Design 3设计
  • 14 |
15 | -------------------------------------------------------------------------------- /metadata/zh-CN/short_description.txt: -------------------------------------------------------------------------------- 1 | 本地音乐播放器 -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google { 4 | content { 5 | includeGroupByRegex("com\\.android.*") 6 | includeGroupByRegex("com\\.google.*") 7 | includeGroupByRegex("androidx.*") 8 | } 9 | } 10 | mavenCentral() 11 | gradlePluginPortal() 12 | } 13 | } 14 | 15 | dependencyResolutionManagement { 16 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 17 | repositories { 18 | google() 19 | mavenCentral() 20 | } 21 | } 22 | 23 | rootProject.name = "Phocid" 24 | 25 | include(":app") 26 | 27 | includeBuild("deps/OpusMetadataIo") 28 | -------------------------------------------------------------------------------- /stability_config.conf: -------------------------------------------------------------------------------- 1 | java.util.UUID 2 | kotlin.collections.* 3 | --------------------------------------------------------------------------------