From a4563a414085c5e99bf96b61d049155eb579e431 Mon Sep 17 00:00:00 2001 From: Him188 Date: Mon, 16 Dec 2024 12:54:40 +0000 Subject: [PATCH] Initial commit --- .editorconfig | 1022 +++++++++++++++++ .github/workflows/build.yml | 215 ++++ .github/workflows/src.main.kts | 871 ++++++++++++++ .gitignore | 31 + .idea/copyright/openani.xml | 7 + .idea/copyright/profiles_settings.xml | 7 + README.md | 14 +- build.gradle.kts | 64 ++ buildSrc/build.gradle.kts | 50 + buildSrc/settings.gradle.kts | 9 + buildSrc/src/main/kotlin/Versions.kt | 19 + buildSrc/src/main/kotlin/android.kt | 33 + .../main/kotlin/ani-lib-targets.gradle.kts | 6 + buildSrc/src/main/kotlin/build.kt | 290 +++++ buildSrc/src/main/kotlin/compose.kt | 4 + .../kotlin/flatten-source-sets.gradle.kts | 78 ++ .../main/kotlin/mpp-lib-targets.gradle.kts | 186 +++ buildSrc/src/main/kotlin/mpp.kt | 3 + buildSrc/src/main/kotlin/optIns.kt | 79 ++ buildSrc/src/main/kotlin/os.kt | 42 + buildSrc/src/main/kotlin/properties.kt | 48 + buildSrc/src/main/kotlin/sourceSets.kt | 58 + buildSrc/src/main/kotlin/utils.kt | 56 + gradle.properties | 30 + gradle/libs.versions.toml | 149 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes gradle/wrapper/gradle-wrapper.properties | 13 + gradlew | 234 ++++ gradlew.bat | 89 ++ mediamp-api/build.gradle.kts | 28 + .../kotlin/HttpStreamingVideoSource.kt | 54 + .../commonMain/kotlin/data/FileVideoSource.kt | 73 ++ .../src/commonMain/kotlin/data/VideoData.kt | 94 ++ .../commonMain/kotlin/data/VideoProperties.kt | 27 + .../src/commonMain/kotlin/data/VideoSource.kt | 42 + .../kotlin/data/VideoSourceOpenException.kt | 38 + mediamp-api/src/commonMain/kotlin/package.kt | 2 + mediamp-core/build.gradle.kts | 54 + .../androidMain/kotlin/PlayerState.android.kt | 432 +++++++ .../kotlin/media/VideoDataDataSource.kt | 119 ++ .../src/androidMain/kotlin/package.kt | 2 + .../kotlin/ui/VideoGestureHost.android.kt | 96 ++ .../kotlin/ui/VideoPlayer.android.kt | 70 ++ .../kotlin/ui/gesture/GestureLock.android.kt | 17 + mediamp-core/src/commonMain/kotlin/package.kt | 10 + .../kotlin/ui/VideoControllerState.kt | 197 ++++ .../kotlin/ui/VideoLoadingIndicator.kt | 37 + .../src/commonMain/kotlin/ui/VideoPlayer.kt | 18 + .../src/commonMain/kotlin/ui/VideoScaffold.kt | 323 ++++++ .../kotlin/ui/guesture/FastSkipState.kt | 158 +++ .../kotlin/ui/guesture/GestureLock.kt | 164 +++ .../kotlin/ui/guesture/KeyboardSeek.kt | 55 + .../ui/guesture/PlayerFloatingButtonBox.kt | 28 + .../kotlin/ui/guesture/ScreenshotButton.kt | 30 + .../kotlin/ui/guesture/SteppedDraggable.kt | 182 +++ .../kotlin/ui/guesture/SwipeSeekerState.kt | 157 +++ .../kotlin/ui/guesture/SwipeVolumeControl.kt | 129 +++ .../kotlin/ui/guesture/VideoGestureHost.kt | 829 +++++++++++++ .../kotlin/ui/progress/AudioPresentation.kt | 16 + .../kotlin/ui/progress/AudioSwitcher.kt | 110 ++ .../ui/progress/MediaProgressIndicatorText.kt | 116 ++ .../kotlin/ui/progress/MediaProgressSlider.kt | 557 +++++++++ .../kotlin/ui/progress/PlayerControllerBar.kt | 690 +++++++++++ .../kotlin/ui/progress/SubtitleLanguage.kt | 16 + .../kotlin/ui/progress/SubtitleSwitcher.kt | 110 ++ .../kotlin/ui/progress/VerticalSlider.kt | 75 ++ .../ui/state/MediaCacheProgressState.kt | 88 ++ .../commonMain/kotlin/ui/state/PlayerState.kt | 570 +++++++++ .../commonMain/kotlin/ui/state/TrackGroup.kt | 67 ++ .../commonMain/kotlin/ui/top/Indicators.kt | 2 + .../commonMain/kotlin/ui/top/PlayerTopBar.kt | 83 ++ mediamp-core/src/commonTest/kotlin/package.kt | 1 + .../kotlin/ui/VideoControllerStateTest.kt | 103 ++ .../kotlin/io/SeekableInputCallbackMedia.kt | 57 + .../src/desktopMain/kotlin/package.kt | 2 + .../kotlin/ui/ComposeMediaPlayerComponent.kt | 365 ++++++ .../kotlin/ui/SkiaBitmapVideoSurface.kt | 110 ++ .../desktopMain/kotlin/ui/SkiaVideoSurface.kt | 146 +++ .../kotlin/ui/VideoPlayer.desktop.kt | 649 +++++++++++ .../src/desktopTest/kotlin/package.kt | 1 + ...ch___swipeToSeek_shows_detached_slider.png | Bin 0 -> 10999 bytes mediamp-core/src/iosMain/kotlin/package.kt | 2 + .../src/iosMain/kotlin/ui/VideoPlayer.ios.kt | 19 + mediamp-core/torrent-source/build.gradle.kts | 27 + .../src/androidDebug/kotlin/package.kt | 2 + .../src/commonMain/kotlin/package.kt | 2 + .../kotlin/torrent/TorrentVideoData.kt | 52 + proguard-rules.pro | 81 ++ settings.gradle.kts | 28 + 89 files changed, 11318 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/build.yml create mode 100755 .github/workflows/src.main.kts create mode 100644 .gitignore create mode 100644 .idea/copyright/openani.xml create mode 100644 .idea/copyright/profiles_settings.xml create mode 100644 build.gradle.kts create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/settings.gradle.kts create mode 100644 buildSrc/src/main/kotlin/Versions.kt create mode 100644 buildSrc/src/main/kotlin/android.kt create mode 100644 buildSrc/src/main/kotlin/ani-lib-targets.gradle.kts create mode 100644 buildSrc/src/main/kotlin/build.kt create mode 100644 buildSrc/src/main/kotlin/compose.kt create mode 100644 buildSrc/src/main/kotlin/flatten-source-sets.gradle.kts create mode 100644 buildSrc/src/main/kotlin/mpp-lib-targets.gradle.kts create mode 100644 buildSrc/src/main/kotlin/mpp.kt create mode 100644 buildSrc/src/main/kotlin/optIns.kt create mode 100644 buildSrc/src/main/kotlin/os.kt create mode 100644 buildSrc/src/main/kotlin/properties.kt create mode 100644 buildSrc/src/main/kotlin/sourceSets.kt create mode 100644 buildSrc/src/main/kotlin/utils.kt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 mediamp-api/build.gradle.kts create mode 100644 mediamp-api/src/commonMain/kotlin/HttpStreamingVideoSource.kt create mode 100644 mediamp-api/src/commonMain/kotlin/data/FileVideoSource.kt create mode 100644 mediamp-api/src/commonMain/kotlin/data/VideoData.kt create mode 100644 mediamp-api/src/commonMain/kotlin/data/VideoProperties.kt create mode 100644 mediamp-api/src/commonMain/kotlin/data/VideoSource.kt create mode 100644 mediamp-api/src/commonMain/kotlin/data/VideoSourceOpenException.kt create mode 100644 mediamp-api/src/commonMain/kotlin/package.kt create mode 100644 mediamp-core/build.gradle.kts create mode 100644 mediamp-core/src/androidMain/kotlin/PlayerState.android.kt create mode 100644 mediamp-core/src/androidMain/kotlin/media/VideoDataDataSource.kt create mode 100644 mediamp-core/src/androidMain/kotlin/package.kt create mode 100644 mediamp-core/src/androidMain/kotlin/ui/VideoGestureHost.android.kt create mode 100644 mediamp-core/src/androidMain/kotlin/ui/VideoPlayer.android.kt create mode 100644 mediamp-core/src/androidMain/kotlin/ui/gesture/GestureLock.android.kt create mode 100644 mediamp-core/src/commonMain/kotlin/package.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/VideoControllerState.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/VideoLoadingIndicator.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/VideoPlayer.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/VideoScaffold.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/FastSkipState.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/GestureLock.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/KeyboardSeek.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/PlayerFloatingButtonBox.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/ScreenshotButton.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/SteppedDraggable.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeSeekerState.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeVolumeControl.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/guesture/VideoGestureHost.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/AudioPresentation.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/AudioSwitcher.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressIndicatorText.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressSlider.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/PlayerControllerBar.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleLanguage.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleSwitcher.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/progress/VerticalSlider.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/state/MediaCacheProgressState.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/state/PlayerState.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/state/TrackGroup.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/top/Indicators.kt create mode 100644 mediamp-core/src/commonMain/kotlin/ui/top/PlayerTopBar.kt create mode 100644 mediamp-core/src/commonTest/kotlin/package.kt create mode 100644 mediamp-core/src/commonTest/kotlin/ui/VideoControllerStateTest.kt create mode 100644 mediamp-core/src/desktopMain/kotlin/io/SeekableInputCallbackMedia.kt create mode 100644 mediamp-core/src/desktopMain/kotlin/package.kt create mode 100644 mediamp-core/src/desktopMain/kotlin/ui/ComposeMediaPlayerComponent.kt create mode 100644 mediamp-core/src/desktopMain/kotlin/ui/SkiaBitmapVideoSurface.kt create mode 100644 mediamp-core/src/desktopMain/kotlin/ui/SkiaVideoSurface.kt create mode 100644 mediamp-core/src/desktopMain/kotlin/ui/VideoPlayer.desktop.kt create mode 100644 mediamp-core/src/desktopTest/kotlin/package.kt create mode 100644 mediamp-core/src/desktopTest/resources/screenshots/EpisodeVideoControllerTest.touch___swipeToSeek_shows_detached_slider.png create mode 100644 mediamp-core/src/iosMain/kotlin/package.kt create mode 100644 mediamp-core/src/iosMain/kotlin/ui/VideoPlayer.ios.kt create mode 100644 mediamp-core/torrent-source/build.gradle.kts create mode 100644 mediamp-core/torrent-source/src/androidDebug/kotlin/package.kt create mode 100644 mediamp-core/torrent-source/src/commonMain/kotlin/package.kt create mode 100644 mediamp-core/torrent-source/src/commonMain/kotlin/torrent/TorrentVideoData.kt create mode 100644 proguard-rules.pro create mode 100644 settings.gradle.kts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1930b41 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,1022 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = tab +insert_final_newline = false +max_line_length = 100 +tab_width = 4 +trim_trailing_whitespace = false +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = true +ij_smart_tabs = false +ij_visual_guides = +ij_wrap_on_typing = false + +[*.java] +indent_style = space +ij_java_align_consecutive_assignments = false +ij_java_align_consecutive_variable_declarations = false +ij_java_align_group_field_declarations = false +ij_java_align_multiline_annotation_parameters = false +ij_java_align_multiline_array_initializer_expression = false +ij_java_align_multiline_assignment = false +ij_java_align_multiline_binary_operation = false +ij_java_align_multiline_chained_methods = false +ij_java_align_multiline_deconstruction_list_components = true +ij_java_align_multiline_extends_list = false +ij_java_align_multiline_for = true +ij_java_align_multiline_method_parentheses = false +ij_java_align_multiline_parameters = true +ij_java_align_multiline_parameters_in_calls = false +ij_java_align_multiline_parenthesized_expression = false +ij_java_align_multiline_records = true +ij_java_align_multiline_resources = true +ij_java_align_multiline_ternary_operation = false +ij_java_align_multiline_text_blocks = false +ij_java_align_multiline_throws_list = false +ij_java_align_subsequent_simple_methods = false +ij_java_align_throws_keyword = false +ij_java_align_types_in_multi_catch = true +ij_java_annotation_parameter_wrap = off +ij_java_array_initializer_new_line_after_left_brace = false +ij_java_array_initializer_right_brace_on_new_line = false +ij_java_array_initializer_wrap = off +ij_java_assert_statement_colon_on_next_line = false +ij_java_assert_statement_wrap = off +ij_java_assignment_wrap = off +ij_java_binary_operation_sign_on_next_line = false +ij_java_binary_operation_wrap = off +ij_java_blank_lines_after_anonymous_class_header = 0 +ij_java_blank_lines_after_class_header = 0 +ij_java_blank_lines_after_imports = 1 +ij_java_blank_lines_after_package = 1 +ij_java_blank_lines_around_class = 1 +ij_java_blank_lines_around_field = 0 +ij_java_blank_lines_around_field_in_interface = 0 +ij_java_blank_lines_around_initializer = 1 +ij_java_blank_lines_around_method = 1 +ij_java_blank_lines_around_method_in_interface = 1 +ij_java_blank_lines_before_class_end = 0 +ij_java_blank_lines_before_imports = 1 +ij_java_blank_lines_before_method_body = 0 +ij_java_blank_lines_before_package = 0 +ij_java_block_brace_style = end_of_line +ij_java_block_comment_add_space = false +ij_java_block_comment_at_first_column = true +ij_java_builder_methods = +ij_java_call_parameters_new_line_after_left_paren = false +ij_java_call_parameters_right_paren_on_new_line = false +ij_java_call_parameters_wrap = off +ij_java_case_statement_on_separate_line = true +ij_java_catch_on_new_line = false +ij_java_class_annotation_wrap = split_into_lines +ij_java_class_brace_style = end_of_line +ij_java_class_count_to_use_import_on_demand = 99 +ij_java_class_names_in_javadoc = 1 +ij_java_deconstruction_list_wrap = normal +ij_java_do_not_indent_top_level_class_members = false +ij_java_do_not_wrap_after_single_annotation = false +ij_java_do_not_wrap_after_single_annotation_in_parameter = false +ij_java_do_while_brace_force = never +ij_java_doc_add_blank_line_after_description = true +ij_java_doc_add_blank_line_after_param_comments = false +ij_java_doc_add_blank_line_after_return = false +ij_java_doc_add_p_tag_on_empty_lines = true +ij_java_doc_align_exception_comments = true +ij_java_doc_align_param_comments = true +ij_java_doc_do_not_wrap_if_one_line = false +ij_java_doc_enable_formatting = true +ij_java_doc_enable_leading_asterisks = true +ij_java_doc_indent_on_continuation = false +ij_java_doc_keep_empty_lines = true +ij_java_doc_keep_empty_parameter_tag = true +ij_java_doc_keep_empty_return_tag = true +ij_java_doc_keep_empty_throws_tag = true +ij_java_doc_keep_invalid_tags = true +ij_java_doc_param_description_on_new_line = false +ij_java_doc_preserve_line_breaks = false +ij_java_doc_use_throws_not_exception_tag = true +ij_java_else_on_new_line = false +ij_java_enum_constants_wrap = off +ij_java_extends_keyword_wrap = off +ij_java_extends_list_wrap = off +ij_java_field_annotation_wrap = split_into_lines +ij_java_field_name_prefix = +ij_java_field_name_suffix = +ij_java_finally_on_new_line = false +ij_java_for_brace_force = never +ij_java_for_statement_new_line_after_left_paren = false +ij_java_for_statement_right_paren_on_new_line = false +ij_java_for_statement_wrap = off +ij_java_generate_final_locals = false +ij_java_generate_final_parameters = false +ij_java_if_brace_force = never +ij_java_imports_layout = $android.**, $androidx.**, $com.**, $junit.**, $net.**, $org.**, $java.**, $javax.**, $*, |, android.**, |, androidx.**, |, com.**, |, junit.**, |, net.**, |, org.**, |, java.**, |, javax.**, |, *, | +ij_java_indent_case_from_switch = true +ij_java_insert_inner_class_imports = false +ij_java_insert_override_annotation = true +ij_java_keep_blank_lines_before_right_brace = 2 +ij_java_keep_blank_lines_between_package_declaration_and_header = 2 +ij_java_keep_blank_lines_in_code = 2 +ij_java_keep_blank_lines_in_declarations = 2 +ij_java_keep_builder_methods_indents = false +ij_java_keep_control_statement_in_one_line = true +ij_java_keep_first_column_comment = true +ij_java_keep_indents_on_empty_lines = false +ij_java_keep_line_breaks = true +ij_java_keep_multiple_expressions_in_one_line = false +ij_java_keep_simple_blocks_in_one_line = false +ij_java_keep_simple_classes_in_one_line = false +ij_java_keep_simple_lambdas_in_one_line = false +ij_java_keep_simple_methods_in_one_line = false +ij_java_label_indent_absolute = false +ij_java_label_indent_size = 0 +ij_java_lambda_brace_style = end_of_line +ij_java_layout_static_imports_separately = true +ij_java_line_comment_add_space = false +ij_java_line_comment_add_space_on_reformat = false +ij_java_line_comment_at_first_column = true +ij_java_local_variable_name_prefix = +ij_java_local_variable_name_suffix = +ij_java_method_annotation_wrap = split_into_lines +ij_java_method_brace_style = end_of_line +ij_java_method_call_chain_wrap = off +ij_java_method_parameters_new_line_after_left_paren = false +ij_java_method_parameters_right_paren_on_new_line = false +ij_java_method_parameters_wrap = off +ij_java_modifier_list_wrap = false +ij_java_multi_catch_types_wrap = normal +ij_java_names_count_to_use_import_on_demand = 99 +ij_java_new_line_after_lparen_in_annotation = false +ij_java_new_line_after_lparen_in_deconstruction_pattern = true +ij_java_new_line_after_lparen_in_record_header = false +ij_java_packages_to_use_import_on_demand = +ij_java_parameter_annotation_wrap = off +ij_java_parameter_name_prefix = +ij_java_parameter_name_suffix = +ij_java_parentheses_expression_new_line_after_left_paren = false +ij_java_parentheses_expression_right_paren_on_new_line = false +ij_java_place_assignment_sign_on_next_line = false +ij_java_prefer_longer_names = true +ij_java_prefer_parameters_wrap = false +ij_java_record_components_wrap = normal +ij_java_repeat_annotations = +ij_java_repeat_synchronized = true +ij_java_replace_instanceof_and_cast = false +ij_java_replace_null_check = true +ij_java_replace_sum_lambda_with_method_ref = true +ij_java_resource_list_new_line_after_left_paren = false +ij_java_resource_list_right_paren_on_new_line = false +ij_java_resource_list_wrap = off +ij_java_rparen_on_new_line_in_annotation = false +ij_java_rparen_on_new_line_in_deconstruction_pattern = true +ij_java_rparen_on_new_line_in_record_header = false +ij_java_space_after_closing_angle_bracket_in_type_argument = false +ij_java_space_after_colon = true +ij_java_space_after_comma = true +ij_java_space_after_comma_in_type_arguments = true +ij_java_space_after_for_semicolon = true +ij_java_space_after_quest = true +ij_java_space_after_type_cast = true +ij_java_space_before_annotation_array_initializer_left_brace = false +ij_java_space_before_annotation_parameter_list = false +ij_java_space_before_array_initializer_left_brace = false +ij_java_space_before_catch_keyword = true +ij_java_space_before_catch_left_brace = true +ij_java_space_before_catch_parentheses = true +ij_java_space_before_class_left_brace = true +ij_java_space_before_colon = true +ij_java_space_before_colon_in_foreach = true +ij_java_space_before_comma = false +ij_java_space_before_deconstruction_list = false +ij_java_space_before_do_left_brace = true +ij_java_space_before_else_keyword = true +ij_java_space_before_else_left_brace = true +ij_java_space_before_finally_keyword = true +ij_java_space_before_finally_left_brace = true +ij_java_space_before_for_left_brace = true +ij_java_space_before_for_parentheses = true +ij_java_space_before_for_semicolon = false +ij_java_space_before_if_left_brace = true +ij_java_space_before_if_parentheses = true +ij_java_space_before_method_call_parentheses = false +ij_java_space_before_method_left_brace = true +ij_java_space_before_method_parentheses = false +ij_java_space_before_opening_angle_bracket_in_type_parameter = false +ij_java_space_before_quest = true +ij_java_space_before_switch_left_brace = true +ij_java_space_before_switch_parentheses = true +ij_java_space_before_synchronized_left_brace = true +ij_java_space_before_synchronized_parentheses = true +ij_java_space_before_try_left_brace = true +ij_java_space_before_try_parentheses = true +ij_java_space_before_type_parameter_list = false +ij_java_space_before_while_keyword = true +ij_java_space_before_while_left_brace = true +ij_java_space_before_while_parentheses = true +ij_java_space_inside_one_line_enum_braces = false +ij_java_space_within_empty_array_initializer_braces = false +ij_java_space_within_empty_method_call_parentheses = false +ij_java_space_within_empty_method_parentheses = false +ij_java_spaces_around_additive_operators = true +ij_java_spaces_around_annotation_eq = true +ij_java_spaces_around_assignment_operators = true +ij_java_spaces_around_bitwise_operators = true +ij_java_spaces_around_equality_operators = true +ij_java_spaces_around_lambda_arrow = true +ij_java_spaces_around_logical_operators = true +ij_java_spaces_around_method_ref_dbl_colon = false +ij_java_spaces_around_multiplicative_operators = true +ij_java_spaces_around_relational_operators = true +ij_java_spaces_around_shift_operators = true +ij_java_spaces_around_type_bounds_in_type_parameters = true +ij_java_spaces_around_unary_operator = false +ij_java_spaces_within_angle_brackets = false +ij_java_spaces_within_annotation_parentheses = false +ij_java_spaces_within_array_initializer_braces = false +ij_java_spaces_within_braces = false +ij_java_spaces_within_brackets = false +ij_java_spaces_within_cast_parentheses = false +ij_java_spaces_within_catch_parentheses = false +ij_java_spaces_within_deconstruction_list = false +ij_java_spaces_within_for_parentheses = false +ij_java_spaces_within_if_parentheses = false +ij_java_spaces_within_method_call_parentheses = false +ij_java_spaces_within_method_parentheses = false +ij_java_spaces_within_parentheses = false +ij_java_spaces_within_record_header = false +ij_java_spaces_within_switch_parentheses = false +ij_java_spaces_within_synchronized_parentheses = false +ij_java_spaces_within_try_parentheses = false +ij_java_spaces_within_while_parentheses = false +ij_java_special_else_if_treatment = true +ij_java_static_field_name_prefix = +ij_java_static_field_name_suffix = +ij_java_subclass_name_prefix = +ij_java_subclass_name_suffix = Impl +ij_java_ternary_operation_signs_on_next_line = false +ij_java_ternary_operation_wrap = off +ij_java_test_name_prefix = +ij_java_test_name_suffix = Test +ij_java_throws_keyword_wrap = off +ij_java_throws_list_wrap = off +ij_java_use_external_annotations = false +ij_java_use_fq_class_names = false +ij_java_use_relative_indents = false +ij_java_use_single_class_imports = true +ij_java_variable_annotation_wrap = off +ij_java_visibility = public +ij_java_while_brace_force = never +ij_java_while_on_new_line = false +ij_java_wrap_comments = false +ij_java_wrap_first_method_in_call_chain = false +ij_java_wrap_long_lines = false + +[*.properties] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = false +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false + +[*.proto] +indent_size = 2 +indent_style = space +tab_width = 2 +ij_continuation_indent_size = 4 +ij_protobuf_keep_blank_lines_in_code = 2 +ij_protobuf_keep_indents_on_empty_lines = false +ij_protobuf_keep_line_breaks = true +ij_protobuf_space_after_comma = true +ij_protobuf_space_before_comma = false +ij_protobuf_spaces_around_assignment_operators = true +ij_protobuf_spaces_within_braces = false +ij_protobuf_spaces_within_brackets = false + +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true + +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] +indent_style = space +ij_xml_align_attributes = false +ij_xml_align_text = false +ij_xml_attribute_wrap = normal +ij_xml_block_comment_add_space = false +ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +ij_xml_keep_indents_on_empty_lines = false +ij_xml_keep_line_breaks = false +ij_xml_keep_line_breaks_in_text = true +ij_xml_keep_whitespaces = false +ij_xml_keep_whitespaces_around_cdata = preserve +ij_xml_keep_whitespaces_inside_cdata = false +ij_xml_line_comment_at_first_column = true +ij_xml_space_after_tag_name = false +ij_xml_space_around_equals_in_attribute = false +ij_xml_space_inside_empty_tag = true +ij_xml_text_wrap = normal +ij_xml_use_custom_settings = true + +[{*.apinotes,*.yaml,*.yml,.clang-format,.clang-tidy,_clang-format}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +indent_style = space +tab_width = 2 +ij_shell_binary_ops_start_line = false +ij_shell_keep_column_alignment_padding = false +ij_shell_minify_program = false +ij_shell_redirect_followed_by_space = false +ij_shell_switch_cases_indented = false +ij_shell_use_unix_line_separator = true + +[{*.c,*.c++,*.c++m,*.cc,*.ccm,*.cp,*.cpp,*.cppm,*.cu,*.cuh,*.cxx,*.cxxm,*.h,*.h++,*.hh,*.hp,*.hpp,*.hxx,*.i,*.icc,*.ii,*.inl,*.ino,*.ipp,*.ixx,*.m,*.mm,*.mxx,*.pch,*.tcc,*.tpp}] +ij_c_add_brief_tag = false +ij_c_add_getter_prefix = true +ij_c_add_setter_prefix = true +ij_c_align_dictionary_pair_values = false +ij_c_align_group_field_declarations = false +ij_c_align_init_list_in_columns = true +ij_c_align_multiline_array_initializer_expression = true +ij_c_align_multiline_assignment = true +ij_c_align_multiline_binary_operation = true +ij_c_align_multiline_chained_methods = false +ij_c_align_multiline_for = true +ij_c_align_multiline_ternary_operation = true +ij_c_array_initializer_comma_on_next_line = false +ij_c_array_initializer_new_line_after_left_brace = false +ij_c_array_initializer_right_brace_on_new_line = false +ij_c_array_initializer_wrap = normal +ij_c_assignment_wrap = off +ij_c_binary_operation_sign_on_next_line = false +ij_c_binary_operation_wrap = normal +ij_c_blank_lines_after_class_header = 0 +ij_c_blank_lines_after_imports = 1 +ij_c_blank_lines_around_class = 1 +ij_c_blank_lines_around_field = 0 +ij_c_blank_lines_around_field_in_interface = 0 +ij_c_blank_lines_around_method = 1 +ij_c_blank_lines_around_method_in_interface = 1 +ij_c_blank_lines_around_namespace = 0 +ij_c_blank_lines_around_properties_in_declaration = 0 +ij_c_blank_lines_around_properties_in_interface = 0 +ij_c_blank_lines_before_imports = 1 +ij_c_blank_lines_before_method_body = 0 +ij_c_block_brace_placement = end_of_line +ij_c_block_brace_style = end_of_line +ij_c_block_comment_at_first_column = true +ij_c_catch_on_new_line = false +ij_c_class_brace_style = end_of_line +ij_c_class_constructor_init_list_align_multiline = true +ij_c_class_constructor_init_list_comma_on_next_line = false +ij_c_class_constructor_init_list_new_line_after_colon = never +ij_c_class_constructor_init_list_new_line_before_colon = if_long +ij_c_class_constructor_init_list_wrap = normal +ij_c_copy_is_deep = false +ij_c_create_interface_for_categories = true +ij_c_declare_generated_methods = true +ij_c_description_include_member_names = true +ij_c_discharged_short_ternary_operator = false +ij_c_do_not_add_breaks = false +ij_c_do_while_brace_force = never +ij_c_else_on_new_line = false +ij_c_enum_constants_comma_on_next_line = false +ij_c_enum_constants_wrap = on_every_item +ij_c_for_brace_force = never +ij_c_for_statement_new_line_after_left_paren = false +ij_c_for_statement_right_paren_on_new_line = false +ij_c_for_statement_wrap = off +ij_c_function_brace_placement = end_of_line +ij_c_function_call_arguments_align_multiline = true +ij_c_function_call_arguments_align_multiline_pars = false +ij_c_function_call_arguments_comma_on_next_line = false +ij_c_function_call_arguments_new_line_after_lpar = false +ij_c_function_call_arguments_new_line_before_rpar = false +ij_c_function_call_arguments_wrap = normal +ij_c_function_non_top_after_return_type_wrap = normal +ij_c_function_parameters_align_multiline = true +ij_c_function_parameters_align_multiline_pars = false +ij_c_function_parameters_comma_on_next_line = false +ij_c_function_parameters_new_line_after_lpar = false +ij_c_function_parameters_new_line_before_rpar = false +ij_c_function_parameters_wrap = normal +ij_c_function_top_after_return_type_wrap = normal +ij_c_generate_additional_eq_operators = true +ij_c_generate_additional_rel_operators = true +ij_c_generate_class_constructor = true +ij_c_generate_comparison_operators_use_std_tie = false +ij_c_generate_instance_variables_for_properties = ask +ij_c_generate_operators_as_members = true +ij_c_header_guard_style_pattern = ${PROJECT_NAME}_${FILE_NAME}_${EXT} +ij_c_if_brace_force = never +ij_c_in_line_short_ternary_operator = true +ij_c_indent_block_comment = true +ij_c_indent_c_struct_members = 4 +ij_c_indent_case_from_switch = true +ij_c_indent_class_members = 4 +ij_c_indent_directive_as_code = false +ij_c_indent_implementation_members = 0 +ij_c_indent_inside_code_block = 4 +ij_c_indent_interface_members = 0 +ij_c_indent_interface_members_except_ivars_block = false +ij_c_indent_namespace_members = 4 +ij_c_indent_preprocessor_directive = 0 +ij_c_indent_visibility_keywords = 0 +ij_c_insert_override = true +ij_c_insert_virtual_with_override = false +ij_c_introduce_auto_consts = false +ij_c_introduce_auto_vars = false +ij_c_introduce_const_params = false +ij_c_introduce_const_vars = false +ij_c_introduce_constexpr_consts = false +ij_c_introduce_generate_property = false +ij_c_introduce_generate_synthesize = true +ij_c_introduce_globals_to_header = true +ij_c_introduce_prop_to_private_category = false +ij_c_introduce_static_consts = true +ij_c_introduce_use_ns_types = false +ij_c_ivars_prefix = _ +ij_c_ivars_suffix = +ij_c_keep_blank_lines_before_end = 2 +ij_c_keep_blank_lines_before_right_brace = 2 +ij_c_keep_blank_lines_in_code = 2 +ij_c_keep_blank_lines_in_declarations = 2 +ij_c_keep_case_expressions_in_one_line = false +ij_c_keep_control_statement_in_one_line = true +ij_c_keep_directive_at_first_column = true +ij_c_keep_first_column_comment = true +ij_c_keep_line_breaks = true +ij_c_keep_nested_namespaces_in_one_line = false +ij_c_keep_simple_blocks_in_one_line = true +ij_c_keep_simple_methods_in_one_line = true +ij_c_keep_structures_in_one_line = false +ij_c_lambda_capture_list_align_multiline = false +ij_c_lambda_capture_list_align_multiline_bracket = false +ij_c_lambda_capture_list_comma_on_next_line = false +ij_c_lambda_capture_list_new_line_after_lbracket = false +ij_c_lambda_capture_list_new_line_before_rbracket = false +ij_c_lambda_capture_list_wrap = off +ij_c_line_comment_add_space = false +ij_c_line_comment_at_first_column = true +ij_c_method_brace_placement = end_of_line +ij_c_method_call_arguments_align_by_colons = true +ij_c_method_call_arguments_align_multiline = false +ij_c_method_call_arguments_special_dictionary_pairs_treatment = true +ij_c_method_call_arguments_wrap = off +ij_c_method_call_chain_wrap = off +ij_c_method_parameters_align_by_colons = true +ij_c_method_parameters_align_multiline = false +ij_c_method_parameters_wrap = off +ij_c_namespace_brace_placement = end_of_line +ij_c_parentheses_expression_new_line_after_left_paren = false +ij_c_parentheses_expression_right_paren_on_new_line = false +ij_c_place_assignment_sign_on_next_line = false +ij_c_property_nonatomic = true +ij_c_put_ivars_to_implementation = true +ij_c_refactor_compatibility_aliases_and_classes = true +ij_c_refactor_properties_and_ivars = true +ij_c_release_style = ivar +ij_c_retain_object_parameters_in_constructor = true +ij_c_semicolon_after_method_signature = false +ij_c_shift_operation_align_multiline = true +ij_c_shift_operation_wrap = normal +ij_c_show_non_virtual_functions = false +ij_c_space_after_colon = true +ij_c_space_after_colon_in_foreach = true +ij_c_space_after_colon_in_selector = false +ij_c_space_after_comma = true +ij_c_space_after_cup_in_blocks = false +ij_c_space_after_dictionary_literal_colon = true +ij_c_space_after_for_semicolon = true +ij_c_space_after_init_list_colon = true +ij_c_space_after_method_parameter_type_parentheses = false +ij_c_space_after_method_return_type_parentheses = false +ij_c_space_after_pointer_in_declaration = false +ij_c_space_after_quest = true +ij_c_space_after_reference_in_declaration = false +ij_c_space_after_reference_in_rvalue = false +ij_c_space_after_structures_rbrace = true +ij_c_space_after_superclass_colon = true +ij_c_space_after_type_cast = true +ij_c_space_after_visibility_sign_in_method_declaration = true +ij_c_space_before_autorelease_pool_lbrace = true +ij_c_space_before_catch_keyword = true +ij_c_space_before_catch_left_brace = true +ij_c_space_before_catch_parentheses = true +ij_c_space_before_category_parentheses = true +ij_c_space_before_chained_send_message = true +ij_c_space_before_class_left_brace = true +ij_c_space_before_colon = true +ij_c_space_before_colon_in_foreach = false +ij_c_space_before_comma = false +ij_c_space_before_dictionary_literal_colon = false +ij_c_space_before_do_left_brace = true +ij_c_space_before_else_keyword = true +ij_c_space_before_else_left_brace = true +ij_c_space_before_export_lbrace = true +ij_c_space_before_for_left_brace = true +ij_c_space_before_for_parentheses = true +ij_c_space_before_for_semicolon = false +ij_c_space_before_if_left_brace = true +ij_c_space_before_if_parentheses = true +ij_c_space_before_init_list = false +ij_c_space_before_init_list_colon = true +ij_c_space_before_method_call_parentheses = false +ij_c_space_before_method_left_brace = true +ij_c_space_before_method_parentheses = false +ij_c_space_before_namespace_lbrace = true +ij_c_space_before_pointer_in_declaration = true +ij_c_space_before_property_attributes_parentheses = false +ij_c_space_before_protocols_brackets = true +ij_c_space_before_quest = true +ij_c_space_before_reference_in_declaration = true +ij_c_space_before_superclass_colon = true +ij_c_space_before_switch_left_brace = true +ij_c_space_before_switch_parentheses = true +ij_c_space_before_template_call_lt = false +ij_c_space_before_template_declaration_lt = false +ij_c_space_before_try_left_brace = true +ij_c_space_before_while_keyword = true +ij_c_space_before_while_left_brace = true +ij_c_space_before_while_parentheses = true +ij_c_space_between_adjacent_brackets = false +ij_c_space_between_operator_and_punctuator = false +ij_c_space_within_empty_array_initializer_braces = false +ij_c_spaces_around_additive_operators = true +ij_c_spaces_around_assignment_operators = true +ij_c_spaces_around_bitwise_operators = true +ij_c_spaces_around_equality_operators = true +ij_c_spaces_around_lambda_arrow = true +ij_c_spaces_around_logical_operators = true +ij_c_spaces_around_multiplicative_operators = true +ij_c_spaces_around_pm_operators = false +ij_c_spaces_around_relational_operators = true +ij_c_spaces_around_shift_operators = true +ij_c_spaces_around_unary_operator = false +ij_c_spaces_within_array_initializer_braces = false +ij_c_spaces_within_braces = true +ij_c_spaces_within_brackets = false +ij_c_spaces_within_cast_parentheses = false +ij_c_spaces_within_catch_parentheses = false +ij_c_spaces_within_category_parentheses = false +ij_c_spaces_within_empty_braces = false +ij_c_spaces_within_empty_function_call_parentheses = false +ij_c_spaces_within_empty_function_declaration_parentheses = false +ij_c_spaces_within_empty_lambda_capture_list_bracket = false +ij_c_spaces_within_empty_template_call_ltgt = false +ij_c_spaces_within_empty_template_declaration_ltgt = false +ij_c_spaces_within_for_parentheses = false +ij_c_spaces_within_function_call_parentheses = false +ij_c_spaces_within_function_declaration_parentheses = false +ij_c_spaces_within_if_parentheses = false +ij_c_spaces_within_lambda_capture_list_bracket = false +ij_c_spaces_within_method_parameter_type_parentheses = false +ij_c_spaces_within_method_return_type_parentheses = false +ij_c_spaces_within_parentheses = false +ij_c_spaces_within_property_attributes_parentheses = false +ij_c_spaces_within_protocols_brackets = false +ij_c_spaces_within_send_message_brackets = false +ij_c_spaces_within_structured_binding_list_bracket = false +ij_c_spaces_within_switch_parentheses = false +ij_c_spaces_within_template_call_ltgt = false +ij_c_spaces_within_template_declaration_ltgt = false +ij_c_spaces_within_template_double_gt = true +ij_c_spaces_within_while_parentheses = false +ij_c_special_else_if_treatment = true +ij_c_structured_binding_list_align_multiline = false +ij_c_structured_binding_list_align_multiline_bracket = false +ij_c_structured_binding_list_comma_on_next_line = false +ij_c_structured_binding_list_new_line_after_lbracket = false +ij_c_structured_binding_list_new_line_before_rbracket = false +ij_c_structured_binding_list_wrap = off +ij_c_superclass_list_after_colon = never +ij_c_superclass_list_align_multiline = true +ij_c_superclass_list_before_colon = if_long +ij_c_superclass_list_comma_on_next_line = false +ij_c_superclass_list_wrap = on_every_item +ij_c_tag_prefix_of_block_comment = at +ij_c_tag_prefix_of_line_comment = back_slash +ij_c_template_call_arguments_align_multiline = false +ij_c_template_call_arguments_align_multiline_pars = false +ij_c_template_call_arguments_comma_on_next_line = false +ij_c_template_call_arguments_new_line_after_lt = false +ij_c_template_call_arguments_new_line_before_gt = false +ij_c_template_call_arguments_wrap = off +ij_c_template_declaration_function_body_indent = false +ij_c_template_declaration_function_wrap = split_into_lines +ij_c_template_declaration_struct_body_indent = false +ij_c_template_declaration_struct_wrap = split_into_lines +ij_c_template_parameters_align_multiline = false +ij_c_template_parameters_align_multiline_pars = false +ij_c_template_parameters_comma_on_next_line = false +ij_c_template_parameters_new_line_after_lt = false +ij_c_template_parameters_new_line_before_gt = false +ij_c_template_parameters_wrap = off +ij_c_ternary_operation_signs_on_next_line = true +ij_c_ternary_operation_wrap = normal +ij_c_type_qualifiers_placement = before +ij_c_use_modern_casts = true +ij_c_use_setters_in_constructor = true +ij_c_while_brace_force = never +ij_c_while_on_new_line = false +ij_c_wrap_property_declaration = off + +[{*.cmake,CMakeLists.txt}] +ij_cmake_align_command_call_r_par = false +ij_cmake_align_control_flow_r_par = false +ij_cmake_align_multiline_parameters_in_calls = false +ij_cmake_force_commands_case = 2 +ij_cmake_keep_blank_lines_in_code = 2 +ij_cmake_space_before_for_parentheses = true +ij_cmake_space_before_if_parentheses = true +ij_cmake_space_before_method_call_parentheses = false +ij_cmake_space_before_method_parentheses = false +ij_cmake_space_before_while_parentheses = true +ij_cmake_spaces_within_for_parentheses = false +ij_cmake_spaces_within_if_parentheses = false +ij_cmake_spaces_within_method_call_parentheses = false +ij_cmake_spaces_within_method_parentheses = false +ij_cmake_spaces_within_while_parentheses = false + +[{*.gant,*.groovy,*.gy}] +indent_style = space +ij_groovy_align_group_field_declarations = false +ij_groovy_align_multiline_array_initializer_expression = false +ij_groovy_align_multiline_assignment = false +ij_groovy_align_multiline_binary_operation = false +ij_groovy_align_multiline_chained_methods = false +ij_groovy_align_multiline_extends_list = false +ij_groovy_align_multiline_for = true +ij_groovy_align_multiline_list_or_map = true +ij_groovy_align_multiline_method_parentheses = false +ij_groovy_align_multiline_parameters = true +ij_groovy_align_multiline_parameters_in_calls = false +ij_groovy_align_multiline_resources = true +ij_groovy_align_multiline_ternary_operation = false +ij_groovy_align_multiline_throws_list = false +ij_groovy_align_named_args_in_map = true +ij_groovy_align_throws_keyword = false +ij_groovy_array_initializer_new_line_after_left_brace = false +ij_groovy_array_initializer_right_brace_on_new_line = false +ij_groovy_array_initializer_wrap = off +ij_groovy_assert_statement_wrap = off +ij_groovy_assignment_wrap = off +ij_groovy_binary_operation_wrap = off +ij_groovy_blank_lines_after_class_header = 0 +ij_groovy_blank_lines_after_imports = 1 +ij_groovy_blank_lines_after_package = 1 +ij_groovy_blank_lines_around_class = 1 +ij_groovy_blank_lines_around_field = 0 +ij_groovy_blank_lines_around_field_in_interface = 0 +ij_groovy_blank_lines_around_method = 1 +ij_groovy_blank_lines_around_method_in_interface = 1 +ij_groovy_blank_lines_before_imports = 1 +ij_groovy_blank_lines_before_method_body = 0 +ij_groovy_blank_lines_before_package = 0 +ij_groovy_block_brace_style = end_of_line +ij_groovy_block_comment_add_space = false +ij_groovy_block_comment_at_first_column = true +ij_groovy_call_parameters_new_line_after_left_paren = false +ij_groovy_call_parameters_right_paren_on_new_line = false +ij_groovy_call_parameters_wrap = off +ij_groovy_catch_on_new_line = false +ij_groovy_class_annotation_wrap = split_into_lines +ij_groovy_class_brace_style = end_of_line +ij_groovy_class_count_to_use_import_on_demand = 5 +ij_groovy_do_while_brace_force = never +ij_groovy_else_on_new_line = false +ij_groovy_enable_groovydoc_formatting = true +ij_groovy_enum_constants_wrap = off +ij_groovy_extends_keyword_wrap = off +ij_groovy_extends_list_wrap = off +ij_groovy_field_annotation_wrap = split_into_lines +ij_groovy_finally_on_new_line = false +ij_groovy_for_brace_force = never +ij_groovy_for_statement_new_line_after_left_paren = false +ij_groovy_for_statement_right_paren_on_new_line = false +ij_groovy_for_statement_wrap = off +ij_groovy_ginq_general_clause_wrap_policy = 2 +ij_groovy_ginq_having_wrap_policy = 1 +ij_groovy_ginq_indent_having_clause = true +ij_groovy_ginq_indent_on_clause = true +ij_groovy_ginq_on_wrap_policy = 1 +ij_groovy_ginq_space_after_keyword = true +ij_groovy_if_brace_force = never +ij_groovy_import_annotation_wrap = 2 +ij_groovy_imports_layout = *, |, javax.**, java.**, |, $* +ij_groovy_indent_case_from_switch = true +ij_groovy_indent_label_blocks = true +ij_groovy_insert_inner_class_imports = false +ij_groovy_keep_blank_lines_before_right_brace = 2 +ij_groovy_keep_blank_lines_in_code = 2 +ij_groovy_keep_blank_lines_in_declarations = 2 +ij_groovy_keep_control_statement_in_one_line = true +ij_groovy_keep_first_column_comment = true +ij_groovy_keep_indents_on_empty_lines = false +ij_groovy_keep_line_breaks = true +ij_groovy_keep_multiple_expressions_in_one_line = false +ij_groovy_keep_simple_blocks_in_one_line = false +ij_groovy_keep_simple_classes_in_one_line = true +ij_groovy_keep_simple_lambdas_in_one_line = true +ij_groovy_keep_simple_methods_in_one_line = true +ij_groovy_label_indent_absolute = false +ij_groovy_label_indent_size = 0 +ij_groovy_lambda_brace_style = end_of_line +ij_groovy_layout_static_imports_separately = true +ij_groovy_line_comment_add_space = false +ij_groovy_line_comment_add_space_on_reformat = false +ij_groovy_line_comment_at_first_column = true +ij_groovy_method_annotation_wrap = split_into_lines +ij_groovy_method_brace_style = end_of_line +ij_groovy_method_call_chain_wrap = off +ij_groovy_method_parameters_new_line_after_left_paren = false +ij_groovy_method_parameters_right_paren_on_new_line = false +ij_groovy_method_parameters_wrap = off +ij_groovy_modifier_list_wrap = false +ij_groovy_names_count_to_use_import_on_demand = 3 +ij_groovy_packages_to_use_import_on_demand = java.awt.*, javax.swing.* +ij_groovy_parameter_annotation_wrap = off +ij_groovy_parentheses_expression_new_line_after_left_paren = false +ij_groovy_parentheses_expression_right_paren_on_new_line = false +ij_groovy_prefer_parameters_wrap = false +ij_groovy_resource_list_new_line_after_left_paren = false +ij_groovy_resource_list_right_paren_on_new_line = false +ij_groovy_resource_list_wrap = off +ij_groovy_space_after_assert_separator = true +ij_groovy_space_after_colon = true +ij_groovy_space_after_comma = true +ij_groovy_space_after_comma_in_type_arguments = true +ij_groovy_space_after_for_semicolon = true +ij_groovy_space_after_quest = true +ij_groovy_space_after_type_cast = true +ij_groovy_space_before_annotation_parameter_list = false +ij_groovy_space_before_array_initializer_left_brace = false +ij_groovy_space_before_assert_separator = false +ij_groovy_space_before_catch_keyword = true +ij_groovy_space_before_catch_left_brace = true +ij_groovy_space_before_catch_parentheses = true +ij_groovy_space_before_class_left_brace = true +ij_groovy_space_before_closure_left_brace = true +ij_groovy_space_before_colon = true +ij_groovy_space_before_comma = false +ij_groovy_space_before_do_left_brace = true +ij_groovy_space_before_else_keyword = true +ij_groovy_space_before_else_left_brace = true +ij_groovy_space_before_finally_keyword = true +ij_groovy_space_before_finally_left_brace = true +ij_groovy_space_before_for_left_brace = true +ij_groovy_space_before_for_parentheses = true +ij_groovy_space_before_for_semicolon = false +ij_groovy_space_before_if_left_brace = true +ij_groovy_space_before_if_parentheses = true +ij_groovy_space_before_method_call_parentheses = false +ij_groovy_space_before_method_left_brace = true +ij_groovy_space_before_method_parentheses = false +ij_groovy_space_before_quest = true +ij_groovy_space_before_record_parentheses = false +ij_groovy_space_before_switch_left_brace = true +ij_groovy_space_before_switch_parentheses = true +ij_groovy_space_before_synchronized_left_brace = true +ij_groovy_space_before_synchronized_parentheses = true +ij_groovy_space_before_try_left_brace = true +ij_groovy_space_before_try_parentheses = true +ij_groovy_space_before_while_keyword = true +ij_groovy_space_before_while_left_brace = true +ij_groovy_space_before_while_parentheses = true +ij_groovy_space_in_named_argument = true +ij_groovy_space_in_named_argument_before_colon = false +ij_groovy_space_within_empty_array_initializer_braces = false +ij_groovy_space_within_empty_method_call_parentheses = false +ij_groovy_spaces_around_additive_operators = true +ij_groovy_spaces_around_assignment_operators = true +ij_groovy_spaces_around_bitwise_operators = true +ij_groovy_spaces_around_equality_operators = true +ij_groovy_spaces_around_lambda_arrow = true +ij_groovy_spaces_around_logical_operators = true +ij_groovy_spaces_around_multiplicative_operators = true +ij_groovy_spaces_around_regex_operators = true +ij_groovy_spaces_around_relational_operators = true +ij_groovy_spaces_around_shift_operators = true +ij_groovy_spaces_within_annotation_parentheses = false +ij_groovy_spaces_within_array_initializer_braces = false +ij_groovy_spaces_within_braces = true +ij_groovy_spaces_within_brackets = false +ij_groovy_spaces_within_cast_parentheses = false +ij_groovy_spaces_within_catch_parentheses = false +ij_groovy_spaces_within_for_parentheses = false +ij_groovy_spaces_within_gstring_injection_braces = false +ij_groovy_spaces_within_if_parentheses = false +ij_groovy_spaces_within_list_or_map = false +ij_groovy_spaces_within_method_call_parentheses = false +ij_groovy_spaces_within_method_parentheses = false +ij_groovy_spaces_within_parentheses = false +ij_groovy_spaces_within_switch_parentheses = false +ij_groovy_spaces_within_synchronized_parentheses = false +ij_groovy_spaces_within_try_parentheses = false +ij_groovy_spaces_within_tuple_expression = false +ij_groovy_spaces_within_while_parentheses = false +ij_groovy_special_else_if_treatment = true +ij_groovy_ternary_operation_wrap = off +ij_groovy_throws_keyword_wrap = off +ij_groovy_throws_list_wrap = off +ij_groovy_use_flying_geese_braces = false +ij_groovy_use_fq_class_names = false +ij_groovy_use_fq_class_names_in_javadoc = true +ij_groovy_use_relative_indents = false +ij_groovy_use_single_class_imports = true +ij_groovy_variable_annotation_wrap = off +ij_groovy_while_brace_force = never +ij_groovy_while_on_new_line = false +ij_groovy_wrap_chain_calls_after_dot = false +ij_groovy_wrap_long_lines = false + +[{*.har,*.json}] +indent_size = 2 +indent_style = space +ij_json_array_wrapping = split_into_lines +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_keep_trailing_comma = false +ij_json_object_wrapping = split_into_lines +ij_json_property_alignment = do_not_align +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = false +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.htm,*.html,*.sht,*.shtm,*.shtml}] +indent_style = space +ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3 +ij_html_align_attributes = true +ij_html_align_text = false +ij_html_attribute_wrap = normal +ij_html_block_comment_add_space = false +ij_html_block_comment_at_first_column = true +ij_html_do_not_align_children_of_min_lines = 0 +ij_html_do_not_break_if_inline_tags = title, h1, h2, h3, h4, h5, h6, p +ij_html_do_not_indent_children_of_tags = html, body, thead, tbody, tfoot +ij_html_enforce_quotes = false +ij_html_inline_tags = a, abbr, acronym, b, basefont, bdo, big, br, cite, cite, code, dfn, em, font, i, img, input, kbd, label, q, s, samp, select, small, span, strike, strong, sub, sup, textarea, tt, u, var +ij_html_keep_blank_lines = 2 +ij_html_keep_indents_on_empty_lines = false +ij_html_keep_line_breaks = true +ij_html_keep_line_breaks_in_text = true +ij_html_keep_whitespaces = false +ij_html_keep_whitespaces_inside = span, pre, textarea +ij_html_line_comment_at_first_column = true +ij_html_new_line_after_last_attribute = never +ij_html_new_line_before_first_attribute = never +ij_html_quote_style = double +ij_html_remove_new_line_before_tags = br +ij_html_space_after_tag_name = false +ij_html_space_around_equality_in_attribute = false +ij_html_space_inside_empty_tag = false +ij_html_text_wrap = normal + +[{*.kt,*.kts}] +indent_style = space +max_line_length = 120 +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_add_space = false +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = true +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = false +ij_kotlin_continuation_indent_for_expression_bodies = false +ij_kotlin_continuation_indent_in_argument_lists = false +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = true +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = *, java.**, javax.**, kotlin.**, ^ +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_break_after_multiline_when_entry = true +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_add_space_on_reformat = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_use_custom_formatting_for_modifiers = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false + +[{*.markdown,*.md}] +indent_style = space +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_format_tables = true +ij_markdown_insert_quote_arrows_on_wrap = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_keep_line_breaks_inside_text_blocks = true +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 +ij_markdown_wrap_text_if_long = true +ij_markdown_wrap_text_inside_blockquotes = true + +[{*.pb,*.textproto}] +indent_size = 2 +indent_style = space +tab_width = 2 +ij_continuation_indent_size = 4 +ij_prototext_keep_blank_lines_in_code = 2 +ij_prototext_keep_indents_on_empty_lines = false +ij_prototext_keep_line_breaks = true +ij_prototext_space_after_colon = true +ij_prototext_space_after_comma = true +ij_prototext_space_before_colon = false +ij_prototext_space_before_comma = false +ij_prototext_spaces_within_braces = true +ij_prototext_spaces_within_brackets = false + +[{*.toml,Cargo.lock,Cargo.toml.orig,Gopkg.lock,Pipfile,poetry.lock}] +indent_style = space +ij_toml_keep_indents_on_empty_lines = false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2d39bff --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,215 @@ +# This file was generated using Kotlin DSL (.github/workflows/src.main.kts). +# If you want to modify the workflow, please change the Kotlin file and regenerate this YAML file. +# Generated with https://github.com/typesafegithub/github-workflows-kt + +name: 'Build' +on: + push: + paths-ignore: + - '**/*.md' + pull_request: + paths-ignore: + - '**/*.md' +jobs: + build: + name: '${{ matrix.name }}' + runs-on: '${{ matrix.runsOn }}' + strategy: + fail-fast: false + matrix: + id: + - 'windows' + - 'ubuntu-x64' + - 'macos-x64' + - 'macos-aarch64' + include: + - arch: 'x64' + buildAnitorrent: true + buildAnitorrentSeparately: false + buildIosFramework: false + composeResourceTriple: 'windows-x64' + extraGradleArgs: + - '-Pani.android.abis=x86_64' + gradleArgs: '"--scan" "--no-configuration-cache" "-Porg.gradle.daemon.idletimeout=60000" "-Pkotlin.native.ignoreDisabledTargets=true" "-Dfile.encoding=UTF-8" "-Dani.enable.anitorrent=true" "-DCMAKE_BUILD_TYPE=Release" "-DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake" "-DBoost_INCLUDE_DIR=C:/vcpkg/installed/x64-windows/include" "-Dorg.gradle.jvmargs=-Xmx6g" "-Dkotlin.daemon.jvm.options=-Xmx6g" "--parallel" "-Pani.android.abis=x86_64"' + id: 'windows' + installNativeDeps: false + name: 'Windows x86_64' + os: 'windows' + runTests: true + runsOn: + - 'self-hosted' + - 'Windows' + - 'X64' + selfHosted: true + uploadApk: false + uploadDesktopInstallers: true + - arch: 'x64' + buildAnitorrent: false + buildAnitorrentSeparately: false + buildIosFramework: false + composeResourceTriple: 'linux-x64' + extraGradleArgs: [ ] + gradleArgs: '"--scan" "--no-configuration-cache" "-Porg.gradle.daemon.idletimeout=60000" "-Pkotlin.native.ignoreDisabledTargets=true" "-Dfile.encoding=UTF-8" "-Dorg.gradle.jvmargs=-Xmx4g" "-Dkotlin.daemon.jvm.options=-Xmx4g"' + id: 'ubuntu-x64' + installNativeDeps: true + name: 'Ubuntu x86_64 (Compile only)' + os: 'ubuntu' + runTests: false + runsOn: + - 'ubuntu-20.04' + selfHosted: false + uploadApk: false + uploadDesktopInstallers: false + - arch: 'x64' + buildAnitorrent: true + buildAnitorrentSeparately: true + buildIosFramework: false + composeResourceTriple: 'macos-x64' + extraGradleArgs: [ ] + gradleArgs: '"--scan" "--no-configuration-cache" "-Porg.gradle.daemon.idletimeout=60000" "-Pkotlin.native.ignoreDisabledTargets=true" "-Dfile.encoding=UTF-8" "-Dani.enable.anitorrent=true" "-DCMAKE_BUILD_TYPE=Release" "-Dorg.gradle.jvmargs=-Xmx4g" "-Dkotlin.daemon.jvm.options=-Xmx4g"' + id: 'macos-x64' + installNativeDeps: true + name: 'macOS x86_64' + os: 'macos' + runTests: true + runsOn: + - 'macos-13' + selfHosted: false + uploadApk: true + uploadDesktopInstallers: true + - arch: 'aarch64' + buildAnitorrent: true + buildAnitorrentSeparately: true + buildIosFramework: false + composeResourceTriple: 'macos-arm64' + extraGradleArgs: + - '-Pani.android.abis=arm64-v8a' + gradleArgs: '"--scan" "--no-configuration-cache" "-Porg.gradle.daemon.idletimeout=60000" "-Pkotlin.native.ignoreDisabledTargets=true" "-Dfile.encoding=UTF-8" "-Dani.enable.anitorrent=true" "-DCMAKE_BUILD_TYPE=Release" "-Dorg.gradle.jvmargs=-Xmx6g" "-Dkotlin.daemon.jvm.options=-Xmx4g" "--parallel" "-Pani.android.abis=arm64-v8a"' + id: 'macos-aarch64' + installNativeDeps: false + name: 'macOS AArch64' + os: 'macos' + runTests: true + runsOn: + - 'self-hosted' + - 'macOS' + - 'ARM64' + selfHosted: true + uploadApk: true + uploadDesktopInstallers: true + steps: + - id: 'step-0' + uses: 'actions/checkout@v4' + with: + submodules: 'recursive' + - id: 'step-1' + name: 'Free space for macOS' + continue-on-error: true + run: 'chmod +x ./ci-helper/free-space-macos.sh && ./ci-helper/free-space-macos.sh' + if: '${{ ((matrix.os == ''macos'')) && (!(matrix.selfHosted)) }}' + - id: 'step-2' + name: 'Resolve JBR location' + run: |- + # Expand jbrLocationExpr + jbr_location_expr=$(eval echo ${{ runner.tool_cache }}/jbrsdk_jcef-21.0.5-osx-aarch64-b631.8.tar.gz) + echo "jbrLocation=$jbr_location_expr" >> $GITHUB_OUTPUT + if: '${{ ((matrix.os == ''macos'')) && ((matrix.arch == ''aarch64'')) }}' + - id: 'step-3' + name: 'Get JBR 21 for macOS AArch64' + env: + jbrLocation: '${{ steps.step-2.outputs.jbrLocation }}' + run: |- + jbr_location="$jbrLocation" + checksum_url="https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk_jcef-21.0.5-osx-aarch64-b631.8.tar.gz.checksum" + checksum_file="checksum.tmp" + wget -q -O $checksum_file $checksum_url + + expected_checksum=$(awk '{print $1}' $checksum_file) + file_checksum="" + + if [ -f "$jbr_location" ]; then + file_checksum=$(shasum -a 512 "$jbr_location" | awk '{print $1}') + fi + + if [ "$file_checksum" != "$expected_checksum" ]; then + wget -q --tries=3 https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk_jcef-21.0.5-osx-aarch64-b631.8.tar.gz -O "$jbr_location" + file_checksum=$(shasum -a 512 "$jbr_location" | awk '{print $1}') + fi + + if [ "$file_checksum" != "$expected_checksum" ]; then + echo "Checksum verification failed!" >&2 + rm -f $checksum_file + exit 1 + fi + + rm -f $checksum_file + file "$jbr_location" + if: '${{ ((matrix.os == ''macos'')) && ((matrix.arch == ''aarch64'')) }}' + - id: 'step-4' + name: 'Setup JBR 21 for macOS AArch64' + uses: 'gmitch215/setup-java@6d2c5e1f82f180ae79f799f0ed6e3e5efb4e664d' + with: + java-version: '21' + distribution: 'jdkfile' + jdkFile: '${{ steps.step-2.outputs.jbrLocation }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + if: '${{ ((matrix.os == ''macos'')) && ((matrix.arch == ''aarch64'')) }}' + - id: 'step-5' + name: 'Setup JBR 21 for other OS' + uses: 'gmitch215/setup-java@6d2c5e1f82f180ae79f799f0ed6e3e5efb4e664d' + with: + java-version: '21' + distribution: 'jetbrains' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + if: '${{ !(((matrix.os == ''macos'')) && ((matrix.arch == ''aarch64''))) }}' + - id: 'step-6' + run: 'echo "jvm.toolchain.version=21" >> local.properties' + - id: 'step-7' + name: 'Setup vcpkg cache' + uses: 'actions/github-script@v7' + with: + script: |- + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + if: '${{ ((matrix.os == ''windows'')) && (matrix.installNativeDeps) }}' + - id: 'step-8' + name: 'Install Native Dependencies for Windows' + env: + VCPKG_BINARY_SOURCES: 'clear;x-gha,readwrite' + run: './ci-helper/install-deps-windows.cmd' + if: '${{ ((matrix.os == ''windows'')) && (matrix.installNativeDeps) }}' + - id: 'step-9' + name: 'Install Native Dependencies for MacOS' + run: 'chmod +x ./ci-helper/install-deps-macos-ci.sh && ./ci-helper/install-deps-macos-ci.sh' + if: '${{ ((matrix.os == ''macos'')) && (matrix.installNativeDeps) }}' + - id: 'step-10' + run: 'chmod -R 777 .' + if: '${{ ((matrix.os == ''ubuntu'')) || ((matrix.os == ''macos'')) }}' + - id: 'step-11' + name: 'Setup Gradle' + uses: 'gradle/actions/setup-gradle@v3' + with: + cache-disabled: 'true' + - id: 'step-12' + name: 'Clean and download dependencies' + uses: 'nick-fields/retry@v2' + with: + timeout_minutes: '60' + max_attempts: '3' + command: './gradlew ${{ matrix.gradleArgs }}' + - id: 'step-13' + name: 'Explicitly generate Compose resources' + run: './gradlew updateDevVersionNameFromGit ${{ matrix.gradleArgs }}' + - id: 'step-14' + name: 'Compile Kotlin' + run: './gradlew compileKotlin compileCommonMainKotlinMetadata compileDebugKotlinAndroid compileReleaseKotlinAndroid compileJvmMainKotlinMetadata compileKotlinDesktop compileKotlinMetadata ${{ matrix.gradleArgs }}' + - id: 'step-15' + name: 'Check' + uses: 'nick-fields/retry@v2' + with: + timeout_minutes: '60' + max_attempts: '2' + command: './gradlew check ${{ matrix.gradleArgs }}' + if: '${{ matrix.runTests }}' diff --git a/.github/workflows/src.main.kts b/.github/workflows/src.main.kts new file mode 100755 index 0000000..1393009 --- /dev/null +++ b/.github/workflows/src.main.kts @@ -0,0 +1,871 @@ +#!/usr/bin/env kotlin + +// 也可以在 IDE 里右键 Run + +@file:CompilerOptions("-Xmulti-dollar-interpolation", "-Xdont-warn-on-error-suppression") +@file:Suppress("UNSUPPORTED_FEATURE", "UNSUPPORTED") + +@file:Repository("https://repo.maven.apache.org/maven2/") +@file:DependsOn("io.github.typesafegithub:github-workflows-kt:3.0.1") +@file:Repository("https://bindings.krzeminski.it") + +// Build +@file:DependsOn("actions:checkout:v4") +@file:DependsOn("gmitch215:setup-java:6d2c5e1f82f180ae79f799f0ed6e3e5efb4e664d") +@file:DependsOn("org.jetbrains:annotations:23.0.0") +@file:DependsOn("actions:github-script:v7") +@file:DependsOn("gradle:actions__setup-gradle:v3") +@file:DependsOn("nick-fields:retry:v2") +@file:DependsOn("timheuer:base64-to-file:v1.1") +@file:DependsOn("actions:upload-artifact:v4") + +// Release +@file:DependsOn("dawidd6:action-get-tag:v1") +@file:DependsOn("bhowell2:github-substring-action:v1.0.0") +@file:DependsOn("softprops:action-gh-release:v1") +@file:DependsOn("snow-actions:qrcode:v1.0.0") + + +import Secrets.AWS_ACCESS_KEY_ID +import Secrets.AWS_BASEURL +import Secrets.AWS_BUCKET +import Secrets.AWS_REGION +import Secrets.AWS_SECRET_ACCESS_KEY +import Secrets.GITHUB_REPOSITORY +import io.github.typesafegithub.workflows.actions.actions.Checkout +import io.github.typesafegithub.workflows.actions.actions.GithubScript +import io.github.typesafegithub.workflows.actions.actions.UploadArtifact +import io.github.typesafegithub.workflows.actions.bhowell2.GithubSubstringAction_Untyped +import io.github.typesafegithub.workflows.actions.dawidd6.ActionGetTag_Untyped +import io.github.typesafegithub.workflows.actions.gmitch215.SetupJava_Untyped +import io.github.typesafegithub.workflows.actions.gradle.ActionsSetupGradle +import io.github.typesafegithub.workflows.actions.nickfields.Retry_Untyped +import io.github.typesafegithub.workflows.actions.snowactions.Qrcode_Untyped +import io.github.typesafegithub.workflows.domain.CommandStep +import io.github.typesafegithub.workflows.domain.RunnerType +import io.github.typesafegithub.workflows.domain.triggers.PullRequest +import io.github.typesafegithub.workflows.domain.triggers.Push +import io.github.typesafegithub.workflows.dsl.JobBuilder +import io.github.typesafegithub.workflows.dsl.expressions.Contexts +import io.github.typesafegithub.workflows.dsl.expressions.ExpressionContext +import io.github.typesafegithub.workflows.dsl.expressions.contexts.GitHubContext +import io.github.typesafegithub.workflows.dsl.expressions.contexts.SecretsContext +import io.github.typesafegithub.workflows.dsl.expressions.expr +import io.github.typesafegithub.workflows.dsl.workflow +import io.github.typesafegithub.workflows.yaml.ConsistencyCheckJobConfig +import org.intellij.lang.annotations.Language +import kotlin.reflect.KProperty1 +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.memberProperties + +object OS { + const val WINDOWS = "windows" + const val UBUNTU = "ubuntu" + const val MACOS = "macos" +} + +object Arch { + const val X64 = "x64" + const val AARCH64 = "aarch64" +} + +object AndroidArch { + const val ARM64_V8A = "arm64-v8a" + const val X86_64 = "x86_64" + const val ARMEABI_V7A = "armeabi-v7a" + + val entriesWithoutUniversal = listOf(ARM64_V8A, X86_64, ARMEABI_V7A) + val entriesWithUniversal = entriesWithoutUniversal + UNIVERSAL + + const val UNIVERSAL = "universal" +} + +// Build 和 Release 共享这个 +/** + * 一台机器的配置 + * + * 如果改了, 也要改 [MatrixContext] + */ +class MatrixInstance( + // 定义属性为 val, 就会生成到 yml 的 `matrix` 里. + + /** + * 用于 matrix 的 id + */ + val id: String, + /** + * 显示的名字, 不能变更, 否则会导致 PR Rules 失效 + */ + val name: String, + /** + * GitHub Actions 的规范名称, e.g. `ubuntu-20.04`, `windows-2019`. + */ + val runsOn: List, + + /** + * 只在脚本内部判断 OS 使用, 不影响 github 调度机器 + * @see OS + */ + val os: String, + /** + * 只在脚本内部判断 OS 使用, 不影响 github 调度机器 + * @see Arch + */ + val arch: String, + + /** + * `false` = GitHub Actions 的免费机器 + */ + val selfHosted: Boolean, + /** + * 有一台机器是 true 就行 + */ + val uploadApk: Boolean, + val buildAnitorrent: Boolean, + val buildAnitorrentSeparately: Boolean, + /** + * Compose for Desktop 的 resource 标识符, e.g. `windows-x64` + */ + val composeResourceTriple: String, + val runTests: Boolean = true, + /** + * 每种机器必须至少有一个是 true, 否则 release 时传不全 + */ + val uploadDesktopInstallers: Boolean = true, + /** + * 追加到所有 Gradle 命令的参数. 无需 quote + */ + val extraGradleArgs: List = emptyList(), + /** + * Self hosted 机器已经配好了环境, 无需安装 + */ + val installNativeDeps: Boolean = !selfHosted, + val buildIosFramework: Boolean = false, + + // Gradle command line args + gradleHeap: String = "4g", + kotlinCompilerHeap: String = "4g", + /** + * 只能在内存比较大的时候用. + */ + gradleParallel: Boolean = selfHosted, +) { + @Suppress("unused") + val gradleArgs = buildList { + + /** + * Windows 上必须 quote, Unix 上 quote 或不 quote 都行. 所以我们统一 quote. + */ + fun quote(s: String): String { + if (s.startsWith("\"")) { + return s // already quoted + } + return "\"$s\"" + } + + add(quote("--scan")) + add(quote("--no-configuration-cache")) + add(quote("-Porg.gradle.daemon.idletimeout=60000")) + add(quote("-Pkotlin.native.ignoreDisabledTargets=true")) + add(quote("-Dfile.encoding=UTF-8")) + + if (buildAnitorrent) { + add(quote("-Dani.enable.anitorrent=true")) + add(quote("-DCMAKE_BUILD_TYPE=Release")) + } + + if (os == OS.WINDOWS) { + add(quote("-DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake")) + add(quote("-DBoost_INCLUDE_DIR=C:/vcpkg/installed/x64-windows/include")) + } + + add(quote("-Dorg.gradle.jvmargs=-Xmx${gradleHeap}")) + add(quote("-Dkotlin.daemon.jvm.options=-Xmx${kotlinCompilerHeap}")) + + if (gradleParallel) { + add(quote("--parallel")) + } + + extraGradleArgs.forEach { + add(quote(it)) + } + }.joinToString(" ") + + init { + require(os in listOf(OS.WINDOWS, OS.UBUNTU, OS.MACOS)) { "Unsupported OS: $os" } + require(arch in listOf(Arch.X64, Arch.AARCH64)) { "Unsupported arch: $arch" } + } +} + + +val matrixInstances = listOf( + MatrixInstance( + id = "windows", + name = "Windows x86_64", + runsOn = listOf("self-hosted", "Windows", "X64"), + os = OS.WINDOWS, + arch = Arch.X64, + selfHosted = true, + uploadApk = false, + buildAnitorrent = true, + buildAnitorrentSeparately = false, // windows 单线程构建 anitorrent, 要一起跑节约时间 + composeResourceTriple = "windows-x64", + gradleHeap = "6g", + kotlinCompilerHeap = "6g", + gradleParallel = true, + extraGradleArgs = listOf( + "-Pani.android.abis=x86_64", + ), + ), + MatrixInstance( + id = "ubuntu-x64", + name = "Ubuntu x86_64 (Compile only)", + runsOn = listOf("ubuntu-20.04"), + os = OS.UBUNTU, + arch = Arch.X64, + selfHosted = false, + uploadApk = false, + buildAnitorrent = false, + buildAnitorrentSeparately = false, + composeResourceTriple = "linux-x64", + runTests = false, + uploadDesktopInstallers = false, + extraGradleArgs = listOf(), + gradleHeap = "4g", + kotlinCompilerHeap = "4g", + ), + MatrixInstance( + id = "macos-x64", + name = "macOS x86_64", + runsOn = listOf("macos-13"), + os = OS.MACOS, + arch = Arch.X64, + selfHosted = false, + uploadApk = true, // all ABIs + buildAnitorrent = true, + buildAnitorrentSeparately = true, + composeResourceTriple = "macos-x64", + buildIosFramework = false, + gradleHeap = "4g", + kotlinCompilerHeap = "4g", + extraGradleArgs = listOf(), + // build all android ABI + ), + MatrixInstance( + id = "macos-aarch64", + name = "macOS AArch64", + runsOn = listOf("self-hosted", "macOS", "ARM64"), + os = OS.MACOS, + arch = Arch.AARCH64, + selfHosted = true, + uploadApk = true, // upload arm64-v8a once finished + buildAnitorrent = true, + buildAnitorrentSeparately = true, + composeResourceTriple = "macos-arm64", + extraGradleArgs = listOf( + "-Pani.android.abis=arm64-v8a", + ), + buildIosFramework = false, + gradleHeap = "6g", + kotlinCompilerHeap = "4g", + gradleParallel = true, + ), +) + +workflow( + name = "Build", + on = listOf( + Push(pathsIgnore = listOf("**/*.md")), + PullRequest(pathsIgnore = listOf("**/*.md")), + ), + sourceFile = __FILE__, + targetFileName = "build.yml", + consistencyCheckJobConfig = ConsistencyCheckJobConfig.Disabled, +) { + job( + id = "build", + name = expr { matrix.name }, + runsOn = RunnerType.Custom(expr { matrix.runsOn }), + _customArguments = mapOf( + "strategy" to mapOf( + "fail-fast" to false, + "matrix" to mapOf( + "id" to matrixInstances.map { it.id }, + "include" to matrixInstances.map { it.toMatrixIncludeMap() }, + ), + ), + ), + ) { + uses(action = Checkout(submodules_Untyped = "recursive")) + + freeSpace() + installJbr21() + installNativeDeps() + chmod777() + setupGradle() + +// runGradle( +// name = "Update dev version name", +// tasks = ["updateDevVersionNameFromGit"], +// ) + + compileAndAssemble() + gradleCheck() + } +} + +//workflow( +// name = "Release", +// on = listOf( +// Push(tags = listOf("v*")), +// ), +// sourceFile = __FILE__, +// targetFileName = "release.yml", +// consistencyCheckJobConfig = ConsistencyCheckJobConfig.Disabled, +//) { +// val createRelease = job( +// id = "create-release", +// name = "Create Release", +// runsOn = RunnerType.UbuntuLatest, +// outputs = object : JobOutputs() { +// var uploadUrl by output() +// var id by output() +// }, +// ) { +// uses(action = Checkout()) // No need to be recursive +// +// val gitTag = getGitTag() +// +// val releaseNotes = run( +// name = "Generate Release Notes", +// command = shell( +// $$""" +// # Specify the file path +// FILE_PATH="ci-helper/release-template.md" +// +// # Read the file content +// file_content=$(cat "$FILE_PATH") +// +// modified_content="$file_content" +// # Replace 'string_to_find' with 'string_to_replace_with' in the content +// modified_content="${modified_content//\$GIT_TAG/$${expr { gitTag.tagExpr }}}" +// modified_content="${modified_content//\$TAG_VERSION/$${expr { gitTag.tagVersionExpr }}}" +// +// # Output the result as a step output +// echo "result<> $GITHUB_OUTPUT +// echo "$modified_content" >> $GITHUB_OUTPUT +// echo "EOF" >> $GITHUB_OUTPUT +// """.trimIndent(), +// ), +// ) +// +// val createRelease = uses( +// name = "Create Release", +// action = ActionGhRelease( +// tagName = expr { gitTag.tagExpr }, +// name = expr { gitTag.tagVersionExpr }, +// body = expr { releaseNotes.outputs["result"] }, +// draft = true, +// prerelease_Untyped = expr { contains(gitTag.tagExpr, "'-'") }, +// ), +// env = mapOf("GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }), +// ) +// +// jobOutputs.uploadUrl = createRelease.outputs.uploadUrl +// jobOutputs.id = createRelease.outputs.id +// } +// +// val matrixInstancesForRelease = matrixInstances.filterNot { it.os == OS.UBUNTU } +// job( +// id = "release", +// name = expr { matrix.name }, +// needs = listOf(createRelease), +// runsOn = RunnerType.Custom(expr { matrix.runsOn }), +// _customArguments = mapOf( +// "strategy" to mapOf( +// "fail-fast" to false, +// "matrix" to mapOf( +// "id" to matrixInstancesForRelease.map { it.id }, +// "include" to matrixInstancesForRelease.map { it.toMatrixIncludeMap() }, +// ), +// ), +// ), +// ) { +// uses(action = Checkout(submodules_Untyped = "recursive")) +// +// val gitTag = getGitTag() +// +// freeSpace() +// installJbr21() +// installNativeDeps() +// chmod777() +// setupGradle() +// +// runGradle( +// name = "Update Release Version Name", +// tasks = ["updateReleaseVersionNameFromGit"], +// env = mapOf( +// "GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }, +// "GITHUB_REPOSITORY" to expr { secrets.GITHUB_REPOSITORY }, +// "CI_RELEASE_ID" to expr { createRelease.outputs.id }, +// "CI_TAG" to expr { gitTag.tagExpr }, +// ), +// ) +// +// val prepareSigningKey = prepareSigningKey() +// buildAnitorrent() +// compileAndAssemble() +// +// buildAndroidApk(prepareSigningKey) +// // No Check. We've already checked in build +// +// with( +// CIHelper( +// releaseIdExpr = createRelease.outputs.id, +// gitTag, +// ), +// ) { +// uploadAndroidApkToCloud() +// generateQRCodeAndUpload() +// uploadDesktopInstallers() +// uploadComposeLogs() +// } +// run( +// name = "Cleanup temp files", +// `if` = expr { matrix.selfHosted and matrix.isMacOSAArch64 }, +// command = shell("""find /private/var/folders/sv -type f -name "debugInfo.knd*.tmp" -exec rm {} + -print 2>/dev/null | wc -l"""), +// continueOnError = true, +// ) +// } +//} + +data class GitTag( + /** + * The full git tag, e.g. `v1.0.0` + */ + val tagExpr: String, + /** + * The tag version, e.g. `1.0.0` + */ + val tagVersionExpr: String, +) + +fun JobBuilder<*>.getGitTag(): GitTag { + val tag = uses( + name = "Get Tag", + action = ActionGetTag_Untyped(), + ) + + val tagVersion = uses( + action = GithubSubstringAction_Untyped( + value_Untyped = expr { tag.outputs.tag }, + indexOfStr_Untyped = "v", + defaultReturnValue_Untyped = expr { tag.outputs.tag }, + ), + ) + + return GitTag( + tagExpr = tag.outputs.tag, + tagVersionExpr = tagVersion.outputs["substring"], + ) +} + +fun JobBuilder<*>.runGradle( + name: String? = null, + `if`: String? = null, + @Language("shell", prefix = "./gradlew ") vararg tasks: String, + env: Map = emptyMap(), +): CommandStep = run( + name = name, + `if` = `if`, + command = shell( + buildString { + append("./gradlew ") + tasks.joinTo(this, " ") + append(' ') + append(expr { matrix.gradleArgs }) + }, + ), + env = env, +) + +/** + * GitHub Actions 上给的硬盘比较少, 我们删掉一些不必要的文件来腾出空间. + */ +fun JobBuilder<*>.freeSpace() { + run( + name = "Free space for macOS", + `if` = expr { matrix.isMacOS and !matrix.selfHosted }, + command = shell($$"""chmod +x ./ci-helper/free-space-macos.sh && ./ci-helper/free-space-macos.sh"""), + continueOnError = true, + ) +} + +fun JobBuilder<*>.installJbr21() { + // For mac + val jbrUrl = "https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk_jcef-21.0.5-osx-aarch64-b631.8.tar.gz" + val jbrChecksumUrl = + "https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk_jcef-21.0.5-osx-aarch64-b631.8.tar.gz.checksum" + + val jbrFilename = jbrUrl.substringAfterLast('/') + + val jbrLocationExpr = run( + name = "Resolve JBR location", + `if` = expr { matrix.isMacOSAArch64 }, + command = shell( + $$""" + # Expand jbrLocationExpr + jbr_location_expr=$(eval echo $${expr { runner.tool_cache } + "/" + jbrFilename}) + echo "jbrLocation=$jbr_location_expr" >> $GITHUB_OUTPUT + """.trimIndent(), + ), + ).outputs["jbrLocation"] + + run( + name = "Get JBR 21 for macOS AArch64", + `if` = expr { matrix.isMacOSAArch64 }, + command = shell( + $$""" + jbr_location="$jbrLocation" + checksum_url="$$jbrChecksumUrl" + checksum_file="checksum.tmp" + wget -q -O $checksum_file $checksum_url + + expected_checksum=$(awk '{print $1}' $checksum_file) + file_checksum="" + + if [ -f "$jbr_location" ]; then + file_checksum=$(shasum -a 512 "$jbr_location" | awk '{print $1}') + fi + + if [ "$file_checksum" != "$expected_checksum" ]; then + wget -q --tries=3 $$jbrUrl -O "$jbr_location" + file_checksum=$(shasum -a 512 "$jbr_location" | awk '{print $1}') + fi + + if [ "$file_checksum" != "$expected_checksum" ]; then + echo "Checksum verification failed!" >&2 + rm -f $checksum_file + exit 1 + fi + + rm -f $checksum_file + file "$jbr_location" + """.trimIndent(), + ), + env = mapOf( + "jbrLocation" to expr { jbrLocationExpr }, + ), + ) + + uses( + name = "Setup JBR 21 for macOS AArch64", + `if` = expr { matrix.isMacOSAArch64 }, + action = SetupJava_Untyped( + distribution_Untyped = "jdkfile", + javaVersion_Untyped = "21", + jdkFile_Untyped = expr { jbrLocationExpr }, + ), + env = mapOf("GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }), + ) + + // For Windows + Ubuntu + uses( + name = "Setup JBR 21 for other OS", + `if` = expr { !matrix.isMacOSAArch64 }, + action = SetupJava_Untyped( + distribution_Untyped = "jetbrains", + javaVersion_Untyped = "21", + ), + env = mapOf("GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }), + ) + + run( + command = shell($$"""echo "jvm.toolchain.version=21" >> local.properties"""), + ) +} + +fun JobBuilder<*>.installNativeDeps() { + // Windows + uses( + name = "Setup vcpkg cache", + `if` = expr { matrix.isWindows and matrix.installNativeDeps }, + action = GithubScript( + script = """ + core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || ''); + core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); + """.trimIndent(), + ), + ) + run( + name = "Install Native Dependencies for Windows", + `if` = expr { matrix.isWindows and matrix.installNativeDeps }, + command = "./ci-helper/install-deps-windows.cmd", + env = mapOf("VCPKG_BINARY_SOURCES" to "clear;x-gha,readwrite"), + ) + + // MacOS + run( + name = "Install Native Dependencies for MacOS", + `if` = expr { matrix.isMacOS and matrix.installNativeDeps }, + command = shell($$"""chmod +x ./ci-helper/install-deps-macos-ci.sh && ./ci-helper/install-deps-macos-ci.sh"""), + ) +} + +fun JobBuilder<*>.chmod777() { + run( + `if` = expr { matrix.isUnix }, + command = "chmod -R 777 .", + ) +} + +fun JobBuilder<*>.setupGradle() { + uses( + name = "Setup Gradle", + action = ActionsSetupGradle( + cacheDisabled = true, + ), + ) + uses( + name = "Clean and download dependencies", + action = Retry_Untyped( + maxAttempts_Untyped = "3", + timeoutMinutes_Untyped = "60", + command_Untyped = """./gradlew """ + expr { matrix.gradleArgs }, + ), + ) +} + +fun JobBuilder<*>.compileAndAssemble() { + // 备注: 这个可能已经不需要了, Compose 可能已经修复了这个 bug + runGradle( + name = "Explicitly generate Compose resources", + tasks = ["updateDevVersionNameFromGit"], + ) + + runGradle( + name = "Compile Kotlin", + tasks = [ + "compileKotlin", + "compileCommonMainKotlinMetadata", + "compileDebugKotlinAndroid", + "compileReleaseKotlinAndroid", + "compileJvmMainKotlinMetadata", + "compileKotlinDesktop", + "compileKotlinMetadata", + ], + ) +} + +fun JobBuilder<*>.gradleCheck() { + uses( + name = "Check", + `if` = expr { matrix.runTests }, + action = Retry_Untyped( + maxAttempts_Untyped = "2", + timeoutMinutes_Untyped = "60", + command_Untyped = "./gradlew check " + expr { matrix.gradleArgs }, + ), + ) +} + +fun JobBuilder<*>.packageDesktopAndUpload() { + runGradle( + name = "Package Desktop", + `if` = expr { matrix.uploadDesktopInstallers and !matrix.isMacOSX64 }, + tasks = [ + "packageReleaseDistributionForCurrentOS", + ], + ) + + uploadComposeLogs() + +// uses( +// name = "Upload macOS portable", +// `if` = expr { matrix.uploadDesktopInstallers and matrix.isMacOS }, +// action = UploadArtifact( +// name = "ani-macos-portable-${expr { matrix.arch }}", +// path_Untyped = "app/desktop/build/compose/binaries/main-release/app/Ani.app", +// ), +// ) + uses( + name = "Upload macOS dmg", + `if` = expr { matrix.uploadDesktopInstallers and matrix.isMacOS }, + action = UploadArtifact( + name = "ani-macos-dmg-${expr { matrix.arch }}", + path_Untyped = "app/desktop/build/compose/binaries/main-release/dmg/Ani-*.dmg", + ), + ) + uses( + name = "Upload Windows packages", + `if` = expr { matrix.uploadDesktopInstallers and matrix.isWindows }, + action = UploadArtifact( + name = "ani-windows-portable", + path_Untyped = "app/desktop/build/compose/binaries/main-release/app", + ), + ) +} + +fun JobBuilder<*>.uploadComposeLogs() { + uses( + name = "Upload compose logs", + `if` = expr { matrix.uploadDesktopInstallers }, + action = UploadArtifact( + name = "compose-logs-${expr { matrix.os }}-${expr { matrix.arch }}", + path_Untyped = "app/desktop/build/compose/logs", + ), + ) +} + +class CIHelper( + releaseIdExpr: String, + private val gitTag: GitTag, +) { + private val ciHelperSecrets: Map = mapOf( + "GITHUB_TOKEN" to expr { secrets.GITHUB_TOKEN }, + "GITHUB_REPOSITORY" to expr { secrets.GITHUB_REPOSITORY }, + "CI_RELEASE_ID" to expr { releaseIdExpr }, + "CI_TAG" to expr { gitTag.tagExpr }, + "UPLOAD_TO_S3" to "true", + "AWS_ACCESS_KEY_ID" to expr { secrets.AWS_ACCESS_KEY_ID }, + "AWS_SECRET_ACCESS_KEY" to expr { secrets.AWS_SECRET_ACCESS_KEY }, + "AWS_BASEURL" to expr { secrets.AWS_BASEURL }, + "AWS_REGION" to expr { secrets.AWS_REGION }, + "AWS_BUCKET" to expr { secrets.AWS_BUCKET }, + ) + + fun JobBuilder<*>.uploadAndroidApkToCloud() { + runGradle( + name = "Upload Android APK", + `if` = expr { matrix.uploadApk }, + tasks = [":ci-helper:uploadAndroidApk"], + env = ciHelperSecrets, + ) + } + + fun JobBuilder<*>.generateQRCodeAndUpload() { + uses( + name = "Generate QR code for APK (GitHub)", + `if` = expr { matrix.uploadApk }, + action = Qrcode_Untyped( + text_Untyped = """https://github.com/Him188/ani/releases/download/${expr { gitTag.tagExpr }}/ani-${expr { gitTag.tagVersionExpr }}-universal.apk""", + path_Untyped = "apk-qrcode-github.png", + ), + ) + uses( + name = "Generate QR code for APK (Cloudflare)", + `if` = expr { matrix.uploadApk }, + action = Qrcode_Untyped( + text_Untyped = """https://d.myani.org/${expr { gitTag.tagExpr }}/ani-${expr { gitTag.tagVersionExpr }}-universal.apk""", + path_Untyped = "apk-qrcode-cloudflare.png", + ), + ) + runGradle( + name = "Upload QR code", + `if` = expr { matrix.uploadApk }, + tasks = [":ci-helper:uploadAndroidApkQR"], + env = ciHelperSecrets, + ) + } + + fun JobBuilder<*>.uploadDesktopInstallers() { + runGradle( + name = "Upload Desktop Installers", + `if` = expr { matrix.uploadDesktopInstallers }, + tasks = [":ci-helper:uploadDesktopInstallers"], + env = ciHelperSecrets, + ) + } +} + + +/// ENV + +object MatrixContext : ExpressionContext("matrix") { + val id by propertyToExprPath + val os by propertyToExprPath + val runsOn by propertyToExprPath + val selfHosted by propertyToExprPath + val installNativeDeps by propertyToExprPath + val name by propertyToExprPath + val uploadApk by propertyToExprPath + val arch by propertyToExprPath + val buildAnitorrent by propertyToExprPath + val buildAnitorrentSeparately by propertyToExprPath + val composeResourceTriple by propertyToExprPath + val runTests by propertyToExprPath + val uploadDesktopInstallers by propertyToExprPath + val gradleArgs by propertyToExprPath + + init { + // check properties exists + val instanceProperties = MatrixInstance::class.memberProperties + val allowedNames = instanceProperties.map { it.name } + MatrixContext::class.declaredMemberProperties.forEach { + check(it.name in allowedNames) { "Property ${it.name} not found in MatrixInstance" } + } + } +} + +object Secrets { + val SecretsContext.GITHUB_REPOSITORY by SecretsContext.propertyToExprPath + val SecretsContext.SIGNING_RELEASE_STOREFILE by SecretsContext.propertyToExprPath + val SecretsContext.SIGNING_RELEASE_STOREPASSWORD by SecretsContext.propertyToExprPath + val SecretsContext.SIGNING_RELEASE_KEYALIAS by SecretsContext.propertyToExprPath + val SecretsContext.SIGNING_RELEASE_KEYPASSWORD by SecretsContext.propertyToExprPath + + val SecretsContext.AWS_ACCESS_KEY_ID by SecretsContext.propertyToExprPath + val SecretsContext.AWS_SECRET_ACCESS_KEY by SecretsContext.propertyToExprPath + val SecretsContext.AWS_BASEURL by SecretsContext.propertyToExprPath + val SecretsContext.AWS_REGION by SecretsContext.propertyToExprPath + val SecretsContext.AWS_BUCKET by SecretsContext.propertyToExprPath +} + + +/// EXTENSIONS + +val Contexts.matrix get() = MatrixContext + +val GitHubContext.isAnimekoRepository + get() = $$"""$$event_name != 'pull_request' && $$repository == 'open-ani/animeko' """ + +val MatrixContext.isX64 get() = arch.eq(Arch.X64) +val MatrixContext.isAArch64 get() = arch.eq(Arch.AARCH64) + +val MatrixContext.isMacOS get() = os.eq(OS.MACOS) +val MatrixContext.isWindows get() = os.eq(OS.WINDOWS) +val MatrixContext.isUbuntu get() = os.eq(OS.UBUNTU) +val MatrixContext.isUnix get() = (os.eq(OS.UBUNTU)) or (os.eq(OS.MACOS)) + +val MatrixContext.isMacOSAArch64 get() = (os.eq(OS.MACOS)) and (arch.eq(Arch.AARCH64)) +val MatrixContext.isMacOSX64 get() = (os.eq(OS.MACOS)) and (arch.eq(Arch.X64)) + +// only for highlighting (though this does not work in KT 2.1.0) +fun shell(@Language("shell") command: String) = command + +infix fun String.and(other: String) = "($this) && ($other)" +infix fun String.or(other: String) = "($this) || ($other)" + +// 由于 infix 优先级问题, 这里要求使用传统调用方式. +fun String.eq(other: OS) = this.eq(other.toString()) +fun String.eq(other: String) = "($this == '$other')" +fun String.eq(other: Boolean) = "($this == $other)" +fun String.neq(other: String) = "($this != '$other')" +fun String.neq(other: Boolean) = "($this != $other)" + +operator fun String.not() = "!($this)" + +fun MatrixInstance.toMatrixIncludeMap(): Map { + @Suppress("UNCHECKED_CAST") + val memberProperties = + this::class.memberProperties as Collection> + + return buildMap { + for (property in memberProperties) { + val value = property.get(this@toMatrixIncludeMap) + if (value != null) { + put(property.name, value) + } + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2247cdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +local.properties +.kotlin/ +**/test-sandbox/ + +# Local source files +**/local.kt + +# Android build outputs +app/android/release +app/android/debug + +### IntelliJ IDEA ### +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ +.run/Preview*.xml + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store diff --git a/.idea/copyright/openani.xml b/.idea/copyright/openani.xml new file mode 100644 index 0000000..675b6f6 --- /dev/null +++ b/.idea/copyright/openani.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..625c85f --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 6d9ef3f..9bf0253 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,14 @@ # mediamp -Kotlin-first media player for Jetpack Compose and Compose Multiplatform + +Mediamp is a Kotlin-first media player for Jetpack Compose and Compose Multiplatform. It is an +wrapper over popular media player libraries like ExoPlayer on each platform. + +Supported targets and backends: + +|:---:|--------|------| +|Platform| Architecture(s) | Implementation | +| Android | x86_64, arm64-v8a, armeabi-v7a | ExoPlayer | +| Windows | x86_64 | VLC | +| macOS | x86_64, AArch64 | VLC | + +Platforms that are not listed above are not supported yet. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..0d3e4e6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +buildscript { + repositories { + gradlePluginPortal() + mavenCentral() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +plugins { +// alias(libs.plugins.kotlin.multiplatform) apply false +// alias(libs.plugins.kotlin.android) apply false +// alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.plugin.serialization) apply false +// alias(libs.plugins.kotlin.plugin.compose) apply false +// id("org.jetbrains.kotlinx.atomicfu") version libs.versions.atomicfu apply false +// alias(libs.plugins.kotlinx.atomicfu) apply false +// alias(libs.plugins.compose) apply false +// alias(libs.plugins.android.library) apply false +// alias(libs.plugins.android.application) apply false + alias(libs.plugins.antlr.kotlin) apply false + idea +} + +allprojects { + group = "org.openani.mediamp" + version = properties["version.name"].toString() + + repositories { + mavenCentral() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + maven("https://androidx.dev/storage/compose-compiler/repository/") + } +} + +extensions.findByName("buildScan")?.withGroovyBuilder { + setProperty("termsOfServiceUrl", "https://gradle.com/terms-of-service") + setProperty("termsOfServiceAgree", "yes") +} + +subprojects { + afterEvaluate { +// configureKotlinOptIns() +// configureKotlinTestSettings() +// configureEncoding() +// configureJvmTarget() + } +} + +idea { + module { + excludeDirs.add(file(".kotlin")) + } +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..84256e4 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + google() + gradlePluginPortal() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") // Compose Multiplatform pre-release versions +} + +kotlin { + jvmToolchain { + this.languageVersion = + JavaLanguageVersion.of( + (project.findProperty("jvm.toolchain.version")?.toString() ?: "21").toIntOrNull() + ?: error("jvm.toolchain.version must be an integer, check your configuration!"), + ) + } + compilerOptions { + optIn.add("org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi") + } +} + +dependencies { + api(gradleApi()) + api(gradleKotlinDsl()) + + api(libs.kotlin.gradle.plugin) { + exclude("org.jetbrains.kotlin", "kotlin-stdlib") + exclude("org.jetbrains.kotlin", "kotlin-stdlib-common") + exclude("org.jetbrains.kotlin", "kotlin-reflect") + } + + api(libs.android.gradle.plugin) + api(libs.android.application.gradle.plugin) + api(libs.android.library.gradle.plugin) + api(libs.compose.multiplatfrom.gradle.plugin) + api(libs.kotlin.compose.compiler.gradle.plugin) + implementation(kotlin("script-runtime")) +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000..0a1e4df --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,9 @@ +rootProject.name = "mediamp" + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt new file mode 100644 index 0000000..3c9806a --- /dev/null +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -0,0 +1,19 @@ +/* + * Ani + * Copyright (C) 2022-2024 Him188 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +@file:Suppress("ObjectPropertyName", "MemberVisibilityCanBePrivate", "ConstPropertyName") diff --git a/buildSrc/src/main/kotlin/android.kt b/buildSrc/src/main/kotlin/android.kt new file mode 100644 index 0000000..83a32e4 --- /dev/null +++ b/buildSrc/src/main/kotlin/android.kt @@ -0,0 +1,33 @@ +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Project + + +fun Project.configureAndroidLibrary( + namespace: String, + composeCompilerVersion: String, +) { + check(namespace.startsWith("me.him188.ani")) + (extensions.getByName("android") as LibraryExtension).apply { + this.namespace = namespace + compileSdk = getIntProperty("android.compile.sdk") + defaultConfig { + minSdk = getIntProperty("android.min.sdk") + buildConfigField("String", "VERSION_NAME", "\"${getProperty("version.name")}\"") + } + buildTypes.getByName("release") { + isMinifyEnabled = true + isShrinkResources = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + *sharedAndroidProguardRules(), + ) + } + buildFeatures { + compose = true + buildConfig = true + } + composeOptions { + kotlinCompilerExtensionVersion = composeCompilerVersion + } + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ani-lib-targets.gradle.kts b/buildSrc/src/main/kotlin/ani-lib-targets.gradle.kts new file mode 100644 index 0000000..8033e19 --- /dev/null +++ b/buildSrc/src/main/kotlin/ani-lib-targets.gradle.kts @@ -0,0 +1,6 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.kotlinExtension + +(kotlinExtension as? KotlinMultiplatformExtension)?.run { + jvm() +} diff --git a/buildSrc/src/main/kotlin/build.kt b/buildSrc/src/main/kotlin/build.kt new file mode 100644 index 0000000..f4e1389 --- /dev/null +++ b/buildSrc/src/main/kotlin/build.kt @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.Project +import org.gradle.api.artifacts.VersionCatalog +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.jvm.toolchain.JvmVendorSpec +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.extra +import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.get +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.kotlin +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinCommonCompilerOptions +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.dsl.kotlinExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget +import org.jetbrains.kotlin.gradle.plugin.KotlinTargetsContainer +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmAndroidCompilation +import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import java.io.File + +fun Project.sharedAndroidProguardRules(): Array { + val dir = rootProject.projectDir + return listOf( + dir.resolve("proguard-rules.pro"), + dir.resolve("proguard-rules-keep-names.pro"), + ).filter { + it.exists() + }.toTypedArray() +} + +val testOptInAnnotations = arrayOf( + "kotlin.ExperimentalUnsignedTypes", + "kotlin.time.ExperimentalTime", + "io.ktor.util.KtorExperimentalAPI", + "kotlin.io.path.ExperimentalPathApi", + "kotlinx.coroutines.ExperimentalCoroutinesApi", + "kotlinx.serialization.ExperimentalSerializationApi", + "me.him188.ani.utils.platform.annotations.TestOnly", + "androidx.compose.ui.test.ExperimentalTestApi", +) + +val optInAnnotations = arrayOf( + "kotlin.contracts.ExperimentalContracts", + "kotlin.experimental.ExperimentalTypeInference", + "kotlinx.serialization.ExperimentalSerializationApi", + "kotlinx.coroutines.ExperimentalCoroutinesApi", + "kotlinx.coroutines.FlowPreview", + "androidx.compose.foundation.layout.ExperimentalLayoutApi", + "androidx.compose.foundation.ExperimentalFoundationApi", + "androidx.compose.material3.ExperimentalMaterial3Api", + "androidx.compose.ui.ExperimentalComposeUiApi", + "org.jetbrains.compose.resources.ExperimentalResourceApi", + "kotlin.ExperimentalStdlibApi", + "androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi", + "androidx.compose.animation.ExperimentalSharedTransitionApi", + "androidx.paging.ExperimentalPagingApi", +) + +val testLanguageFeatures: List = listOf( +// "ContextReceivers" // causes segfault on ios +) + +fun Project.configureKotlinOptIns() { + val sourceSets = kotlinSourceSets ?: return + sourceSets.all { + configureKotlinOptIns() + } + + val libs = versionCatalogLibs() + val (major, minor) = libs["kotlin"].split('.') + val kotlinVersion = KotlinVersion.valueOf("KOTLIN_${major}_${minor}") + + val options = kotlinCommonCompilerOptions() + options.apply { + languageVersion.set(kotlinVersion) + } + // ksp task extends KotlinCompile + project.tasks.withType(KotlinCompile::class.java) { + @Suppress("MISSING_DEPENDENCY_SUPERCLASS_IN_TYPE_ARGUMENT") + compilerOptions.languageVersion.set(kotlinVersion) + } + + for (name in testLanguageFeatures) { + enableLanguageFeatureForTestSourceSets(name) + } +} + +private fun Project.versionCatalogLibs(): VersionCatalog = + project.extensions.getByType().named("libs") + +private operator fun VersionCatalog.get(name: String): String = findVersion(name).get().displayName + +private fun Project.kotlinCommonCompilerOptions(): KotlinCommonCompilerOptions = when (val ext = kotlinExtension) { + is KotlinJvmProjectExtension -> ext.compilerOptions + is KotlinAndroidProjectExtension -> ext.compilerOptions + is KotlinMultiplatformExtension -> ext.compilerOptions + else -> error("Unsupported kotlinExtension: ${ext::class}") +} + +fun KotlinSourceSet.configureKotlinOptIns() { + languageSettings.progressiveMode = true + optInAnnotations.forEach { a -> + languageSettings.optIn(a) + } + if (name.contains("test", ignoreCase = true)) { + testOptInAnnotations.forEach { a -> + languageSettings.optIn(a) + } + } +} + +val Project.DEFAULT_JVM_TOOLCHAIN_VENDOR + get() = getPropertyOrNull("jvm.toolchain.vendor")?.let { JvmVendorSpec.matching(it) } + +private fun Project.getProjectPreferredJvmTargetVersion() = extra.runCatching { get("ani.jvm.target") }.fold( + onSuccess = { JavaVersion.toVersion(it.toString()) }, + onFailure = { JavaVersion.toVersion(getPropertyOrNull("jvm.toolchain.version")?.toInt() ?: 17) }, +) + +fun Project.configureJvmTarget() { + val ver = getProjectPreferredJvmTargetVersion() + logger.info("JVM target for project ${this.path} is: $ver") + + // 我也不知道到底设置谁就够了, 反正全都设置了 + + tasks.withType(KotlinJvmCompile::class.java) { + compilerOptions.jvmTarget.set(JvmTarget.fromTarget(ver.toString())) + } + + tasks.withType(KotlinCompile::class.java) { + compilerOptions.jvmTarget.set(JvmTarget.fromTarget(ver.toString())) + } + + tasks.withType(JavaCompile::class.java) { + sourceCompatibility = ver.toString() + targetCompatibility = ver.toString() + } + + extensions.findByType(KotlinProjectExtension::class)?.apply { + jvmToolchain { + vendor.set(DEFAULT_JVM_TOOLCHAIN_VENDOR) + languageVersion.set(JavaLanguageVersion.of(ver.getMajorVersion())) + } + } + + extensions.findByType(JavaPluginExtension::class)?.apply { + toolchain { + vendor.set(DEFAULT_JVM_TOOLCHAIN_VENDOR) + languageVersion.set(JavaLanguageVersion.of(ver.getMajorVersion())) + sourceCompatibility = ver + targetCompatibility = ver + } + } + + withKotlinTargets { + it.compilations.all { + compileTaskProvider.configure { + compilerOptions { + freeCompilerArgs.add("-Xdont-warn-on-error-suppression") + } + } + if (this is KotlinJvmAndroidCompilation) { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.fromTarget(ver.toString())) + } + } + } + } + } + + extensions.findByType(JavaPluginExtension::class.java)?.run { + sourceCompatibility = ver + targetCompatibility = ver + } + + extensions.findByType(CommonExtension::class)?.apply { + compileOptions { + sourceCompatibility = ver + targetCompatibility = ver + } + } +} + +fun Project.configureEncoding() { + tasks.withType(JavaCompile::class.java) { + options.encoding = "UTF8" + } +} + +const val JUNIT_VERSION = "5.7.2" + +fun Project.configureKotlinTestSettings() { + tasks.withType(Test::class) { + useJUnitPlatform() + } + + allKotlinTargets().all { + if (this !is KotlinJvmTarget) return@all + this.testRuns["test"].executionTask.configure { useJUnitPlatform() } + } + + val b = "Auto-set for project '${project.path}'. (configureKotlinTestSettings)" + when { + isKotlinJvmProject -> { + dependencies { + "testImplementation"(kotlin("test-junit5"))?.because(b) + + "testApi"("org.junit.jupiter:junit-jupiter-api:$JUNIT_VERSION")?.because(b) + "testRuntimeOnly"("org.junit.jupiter:junit-jupiter-engine:${JUNIT_VERSION}")?.because(b) + } + } + + isKotlinMpp -> { + kotlinSourceSets?.all { + val sourceSet = this + + val target = allKotlinTargets() + .find { it.name == sourceSet.name.substringBeforeLast("Main").substringBeforeLast("Test") } + + if (sourceSet.name.contains("test", ignoreCase = true)) { + when { + target?.platformType == KotlinPlatformType.jvm -> { + // For android, this should be done differently. See Android.kt + sourceSet.configureJvmTest(b) + } + + sourceSet.name == "commonTest" -> { + sourceSet.dependencies { + implementation(kotlin("test"))?.because(b) + implementation(kotlin("test-annotations-common"))?.because(b) + } + } + + target?.platformType == KotlinPlatformType.androidJvm -> { + // Android uses JUnit4 + sourceSet.dependencies { + implementation("junit:junit:4.13")?.because(b) + } + } + } + } + } + } + } +} + +fun KotlinSourceSet.configureJvmTest(because: String) { + dependencies { + implementation(kotlin("test-junit5"))?.because(because) + + implementation("org.junit.jupiter:junit-jupiter-api:${JUNIT_VERSION}")?.because(because) + runtimeOnly("org.junit.jupiter:junit-jupiter-engine:${JUNIT_VERSION}")?.because(because) + } +} + + +fun Project.withKotlinTargets(fn: (KotlinTarget) -> Unit) { + extensions.findByType(KotlinTargetsContainer::class.java)?.let { kotlinExtension -> + // find all compilations given sourceSet belongs to + kotlinExtension.targets + .all { + fn(this) + } + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/compose.kt b/buildSrc/src/main/kotlin/compose.kt new file mode 100644 index 0000000..6c19c10 --- /dev/null +++ b/buildSrc/src/main/kotlin/compose.kt @@ -0,0 +1,4 @@ +import org.gradle.api.Project + +val Project.composeOrNull + get() = (this as org.gradle.api.plugins.ExtensionAware).extensions.findByName("compose") as org.jetbrains.compose.ComposeExtension? diff --git a/buildSrc/src/main/kotlin/flatten-source-sets.gradle.kts b/buildSrc/src/main/kotlin/flatten-source-sets.gradle.kts new file mode 100644 index 0000000..4c1c09a --- /dev/null +++ b/buildSrc/src/main/kotlin/flatten-source-sets.gradle.kts @@ -0,0 +1,78 @@ +import com.android.build.api.dsl.CommonExtension + +/** + * 扁平化源集目录结构, 减少文件树层级 by 2 + * + * 变化: + * ``` + * src/${targetName}Main/kotlin -> ${targetName}Main + * src/${targetName}Main/resources -> ${targetName}Resources + * src/${targetName}Test/kotlin -> ${targetName}Test + * src/${targetName}Test/resources -> ${targetName}TestResources + * ``` + * + * `${targetName}` 可以是 `common`, `android` `desktop` 等. + */ +fun configureFlattenSourceSets() { + val flatten = extra.runCatching { get("flatten.sourceset") }.getOrNull()?.toString()?.toBoolean() ?: true + if (!flatten) return + sourceSets { + findByName("main")?.apply { + resources.srcDirs(listOf(projectDir.resolve("resources"))) + java.srcDirs(listOf(projectDir.resolve("src"))) + } + findByName("test")?.apply { + resources.srcDirs(listOf(projectDir.resolve("testResources"))) + java.srcDirs(listOf(projectDir.resolve("test"))) + } + } +} + +/** + * 扁平化多平台项目的源集目录结构, 减少文件树层级 by 2 + * + * 变化: + * ``` + * src/androidMain/res -> androidRes + * src/androidMain/assets -> androidAssets + * src/androidMain/aidl -> androidAidl + * src/${targetName}Main/kotlin -> ${targetName}Main + * src/${targetName}Main/resources -> ${targetName}Resources + * src/${targetName}Test/kotlin -> ${targetName}Test + * src/${targetName}Test/resources -> ${targetName}TestResources + * ``` + * + * `${targetName}` 可以是 `common`, `android` `desktop` 等. + */ +fun Project.configureFlattenMppSourceSets() { + kotlinSourceSets?.invoke { + fun setForTarget( + targetName: String, + ) { + findByName("${targetName}Main")?.apply { + resources.srcDirs(listOf(projectDir.resolve("${targetName}Resources"))) + kotlin.srcDirs(listOf(projectDir.resolve("${targetName}Main"), projectDir.resolve(targetName))) + } + findByName("${targetName}Test")?.apply { + resources.srcDirs(listOf(projectDir.resolve("${targetName}TestResources"))) + kotlin.srcDirs(listOf(projectDir.resolve("${targetName}Test"))) + } + } + + setForTarget("common") + + allKotlinTargets().all { + val targetName = name + setForTarget(targetName) + } + } + + extensions.findByType(CommonExtension::class)?.run { + this.sourceSets["main"].res.srcDirs(projectDir.resolve("androidRes")) + this.sourceSets["main"].assets.srcDirs(projectDir.resolve("androidAssets")) + this.sourceSets["main"].aidl.srcDirs(projectDir.resolve("androidAidl")) + } +} + +configureFlattenSourceSets() +configureFlattenMppSourceSets() \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/mpp-lib-targets.gradle.kts b/buildSrc/src/main/kotlin/mpp-lib-targets.gradle.kts new file mode 100644 index 0000000..af8e638 --- /dev/null +++ b/buildSrc/src/main/kotlin/mpp-lib-targets.gradle.kts @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +@file:OptIn(ExperimentalKotlinGradlePluginApi::class, ExperimentalComposeLibrary::class) + +import com.android.build.api.dsl.LibraryExtension +import org.jetbrains.compose.ComposeExtension +import org.jetbrains.compose.ComposePlugin +import org.jetbrains.compose.ExperimentalComposeLibrary +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask + +/* + * 配置 JVM + Android 的 compose 项目. 默认不会配置 resources. + * + * 该插件必须在 kotlin, compose, android 之后引入. + * + * 如果开了 android, 就会配置 desktop + android, 否则只配置 jvm. + */ + +val android = extensions.findByType(LibraryExtension::class) +val composeExtension = extensions.findByType(ComposeExtension::class) + +configure { + /** + * 平台架构: + * ``` + * common + * - jvm (可访问 JDK, 但不能使用 Android SDK 没有的 API) + * - android (可访问 Android SDK) + * - desktop (可访问 JDK) + * - native + * - apple + * - ios + * - iosArm64 + * - iosSimulatorArm64 TODO + * ``` + * + * `native - apple - ios` 的架构是为了契合 Kotlin 官方推荐的默认架构. 以后如果万一要添加其他平台, 可方便添加. + */ + if (project.enableIos) { + iosArm64() + iosSimulatorArm64() // to run tests + // no x86 + } + if (android != null) { + jvm("desktop") + androidTarget { + instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.instrumentedTest) + } + + applyDefaultHierarchyTemplate { + common { + group("jvm") { + withJvm() + withAndroidTarget() + } + group("skiko") { + withJvm() + withNative() + } + } + } + + } else { + jvm() + + applyDefaultHierarchyTemplate() + } + + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + sourceSets.commonMain.dependencies { + // 添加常用依赖 + if (composeExtension != null) { + val compose = ComposePlugin.Dependencies(project) + // Compose + api(compose.foundation) + api(compose.animation) + api(compose.ui) + api(compose.material3) + api(compose.materialIconsExtended) + api(compose.runtime) + } + } + sourceSets.commonTest.dependencies { + // https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html#writing-and-running-tests-with-compose-multiplatform + if (composeExtension != null) { + val compose = ComposePlugin.Dependencies(project) + implementation(compose.uiTest) + } + } + + if (composeExtension != null) { + sourceSets.getByName("desktopMain").dependencies { + val compose = ComposePlugin.Dependencies(project) + implementation(compose.desktop.uiTestJUnit4) + } + } + + if (android != null && composeExtension != null) { + val composeVersion = versionCatalogs.named("libs").findVersion("jetpack-compose").get() + listOf( + sourceSets.getByName("androidInstrumentedTest"), + sourceSets.getByName("androidUnitTest"), + ).forEach { sourceSet -> + sourceSet.dependencies { + // https://developer.android.com/develop/ui/compose/testing#setup + implementation("androidx.compose.ui:ui-test-junit4-android:${composeVersion}") + implementation("androidx.compose.ui:ui-test-manifest:${composeVersion}") + } + } + + dependencies { + "debugImplementation"("androidx.compose.ui:ui-test-manifest:${composeVersion}") + } + } +} + +if (project.findProperty("mediamp.enable.ios")?.toString()?.toBoolean() != false) { + // ios testing workaround + // https://developer.squareup.com/blog/kotlin-multiplatform-shared-test-resources/ + tasks.register("copyiOSTestResources") { + from("src/commonTest/resources") + into("build/bin/iosSimulatorArm64/debugTest/resources") + } + tasks.named("iosSimulatorArm64Test") { + dependsOn("copyiOSTestResources") + } +} + +if (android != null) { + configure { + sourceSets { + // Workaround for MPP compose bug, don't change + removeIf { it.name == "androidAndroidTestRelease" } + removeIf { it.name == "androidTestFixtures" } + removeIf { it.name == "androidTestFixturesDebug" } + removeIf { it.name == "androidTestFixturesRelease" } + } + } + if (composeExtension != null) { + tasks.named("generateComposeResClass") { + dependsOn("generateResourceAccessorsForAndroidUnitTest") + } + tasks.withType(KotlinCompilationTask::class) { + dependsOn(tasks.matching { it.name == "generateComposeResClass" }) + dependsOn(tasks.matching { it.name == "generateResourceAccessorsForAndroidRelease" }) + dependsOn(tasks.matching { it.name == "generateResourceAccessorsForAndroidUnitTest" }) + dependsOn(tasks.matching { it.name == "generateResourceAccessorsForAndroidUnitTestRelease" }) + dependsOn(tasks.matching { it.name == "generateResourceAccessorsForAndroidUnitTestDebug" }) + dependsOn(tasks.matching { it.name == "generateResourceAccessorsForAndroidDebug" }) + } + } + + android.apply { + compileSdk = getIntProperty("android.compile.sdk") + defaultConfig { + minSdk = getIntProperty("android.min.sdk") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes.getByName("release") { + isMinifyEnabled = false // shared 不能 minify, 否则构建 app 会失败 + isShrinkResources = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + *sharedAndroidProguardRules(), + ) + } + buildFeatures { + if (composeExtension != null) { + compose = true + } + } + } +} diff --git a/buildSrc/src/main/kotlin/mpp.kt b/buildSrc/src/main/kotlin/mpp.kt new file mode 100644 index 0000000..d5e2fbf --- /dev/null +++ b/buildSrc/src/main/kotlin/mpp.kt @@ -0,0 +1,3 @@ +import org.gradle.api.attributes.Attribute + +val AniTarget = Attribute.of("AniTarget", String::class.java) diff --git a/buildSrc/src/main/kotlin/optIns.kt b/buildSrc/src/main/kotlin/optIns.kt new file mode 100644 index 0000000..f38895a --- /dev/null +++ b/buildSrc/src/main/kotlin/optIns.kt @@ -0,0 +1,79 @@ +/* + * Ani + * Copyright (C) 2022-2024 Him188 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet + +/* + * Ani + * Copyright (C) 2022-2024 Him188 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + + +fun Project.optInForAllSourceSets(qualifiedClassname: String) { + kotlinSourceSets!!.all { + languageSettings { + optIn(qualifiedClassname) + } + } +} + +fun Project.optInForTestSourceSets(qualifiedClassname: String) { + kotlinSourceSets!!.matching { it.name.contains("test", ignoreCase = true) }.all { + languageSettings { + optIn(qualifiedClassname) + } + } +} + +fun Project.enableLanguageFeatureForAllSourceSets(qualifiedClassname: String) { + kotlinSourceSets!!.all { + languageSettings { + this.enableLanguageFeature(qualifiedClassname) + } + } +} + +fun Project.enableLanguageFeatureForTestSourceSets(name: String) { + allTestSourceSets { + languageSettings { + this.enableLanguageFeature(name) + } + } +} + +fun Project.allTestSourceSets(action: KotlinSourceSet.() -> Unit) { + kotlinSourceSets!!.all { + if (this.name.contains("test", ignoreCase = true)) { + action() + } + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/os.kt b/buildSrc/src/main/kotlin/os.kt new file mode 100644 index 0000000..2135dd5 --- /dev/null +++ b/buildSrc/src/main/kotlin/os.kt @@ -0,0 +1,42 @@ +import java.util.Locale + +// get current os +enum class Os { + Windows, + MacOS, + Linux, + Unknown +} + +fun getOs(): Os { + val os = System.getProperty("os.name").lowercase(Locale.getDefault()) + return when { + os.contains("win") -> Os.Windows + os.contains("mac") -> Os.MacOS + os.contains("nux") -> Os.Linux + else -> Os.Unknown + } +} + +enum class Arch { + X86_64, + AARCH64, +} + +fun getArch(): Arch { + val arch = System.getProperty("os.arch").lowercase(Locale.getDefault()) + return when { + arch.contains("x86_64") -> Arch.X86_64 + arch.contains("aarch64") || arch.contains("arm") -> Arch.AARCH64 + else -> throw UnsupportedOperationException("Unknown architecture: $arch") + } +} + +fun getOsTriple(): String { + return when (getOs()) { + Os.Windows -> "windows-x64" + Os.MacOS -> if (getArch() == Arch.AARCH64) "macos-arm64" else "macos-x64" + Os.Linux -> "linux-x64" + Os.Unknown -> throw UnsupportedOperationException("Unknown OS") + } +} diff --git a/buildSrc/src/main/kotlin/properties.kt b/buildSrc/src/main/kotlin/properties.kt new file mode 100644 index 0000000..dd6e26b --- /dev/null +++ b/buildSrc/src/main/kotlin/properties.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +import org.gradle.api.Project +import java.io.File +import java.util.Properties + +fun Project.getProperty(name: String) = + getPropertyOrNull(name) ?: error("Property $name not found") + +fun Project.getPropertyOrNull(name: String) = + getLocalProperty(name) + ?: System.getProperty(name) + ?: System.getenv(name) + ?: findProperty(name)?.toString() + ?: properties[name]?.toString() + ?: extensions.extraProperties.runCatching { get(name).toString() }.getOrNull() + + +val Project.localPropertiesFile: File get() = project.rootProject.file("local.properties") + +fun Project.getLocalProperty(key: String): String? { + return if (localPropertiesFile.exists()) { + val properties = Properties() + localPropertiesFile.inputStream().buffered().use { input -> + properties.load(input) + } + properties.getProperty(key) + } else { + localPropertiesFile.createNewFile() + null + } +} + + +fun Project.getIntProperty(name: String) = getProperty(name).toInt() + +val Project.enableAnitorrent + get() = (getPropertyOrNull("ani.enable.anitorrent") ?: "false").toBooleanStrict() + +val Project.enableIos + get() = getPropertyOrNull("ani.enable.ios")?.toBooleanStrict() ?: true diff --git a/buildSrc/src/main/kotlin/sourceSets.kt b/buildSrc/src/main/kotlin/sourceSets.kt new file mode 100644 index 0000000..070e33a --- /dev/null +++ b/buildSrc/src/main/kotlin/sourceSets.kt @@ -0,0 +1,58 @@ +/* + * Ani + * Copyright (C) 2022-2024 Him188 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import org.gradle.api.NamedDomainObjectCollection +import org.gradle.api.NamedDomainObjectList +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget + + +inline fun Any?.safeAs(): T? { + return this as? T +} + +val Project.kotlinSourceSets get() = extensions.findByName("kotlin").safeAs()?.sourceSets + +fun Project.allKotlinTargets(): NamedDomainObjectCollection { + return extensions.findByName("kotlin")?.safeAs>() + ?.target?.let { namedDomainObjectListOf(it) } + ?: extensions.findByName("kotlin")?.safeAs()?.targets + ?: namedDomainObjectListOf() +} + +private inline fun Project.namedDomainObjectListOf(vararg values: T): NamedDomainObjectList { + return objects.namedDomainObjectList(T::class.java).apply { addAll(values) } +} + +val Project.isKotlinJvmProject: Boolean get() = extensions.findByName("kotlin") is KotlinJvmProjectExtension +val Project.isKotlinMpp: Boolean get() = extensions.findByName("kotlin") is KotlinMultiplatformExtension + + +//val ANI_DISAMBIGUATION: Attribute = Attribute.of("me.him188.ani.disambiguation", String::class.java) + +//inline fun org.gradle.api.NamedDomainObjectProvider.dependencies(crossinline block: context(KotlinSourceSet) KotlinDependencyHandler.() -> Unit) { +// configure { +// dependencies { +// block(this) +// } +// } +//} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/utils.kt b/buildSrc/src/main/kotlin/utils.kt new file mode 100644 index 0000000..3a2e766 --- /dev/null +++ b/buildSrc/src/main/kotlin/utils.kt @@ -0,0 +1,56 @@ +/* + * Ani + * Copyright (C) 2022-2024 Him188 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.tasks.TaskContainer +import org.gradle.kotlin.dsl.ExistingDomainObjectDelegate +import org.gradle.kotlin.dsl.RegisteringDomainObjectDelegateProviderWithTypeAndAction +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract +import kotlin.reflect.KProperty + + +@Suppress( + "INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", +) +@PublishedApi +internal operator fun RegisteringDomainObjectDelegateProviderWithTypeAndAction.provideDelegate( + receiver: Any?, + property: KProperty<*>, +) = ExistingDomainObjectDelegate.of( + delegateProvider.register(property.name, type.java, action), +) + +@PublishedApi +internal val Project.sourceSets: org.gradle.api.tasks.SourceSetContainer + get() = + (this as org.gradle.api.plugins.ExtensionAware).extensions.getByName("sourceSets") as org.gradle.api.tasks.SourceSetContainer + +@Suppress( + "INVISIBLE_MEMBER", "INVISIBLE_REFERENCE", +) +@PublishedApi +internal operator fun ExistingDomainObjectDelegate.getValue(receiver: Any?, property: KProperty<*>): T = + delegate + +@OptIn(ExperimentalContracts::class) +inline fun Any?.cast(): T { + contract { returns() implies (this@cast is T) } + return this as T +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..687e40d --- /dev/null +++ b/gradle.properties @@ -0,0 +1,30 @@ +# +# Copyright (C) 2024 OpenAni and contributors. +# +# \u6B64\u6E90\u4EE3\u7801\u7684\u4F7F\u7528\u53D7 GNU AFFERO GENERAL PUBLIC LICENSE version 3 \u8BB8\u53EF\u8BC1\u7684\u7EA6\u675F, \u53EF\u4EE5\u5728\u4EE5\u4E0B\u94FE\u63A5\u627E\u5230\u8BE5\u8BB8\u53EF\u8BC1. +# Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. +# +# https://github.com/open-ani/ani/blob/main/LICENSE +# +kotlin.code.style=official +android.useAndroidX=true +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4g" +# Project version, automatically updated by task :ci-helper:updateDevVersionNameFromGit +version.name=0.1.0 +# package.version must be major.minor.patch without meta +package.version=1.0.0 +kotlin.mpp.androidSourceSetLayoutVersion=2 +org.gradle.caching=true +org.gradle.configuration-cache=true +# major(1)-minor(pad to 2)-patch(1)-meta(1) +android.version.code=30200 +android.compile.sdk=34 +android.min.sdk=26 +# JBR must have JCEF bundled +jvm.toolchain.vendor=jetbrains +jvm.toolchain.version=17 +# Workaround for https://youtrack.jetbrains.com/issue/KT-32476/Native-dependency-type-diagnostics-is-too-strict +kotlin.native.ignoreIncorrectDependencies=true +kotlin.mpp.enableCInteropCommonization=true +kotlin.incremental.native=true +kotlin.apple.xcodeCompatibility.nowarn=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..297c816 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,149 @@ +[versions] +kotlin = "2.1.0" +# kotlinx +coroutines = "1.9.0" # https://github.com/Kotlin/kotlinx.coroutines/releases +serialization = "1.7.3" # https://github.com/Kotlin/kotlinx.serialization/releases +datetime = "0.6.1" # https://github.com/Kotlin/kotlinx-datetime/releases +atomicfu = "0.26.1" # https://github.com/Kotlin/kotlinx-atomicfu/releases +kotlinx-io = "0.6.0" # https://github.com/Kotlin/kotlinx-io/releases +kotlinx-collections-immutable = "0.3.8" # https://github.com/Kotlin/kotlinx.collections.immutable/releases +# +jna = "5.13.0" # 不要轻易改这个版本, 它可能导致 VLC 兼容性问题 + +android-gradle-plugin = "8.5.2" +ksp = "2.1.0-1.0.29" # https://github.com/google/ksp/releases +room = "2.7.0-alpha12" # https://developer.android.com/jetpack/androidx/releases/room#declaring_dependencies +snakeyaml = "2.2" +sqlite = "2.5.0-alpha12" +constraintlayout-compose = "0.4.0" +antlr-kotlin = "1.0.0" +oshai-kotlin-logging = "7.0.0" # Only for native. On JVM we use slf4j directly. +ipaddress-parser = "5.5.1" +androidx-annotation = "1.9.1" +androidx-media3 = "1.4.1" +androidx-lifecycle = "2.8.7" +paging = "3.3.5" # https://developer.android.com/jetpack/androidx/releases/paging + +mockito = "5.12.0" +mockito-kotlin = "5.4.0" + +# CI helper +aws = "2.25.49" + +# Compose +# https://developer.android.com/jetpack/androidx/releases/compose-material3 +compose-material3 = "1.3.1" +jetpack-compose = "1.7.6" +# https://github.com/JetBrains/compose-multiplatform/releases +# https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-compatibility-and-versioning.html#use-a-developer-version-of-compose-multiplatform-compiler +compose-multiplatform = "1.7.1" +compose-lifecycle = "2.8.4" +compose-navigation = "2.8.0-alpha10" +compose-material3-adaptive = "1.0.1" + +# https://maven.pkg.jetbrains.space/public/p/compose/dev/org/jetbrains/compose/compiler/compiler/ +#compose-multiplatform-compiler = "1.5.11-kt-2.0.0-RC1" # used by buildscript, don't remove +stately-common = "2.0.7" +vlcj = "4.8.2" + + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-plugin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinx-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } +compose = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } +android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +room = { id = "androidx.room", version.ref = "room" } +antlr-kotlin = { id = "com.strumenta.antlr-kotlin", version.ref = "antlr-kotlin" } + +[libraries] +# Build +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } +android-library-gradle-plugin = { module = "com.android.library:com.android.library.gradle.plugin", version.ref = "android-gradle-plugin" } +android-application-gradle-plugin = { module = "com.android.application:com.android.application.gradle.plugin", version.ref = "android-gradle-plugin" } + +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +atomicfu-gradle-plugin = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", version.ref = "atomicfu" } +compose-multiplatfrom-gradle-plugin = { module = "org.jetbrains.compose:org.jetbrains.compose.gradle.plugin", version.ref = "compose-multiplatform" } +kotlin-compose-compiler-gradle-plugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } +#compose-multiplatfrom-compiler-plugin = { module = "org.jetbrains.compose:org.jetbrains.compose.compiler", version.ref = "compose-multiplatform-compiler" } + +# Kotlinx +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } +kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } +kotlinx-io-bytestring = { module = "org.jetbrains.kotlinx:kotlinx-io-bytestring", version.ref = "kotlinx-io" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } +kotlinx-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-debug", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" } +kotlinx-serialization-json-io = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-io", version.ref = "serialization" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } +kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } +#kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" } + +jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } + +# Compose Multiplatform +compose-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "compose-lifecycle" } +compose-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "compose-lifecycle" } +compose-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "compose-navigation" } +compose-navigation-runtime = { module = "org.jetbrains.androidx.navigation:navigation-runtime", version.ref = "compose-navigation" } +compose-material3-adaptive-core = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "compose-material3-adaptive" } +compose-material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "compose-material3-adaptive" } +compose-material3-adaptive-navigation0 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "compose-material3-adaptive" } +compose-material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "compose-multiplatform" } + +# Android-only libraries +# Each library has its own version, so we don't use `Versions` here. +androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.9.2" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version = "1.9.0" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.0" } +androidx-material = { module = "com.google.android.material:material", version = "1.12.0" } +androidx-material3-window-size-class0 = { module = "androidx.compose.material3:material3-window-size-class", version = "1.2.1" } +androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } +androidx-media = { module = "androidx.media:media", version = "1.7.0" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "androidx-lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-collection = { module = "androidx.collection:collection", version = "1.4.5" } + +#androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version = "2.7.6" } +slf4j-android = { module = "uk.uuid.slf4j:slf4j-android", version = "2.0.7-0" } + +# Android unit test +mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } + +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } +androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } +androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } + +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "jetpack-compose" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "jetpack-compose" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding", version.ref = "jetpack-compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "jetpack-compose" } +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "jetpack-compose" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "jetpack-compose" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } + +stately-common = { module = "co.touchlab:stately-common", version.ref = "stately-common" } + +# VLC +# NOTE: YOU WILL NEVER WANT TO CHANGE VLCJ AND JNA VERSIONS. +# ONLY VLC 3.0.18 IS SUPPORTED. +vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } +vlcj-javafx = { module = "uk.co.caprica:vlcj-javafx", version = "1.2.0" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7454180f2ae8848c63b8b4dea2cb829da983f2fa GIT binary patch literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d2c8141 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,13 @@ +# +# Copyright (C) 2024 OpenAni and contributors. +# +# \u6B64\u6E90\u4EE3\u7801\u7684\u4F7F\u7528\u53D7 GNU AFFERO GENERAL PUBLIC LICENSE version 3 \u8BB8\u53EF\u8BC1\u7684\u7EA6\u675F, \u53EF\u4EE5\u5728\u4EE5\u4E0B\u94FE\u63A5\u627E\u5230\u8BE5\u8BB8\u53EF\u8BC1. +# Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. +# +# https://github.com/open-ani/ani/blob/main/LICENSE +# +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mediamp-api/build.gradle.kts b/mediamp-api/build.gradle.kts new file mode 100644 index 0000000..c733ca0 --- /dev/null +++ b/mediamp-api/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + kotlin("plugin.compose") + id("org.jetbrains.compose") + + `mpp-lib-targets` + kotlin("plugin.serialization") +} + +kotlin { + sourceSets.commonMain.dependencies { + } + sourceSets.commonTest.dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.coroutines.test) + } + sourceSets.androidMain.dependencies { + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.ui.tooling) + } + sourceSets.desktopMain.dependencies { + } +} + +android { + namespace = "org.openani.mediamp.api" +} diff --git a/mediamp-api/src/commonMain/kotlin/HttpStreamingVideoSource.kt b/mediamp-api/src/commonMain/kotlin/HttpStreamingVideoSource.kt new file mode 100644 index 0000000..7b7b46e --- /dev/null +++ b/mediamp-api/src/commonMain/kotlin/HttpStreamingVideoSource.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import me.him188.ani.datasources.api.MediaExtraFiles +import me.him188.ani.datasources.api.matcher.WebVideo +import me.him188.ani.utils.io.SeekableInput +import org.openani.mediamp.data.VideoData +import org.openani.mediamp.data.VideoSource +import kotlin.coroutines.CoroutineContext + +class HttpStreamingVideoSource( + override val uri: String, + private val filename: String, + val webVideo: WebVideo, + override val extraFiles: MediaExtraFiles, +) : VideoSource { + override suspend fun open(): HttpStreamingVideoData { + return HttpStreamingVideoData(uri, filename) + } + + override fun toString(): String { + return "HttpStreamingVideoSource(webVideo=$webVideo, filename='$filename')" + } +} + + +class HttpStreamingVideoData( + val url: String, + override val filename: String +) : VideoData { + override val fileLength: Long = 0 + override val networkStats: Flow = flowOf(VideoData.Stats.Unspecified) + + override val supportsStreaming: Boolean get() = true + + override fun computeHash(): String? = null + + override suspend fun createInput(coroutineContext: CoroutineContext): SeekableInput { + throw UnsupportedOperationException() + } + + override suspend fun close() { + } +} \ No newline at end of file diff --git a/mediamp-api/src/commonMain/kotlin/data/FileVideoSource.kt b/mediamp-api/src/commonMain/kotlin/data/FileVideoSource.kt new file mode 100644 index 0000000..999e8d5 --- /dev/null +++ b/mediamp-api/src/commonMain/kotlin/data/FileVideoSource.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.io.IOException +import me.him188.ani.datasources.api.MediaExtraFiles +import me.him188.ani.utils.coroutines.runInterruptible +import me.him188.ani.utils.io.DigestAlgorithm +import me.him188.ani.utils.io.SeekableInput +import me.him188.ani.utils.io.SystemPath +import me.him188.ani.utils.io.absolutePath +import me.him188.ani.utils.io.bufferedSource +import me.him188.ani.utils.io.exists +import me.him188.ani.utils.io.length +import me.him188.ani.utils.io.name +import me.him188.ani.utils.io.readAndDigest +import me.him188.ani.utils.io.toSeekableInput +import kotlin.coroutines.CoroutineContext + +class FileVideoData( + val file: SystemPath, +) : VideoData { + override val filename: String + get() = file.name + override val fileLength: Long by lazy { file.length() } + + private var hashCache: String? = null + + @OptIn(ExperimentalStdlibApi::class) + @Throws(IOException::class) + override fun computeHash(): String { + var hash = hashCache + if (hash == null) { + hash = file.bufferedSource().use { it.readAndDigest(DigestAlgorithm.MD5).toHexString() } + hashCache = hash + } + return hash + } + + override val networkStats: Flow = MutableStateFlow(VideoData.Stats.Unspecified) + + override suspend fun createInput(coroutineContext: CoroutineContext): SeekableInput = + runInterruptible { file.toSeekableInput() } + + override suspend fun close() { + // no-op + } +} + +class FileVideoSource( + private val file: SystemPath, + override val extraFiles: MediaExtraFiles, +) : VideoSource { + init { + require(file.exists()) { "File does not exist: $file" } + } + + override val uri: String + get() = "file://${file.absolutePath}" + + override suspend fun open(): FileVideoData = FileVideoData(file) + + override fun toString(): String = "FileVideoSource(uri=$uri)" +} diff --git a/mediamp-api/src/commonMain/kotlin/data/VideoData.kt b/mediamp-api/src/commonMain/kotlin/data/VideoData.kt new file mode 100644 index 0000000..59bd06c --- /dev/null +++ b/mediamp-api/src/commonMain/kotlin/data/VideoData.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp.data + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.io.IOException +import me.him188.ani.datasources.api.topic.FileSize +import me.him188.ani.utils.io.SeekableInput +import me.him188.ani.utils.io.emptySeekableInput +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Holds information about a video file. + */ +@Stable +interface VideoData { + val filename: String // 会显示在 UI + + /** + * Returns the length of the video file in bytes. + */ + val fileLength: Long // 用于匹配弹幕 (仅备选方案下), 一般用不到 + + data class Stats( + /** + * The download speed in bytes per second. + * + * If this video data is not being downloaded, i.e. it is a local file, + * the flow emits [FileSize.Unspecified]. + */ + val downloadSpeed: FileSize, + + /** + * The upload speed in bytes per second. + * + * If this video data is not being uploaded, i.e. it is a local file, + * the flow emits [FileSize.Unspecified]. + */ + val uploadRate: FileSize, + ) { + companion object { + val Unspecified = Stats(FileSize.Unspecified, FileSize.Unspecified) + } + } + + val networkStats: Flow + + val isCacheFinished: Flow get() = flowOf(false) + + /** + * 支持边下边播 + */ + val supportsStreaming: Boolean get() = false + + /** + * MD5 hash. 可以为 `null`. + */ + @Throws(IOException::class) + fun computeHash(): String? // 用于匹配弹幕 (仅备选方案下), 一般用不到 + + /** + * Opens a new input stream to the video file. + * The returned [SeekableInput] needs to be closed when not used anymore. + * + * The returned [SeekableInput] must be closed before a new [createInput] can be made. + * Otherwise, it is undefined behavior. + */ + suspend fun createInput(coroutineContext: CoroutineContext = EmptyCoroutineContext): SeekableInput + + suspend fun close() +} + +fun emptyVideoData(): VideoData = EmptyVideoData + +private object EmptyVideoData : VideoData { + override val filename: String get() = "" + override val fileLength: Long get() = 0 + override val networkStats: Flow = + flowOf(VideoData.Stats.Unspecified) + + override fun computeHash(): String? = null + override suspend fun createInput(coroutineContext: CoroutineContext): SeekableInput = emptySeekableInput() + override suspend fun close() {} +} diff --git a/mediamp-api/src/commonMain/kotlin/data/VideoProperties.kt b/mediamp-api/src/commonMain/kotlin/data/VideoProperties.kt new file mode 100644 index 0000000..a2fe683 --- /dev/null +++ b/mediamp-api/src/commonMain/kotlin/data/VideoProperties.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp.data + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Immutable +data class VideoProperties( + val title: String?, + val durationMillis: Long, +) { + companion object { + @Stable + val EMPTY = VideoProperties( + title = null, + durationMillis = 0, + ) + } +} diff --git a/mediamp-api/src/commonMain/kotlin/data/VideoSource.kt b/mediamp-api/src/commonMain/kotlin/data/VideoSource.kt new file mode 100644 index 0000000..4bf1621 --- /dev/null +++ b/mediamp-api/src/commonMain/kotlin/data/VideoSource.kt @@ -0,0 +1,42 @@ +package org.openani.mediamp.data + +import me.him188.ani.datasources.api.MediaExtraFiles +import kotlin.coroutines.cancellation.CancellationException + +/** + * A source of the video data [S]. + * + * [VideoSource]s are stateless: They only represent a location of the resource, not holding file descriptors or network connections, etc. + * + * ## Obtaining data stream + * + * To get the input stream of the video file, two steps are needed: + * 1. Open a [VideoData] using [open]. + * 2. Use [VideoData.createInput] to get the input stream [SeekableInput]. + * + * Note that both [VideoData] and [SeekableInput] are [AutoCloseable] and needs to be properly closed. + * + * In the BitTorrent scenario, [VideoSource.open] is to resolve magnet links, and to download the torrent metadata file. + * [VideoData.createInput] is to start downloading the actual video file. + * Though the actual implementation might start downloading very soon (e.g. when [VideoSource] is just created), so that + * the video buffers more soon. + * + * @param S type of the stream + */ +interface VideoSource { + val uri: String + + val extraFiles: MediaExtraFiles + + /** + * Opens the underlying video data. + * + * Note that [S] should be closed by the caller. + * + * Repeat calls to this function may return different instances so it may be desirable to store the result. + * + * @throws VideoSourceOpenException 当打开失败时抛出, 包含原因 + */ + @Throws(VideoSourceOpenException::class, CancellationException::class) + suspend fun open(): S +} diff --git a/mediamp-api/src/commonMain/kotlin/data/VideoSourceOpenException.kt b/mediamp-api/src/commonMain/kotlin/data/VideoSourceOpenException.kt new file mode 100644 index 0000000..4152fcd --- /dev/null +++ b/mediamp-api/src/commonMain/kotlin/data/VideoSourceOpenException.kt @@ -0,0 +1,38 @@ +package org.openani.mediamp.data + +/** + * @see VideoSource.open + * @see VideoSourceOpenException + */ +enum class OpenFailures { + /** + * 未找到符合剧集描述的文件 + */ + NO_MATCHING_FILE, + + /** + * 视频资源没问题, 但播放器不支持该资源. 例如尝试用一个不支持边下边播的播放器 (例如桌面端的 vlcj) 来播放种子视频 `TorrentVideoSource`. + */ + UNSUPPORTED_VIDEO_SOURCE, + + /** + * TorrentEngine 等被关闭. + * + * 这个错误实际上不太会发生, 因为当引擎关闭时会跳过使用该引擎的 `VideoSourceResolver`, 也就不会产生依赖该引擎的 [VideoSource]. + * 只有在得到 [VideoSource] 后引擎关闭 (用户去设置中关闭) 才会发生. + */ + ENGINE_DISABLED, +} + +class VideoSourceOpenException( + val reason: OpenFailures, + message: String? = null, + override val cause: Throwable? = null, +) : Exception( + if (message == null) { + "Failed to open video source: $reason" + } else { + "Failed to open video source: $reason. $message" + }, + cause, +) diff --git a/mediamp-api/src/commonMain/kotlin/package.kt b/mediamp-api/src/commonMain/kotlin/package.kt new file mode 100644 index 0000000..d503958 --- /dev/null +++ b/mediamp-api/src/commonMain/kotlin/package.kt @@ -0,0 +1,2 @@ +package org.openani.mediamp + diff --git a/mediamp-core/build.gradle.kts b/mediamp-core/build.gradle.kts new file mode 100644 index 0000000..2736965 --- /dev/null +++ b/mediamp-core/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +plugins { + kotlin("multiplatform") + id("com.android.library") + kotlin("plugin.compose") + id("org.jetbrains.compose") + + `mpp-lib-targets` + kotlin("plugin.serialization") +// id("org.jetbrains.kotlinx.atomicfu") +} + +kotlin { + sourceSets.commonMain.dependencies { + api(projects.mediampApi) + api(libs.kotlinx.coroutines.core) + } + sourceSets.commonTest.dependencies { + api(libs.kotlinx.coroutines.test) + } + sourceSets.androidMain.dependencies { + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.compose.material3.adaptive.core.get().toString()) { + exclude("androidx.window.core", "window-core") + } + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.dash) + implementation(libs.androidx.media3.exoplayer.hls) + } + sourceSets.desktopMain.dependencies { + api(compose.desktop.currentOs) { + exclude(compose.material) // We use material3 + } + + api(libs.kotlinx.coroutines.swing) + implementation(libs.vlcj) + implementation(libs.jna) + implementation(libs.jna.platform) + } +} + +android { + namespace = "org.openani.mediamp.core" +} diff --git a/mediamp-core/src/androidMain/kotlin/PlayerState.android.kt b/mediamp-core/src/androidMain/kotlin/PlayerState.android.kt new file mode 100644 index 0000000..1c9f4d4 --- /dev/null +++ b/mediamp-core/src/androidMain/kotlin/PlayerState.android.kt @@ -0,0 +1,432 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp + +import android.net.Uri +import android.util.Pair +import androidx.annotation.MainThread +import androidx.annotation.OptIn +import androidx.annotation.UiThread +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.trackselection.ExoTrackSelection +import androidx.media3.exoplayer.trackselection.TrackSelection +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.him188.ani.app.platform.Context +import me.him188.ani.app.tools.MonoTasker +import me.him188.ani.utils.logging.error +import org.openani.mediamp.data.VideoData +import org.openani.mediamp.data.VideoProperties +import org.openani.mediamp.data.VideoSource +import org.openani.mediamp.data.emptyVideoData +import org.openani.mediamp.media.VideoDataDataSource +import org.openani.mediamp.ui.state.AbstractPlayerState +import org.openani.mediamp.ui.state.AudioTrack +import org.openani.mediamp.ui.state.Chapter +import org.openani.mediamp.ui.state.Label +import org.openani.mediamp.ui.state.MutableTrackGroup +import org.openani.mediamp.ui.state.PlaybackState +import org.openani.mediamp.ui.state.PlayerState +import org.openani.mediamp.ui.state.PlayerStateFactory +import org.openani.mediamp.ui.state.SubtitleTrack +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds + + +class ExoPlayerStateFactory : PlayerStateFactory { + @OptIn(UnstableApi::class) + override fun create(context: Context, parentCoroutineContext: CoroutineContext): PlayerState = + ExoPlayerState(context, parentCoroutineContext) +} + + +@OptIn(UnstableApi::class) +internal class ExoPlayerState @UiThread constructor( + context: Context, + parentCoroutineContext: CoroutineContext +) : AbstractPlayerState(parentCoroutineContext), + AutoCloseable { + class ExoPlayerData( + videoSource: VideoSource<*>, + videoData: VideoData, + releaseResource: () -> Unit, + val setMedia: () -> Unit, + ) : Data(videoSource, videoData, releaseResource) + + override suspend fun startPlayer(data: ExoPlayerData) { + withContext(Dispatchers.Main.immediate) { + data.setMedia() + player.prepare() + player.play() + } + } + + override suspend fun cleanupPlayer() { + withContext(Dispatchers.Main.immediate) { + player.stop() + player.clearMediaItems() + } + } + + override suspend fun openSource(source: VideoSource<*>): ExoPlayerData { + if (source is HttpStreamingVideoSource) { + return ExoPlayerData( + source, + emptyVideoData(), + releaseResource = {}, + setMedia = { + val headers = source.webVideo.headers + val item = MediaItem.Builder().apply { + setUri(source.uri) + setSubtitleConfigurations( + source.extraFiles.subtitles.map { + MediaItem.SubtitleConfiguration.Builder( + Uri.parse(it.uri), + ).apply { + it.mimeType?.let { mimeType -> setMimeType(mimeType) } + it.language?.let { language -> setLanguage(language) } + }.build() + }, + ) + }.build() + player.setMediaSource( + DefaultMediaSourceFactory( + DefaultHttpDataSource.Factory() + .setUserAgent( + headers["User-Agent"] + ?: """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + ) + .setDefaultRequestProperties(headers) + .setConnectTimeoutMs(30_000), + ).createMediaSource(item), + ) + }, + ) + } + val data = source.open() + val file = withContext(Dispatchers.IO) { + data.createInput() + } + val factory = ProgressiveMediaSource.Factory { + VideoDataDataSource(data, file) + } + return ExoPlayerData( + source, + data, + releaseResource = { + file.close() + backgroundScope.launch(NonCancellable) { + data.close() + } + }, + setMedia = { + player.setMediaSource(factory.createMediaSource(MediaItem.fromUri(source.uri))) + }, + ) + } + + private val updateVideoPropertiesTasker = MonoTasker(backgroundScope) + + val player = kotlin.run { + ExoPlayer.Builder(context).apply { + setTrackSelector( + object : DefaultTrackSelector(context) { + override fun selectTextTrack( + mappedTrackInfo: MappedTrackInfo, + rendererFormatSupports: Array>, + params: Parameters, + selectedAudioLanguage: String? + ): Pair? { + val preferred = subtitleTracks.current.value + ?: return super.selectTextTrack( + mappedTrackInfo, + rendererFormatSupports, + params, + selectedAudioLanguage, + ) + + infix fun SubtitleTrack.matches(group: TrackGroup): Boolean { + if (this.internalId == group.id) return true + + if (this.labels.isEmpty()) return false + for (index in 0 until group.length) { + val format = group.getFormat(index) + if (format.labels.isEmpty()) { + continue + } + if (this.labels.any { it.value == format.labels.first().value }) { + return true + } + } + return false + } + + // 备注: 这个实现可能并不好, 他只是恰好能跑 + for (rendererIndex in 0 until mappedTrackInfo.rendererCount) { + if (C.TRACK_TYPE_TEXT != mappedTrackInfo.getRendererType(rendererIndex)) continue + + val groups = mappedTrackInfo.getTrackGroups(rendererIndex) + for (groupIndex in 0 until groups.length) { + val trackGroup = groups[groupIndex] + if (preferred matches trackGroup) { + return Pair( + ExoTrackSelection.Definition( + trackGroup, + IntArray(trackGroup.length) { it }, // 如果选择所有字幕会闪烁 + TrackSelection.TYPE_UNSET, + ), + rendererIndex, + ) + } + } + } + return super.selectTextTrack( + mappedTrackInfo, + rendererFormatSupports, + params, + selectedAudioLanguage, + ) + } + }, + ) + }.build().apply { + playWhenReady = true + addListener( + object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + state.value = PlaybackState.READY + isBuffering.value = false + } + + override fun onTracksChanged(tracks: Tracks) { + val newSubtitleTracks = + tracks.groups.asSequence() + .filter { it.type == C.TRACK_TYPE_TEXT } + .flatMapIndexed { groupIndex: Int, group: Tracks.Group -> + group.getSubtitleTracks() + } + .toList() + // 新的字幕轨道和原来不同时才会更改,同时将 current 设置为新字幕轨道列表的第一个 + if (newSubtitleTracks != subtitleTracks.candidates.value) { + subtitleTracks.candidates.value = newSubtitleTracks + subtitleTracks.current.value = newSubtitleTracks.firstOrNull() + } + + audioTracks.candidates.value = + tracks.groups.asSequence() + .filter { it.type == C.TRACK_TYPE_AUDIO } + .flatMapIndexed { groupIndex: Int, group: Tracks.Group -> + group.getAudioTracks() + } + .toList() + } + + override fun onPlayerError(error: PlaybackException) { + state.value = PlaybackState.ERROR + logger.warn("ExoPlayer error: ${error.errorCodeName}", error) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + super.onVideoSizeChanged(videoSize) + updateVideoProperties() + } + + @MainThread + private fun updateVideoProperties(): Boolean { + val video = videoFormat ?: return false + val audio = audioFormat ?: return false + val data = openResource.value?.videoData ?: return false + val title = mediaMetadata.title + val duration = duration + + // 注意, 要把所有 UI 属性全都读出来然后 captured 到 background -- ExoPlayer 所有属性都需要在主线程 + + updateVideoPropertiesTasker.launch(Dispatchers.IO) { + // This is in background + videoProperties.value = VideoProperties( + title = title?.toString(), + durationMillis = duration, + ) + } + return true + } + + /** + * STATE_READY 会在当前帧缓冲结束时设置 + * + * exoplayer 的 STATE_READY 是不符合 [PlaybackState.READY] 预期的,所以不能在这里设置 + * + * [PlaybackState.READY] 会在 [onMediaItemTransition] 中设置 + */ + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_BUFFERING -> { + isBuffering.value = true + } + + Player.STATE_ENDED -> { + state.value = PlaybackState.FINISHED + isBuffering.value = false + } + + Player.STATE_READY -> { + isBuffering.value = false + } + } + updateVideoProperties() + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + state.value = PlaybackState.PLAYING + isBuffering.value = false + } else { + state.value = PlaybackState.PAUSED + } + } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + super.onPlaybackParametersChanged(playbackParameters) + playbackSpeed.value = playbackParameters.speed + } + }, + ) + } + } + + private fun Tracks.Group.getSubtitleTracks() = sequence { + repeat(length) { index -> + val format = getTrackFormat(index) + val firstLabel = format.labels.firstNotNullOfOrNull { it.value } + format.metadata + this.yield( + SubtitleTrack( + "${openResource.value?.videoData?.filename}-${mediaTrackGroup.id}-$index", + mediaTrackGroup.id, + firstLabel ?: mediaTrackGroup.id, + format.labels.map { Label(it.language, it.value) }, + ), + ) + } + } + + private fun Tracks.Group.getAudioTracks() = sequence { + repeat(length) { index -> + val format = getTrackFormat(index) + val firstLabel = format.labels.firstNotNullOfOrNull { it.value } + format.metadata + this.yield( + AudioTrack( + "${openResource.value?.videoData?.filename}-${mediaTrackGroup.id}-$index", + mediaTrackGroup.id, + firstLabel ?: mediaTrackGroup.id, + format.labels.map { Label(it.language, it.value) }, + ), + ) + } + } + + override val isBuffering: MutableStateFlow = MutableStateFlow(false) // 需要单独状态, 因为要用户可能会覆盖 [state] + override fun stopImpl() { + player.stop() + } + + override val videoProperties = MutableStateFlow(null) + override val bufferedPercentage = MutableStateFlow(0) + + override fun seekTo(positionMillis: Long) { + currentPositionMillis.value = positionMillis + player.seekTo(positionMillis) + } + + override val subtitleTracks: MutableTrackGroup = MutableTrackGroup() + + override val audioTracks: MutableTrackGroup = MutableTrackGroup() + + override fun saveScreenshotFile(filename: String) { + TODO("Not yet implemented") + } + + override val chapters: StateFlow> = MutableStateFlow(persistentListOf()) + + override val currentPositionMillis: MutableStateFlow = MutableStateFlow(0) + override fun getExactCurrentPositionMillis(): Long = player.currentPosition + + init { + backgroundScope.launch(Dispatchers.Main) { + while (currentCoroutineContext().isActive) { + currentPositionMillis.value = player.currentPosition + bufferedPercentage.value = player.bufferedPercentage + delay(0.1.seconds) // 10 fps + } + } + backgroundScope.launch(Dispatchers.Main) { + subtitleTracks.current.collect { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().apply { + setPreferredTextLanguage(it?.internalId) // dummy value to trigger a select, we have custom selector + setTrackTypeDisabled(C.TRACK_TYPE_TEXT, it == null) // disable subtitle track + }.build() + } + } + } + + override fun pause() { + player.playWhenReady = false + player.pause() + } + + override fun resume() { + player.playWhenReady = true + player.play() + } + + override val playbackSpeed: MutableStateFlow = MutableStateFlow(1f) + + override fun closeImpl() { + @kotlin.OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.Main) { + try { + player.stop() + player.release() + logger.info("ExoPlayer $player released") + } catch (e: Throwable) { + logger.error(e) { "Failed to release ExoPlayer $player, ignoring" } + } + } + } + + override fun setPlaybackSpeed(speed: Float) { + player.setPlaybackSpeed(speed) + } +} diff --git a/mediamp-core/src/androidMain/kotlin/media/VideoDataDataSource.kt b/mediamp-core/src/androidMain/kotlin/media/VideoDataDataSource.kt new file mode 100644 index 0000000..3871653 --- /dev/null +++ b/mediamp-core/src/androidMain/kotlin/media/VideoDataDataSource.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +@file:androidx.annotation.OptIn(UnstableApi::class) + +package org.openani.mediamp.media + +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import kotlinx.coroutines.runBlocking +import me.him188.ani.utils.io.SeekableInput +import me.him188.ani.utils.logging.info +import me.him188.ani.utils.logging.warn +import org.openani.mediamp.data.VideoData +import org.openani.mediamp.media.VideoDataDataSource.Companion.logger +import java.io.IOException +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.measureTimedValue + +/** + * Wrap of an Ani [VideoData] into a ExoPlayer [DataSource]. + * + * This class will not close [videoData]. + */ +@androidx.annotation.OptIn(UnstableApi::class) +class VideoDataDataSource( + private val videoData: VideoData, + private val file: SeekableInput, +) : BaseDataSource(true) { + private companion object { + @JvmStatic + private val logger = logger() + private const val ENABLE_READ_LOG = false + private const val ENABLE_TRACE_LOG = false + } + + private var uri: Uri? = null + + private var opened = false + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + // 性能提示: 这个函数会被非常频繁调用 (一个 byte 一次), 速度会直接影响视频首帧延迟 + + if (length == 0) return 0 + + if (ENABLE_READ_LOG) { // const val, optimized out + logger.warn { "VideoDataDataSource read: offset=$offset, length=$length" } + } + + val bytesRead = if (ENABLE_READ_LOG) { + val (value, time) = measureTimedValue { + file.read(buffer, offset, length) + } + if (time > 100.milliseconds) { + logger.warn { "VideoDataDataSource slow read: read $offset for length $length took $time" } + } + value + } else { + file.read(buffer, offset, length) + } + if (bytesRead == -1) { + return C.RESULT_END_OF_INPUT + } + bytesTransferred(bytesRead) + return bytesRead + } + + @Throws(IOException::class) + override fun open(dataSpec: DataSpec): Long { + if (ENABLE_TRACE_LOG) logger.info { "Opening dataSpec, offset=${dataSpec.position}, length=${dataSpec.length}, videoData=$videoData" } + + val uri = dataSpec.uri + if (opened && dataSpec.uri == this.uri) { + if (ENABLE_TRACE_LOG) logger.info { "Double open, will not start download." } + } else { + this.uri = uri + transferInitializing(dataSpec) + opened = true + } + + val torrentLength = videoData.fileLength + + if (ENABLE_TRACE_LOG) logger.info { "torrentLength = $torrentLength" } + + if (dataSpec.position >= torrentLength) { + if (ENABLE_TRACE_LOG) logger.info { "dataSpec.position ${dataSpec.position} > torrentLength $torrentLength" } + } else { + if (dataSpec.position != -1L && dataSpec.position != 0L) { + if (ENABLE_TRACE_LOG) logger.info { "Seeking to ${dataSpec.position}" } + runBlocking { file.seek(dataSpec.position) } + } + + if (ENABLE_TRACE_LOG) logger.info { "Open done, bytesRemaining = ${file.bytesRemaining}" } + } + + transferStarted(dataSpec) + return file.bytesRemaining + } + + override fun getUri(): Uri? = uri + + override fun close() { + if (ENABLE_TRACE_LOG) logger.info { "Closing VideoDataDataSource" } + uri = null + if (opened) { + transferEnded() + } + } +} \ No newline at end of file diff --git a/mediamp-core/src/androidMain/kotlin/package.kt b/mediamp-core/src/androidMain/kotlin/package.kt new file mode 100644 index 0000000..d503958 --- /dev/null +++ b/mediamp-core/src/androidMain/kotlin/package.kt @@ -0,0 +1,2 @@ +package org.openani.mediamp + diff --git a/mediamp-core/src/androidMain/kotlin/ui/VideoGestureHost.android.kt b/mediamp-core/src/androidMain/kotlin/ui/VideoGestureHost.android.kt new file mode 100644 index 0000000..c5c48fe --- /dev/null +++ b/mediamp-core/src/androidMain/kotlin/ui/VideoGestureHost.android.kt @@ -0,0 +1,96 @@ +package org.openani.mediamp.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.openani.mediamp.ui.guesture.GestureIndicator +import org.openani.mediamp.ui.guesture.rememberGestureIndicatorState + + +@Composable +private fun SeekPositionIndicator( + deltaDuration: Int, +) { + GestureIndicator( + state = rememberGestureIndicatorState().apply { + LaunchedEffect(key1 = true) { + showSeeking(deltaDuration) + } + }, + ) +} + +@PreviewLightDark +@Composable +private fun PreviewSeekPositionIndicatorForward() { + Box { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Transparent), + ) { + + } + SeekPositionIndicator(deltaDuration = 10) + } +} + +@PreviewLightDark +@Composable +private fun PreviewSeekPositionIndicatorBackward() { + Box { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Transparent), + ) { + + } + SeekPositionIndicator(deltaDuration = -10) + } +} + +@PreviewLightDark +@Composable +private fun PreviewSeekPositionIndicatorBackwardMinutes() { + Box { + Box( + modifier = Modifier + .matchParentSize() + .background(Color.Transparent), + ) { + + } + SeekPositionIndicator(deltaDuration = -90) + } +} + + +@Preview +@Composable +private fun PreviewPaused() { + GestureIndicator( + state = rememberGestureIndicatorState().apply { + LaunchedEffect(key1 = true) { + showPausedLong() + } + }, + ) +} + +@Preview +@Composable +private fun PreviewVolume() { + GestureIndicator( + state = rememberGestureIndicatorState().apply { + LaunchedEffect(key1 = true) { + showVolumeRange(0.6f) + } + }, + ) +} \ No newline at end of file diff --git a/mediamp-core/src/androidMain/kotlin/ui/VideoPlayer.android.kt b/mediamp-core/src/androidMain/kotlin/ui/VideoPlayer.android.kt new file mode 100644 index 0000000..d56b39b --- /dev/null +++ b/mediamp-core/src/androidMain/kotlin/ui/VideoPlayer.android.kt @@ -0,0 +1,70 @@ +package org.openani.mediamp.ui + +import android.graphics.Color +import android.graphics.Typeface +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.CaptionStyleCompat +import androidx.media3.ui.PlayerView +import androidx.media3.ui.PlayerView.ControllerVisibilityListener +import me.him188.ani.app.ui.foundation.LocalIsPreviewing +import org.openani.mediamp.ExoPlayerState +import org.openani.mediamp.ui.state.PlayerState + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +actual fun VideoPlayer( + playerState: PlayerState, + modifier: Modifier +) { + val isPreviewing by rememberUpdatedState(me.him188.ani.app.ui.foundation.LocalIsPreviewing.current) + + AndroidView( + factory = { context -> + PlayerView(context).apply { + val videoView = this + if (isPreviewing) { + return@apply // preview 时 set 会 ISE + } + controllerAutoShow = false + useController = false + controllerHideOnTouch = false + subtitleView?.apply { + this.setStyle( + CaptionStyleCompat( + Color.WHITE, + 0x000000FF, + 0x00000000, + CaptionStyleCompat.EDGE_TYPE_OUTLINE, + Color.BLACK, + Typeface.DEFAULT, + ), + ) + } + (playerState as? ExoPlayerState)?.let { + player = it.player + setControllerVisibilityListener( + ControllerVisibilityListener { visibility -> + if (visibility == View.VISIBLE) { + videoView.hideController() + } + }, + ) + } + } + }, + modifier, + onRelease = { + }, + update = { view -> + (playerState as? ExoPlayerState)?.let { + view.player = it.player + } + }, + ) +} \ No newline at end of file diff --git a/mediamp-core/src/androidMain/kotlin/ui/gesture/GestureLock.android.kt b/mediamp-core/src/androidMain/kotlin/ui/gesture/GestureLock.android.kt new file mode 100644 index 0000000..a62987e --- /dev/null +++ b/mediamp-core/src/androidMain/kotlin/ui/gesture/GestureLock.android.kt @@ -0,0 +1,17 @@ +package org.openani.mediamp.ui.gesture + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.openani.mediamp.ui.guesture.GestureLock + +@PreviewLightDark +@Composable +private fun PreviewGestureLockLocked() { + GestureLock(true, {}) +} + +@PreviewLightDark +@Composable +private fun PreviewGestureLockUnlocked() { + GestureLock(false, {}) +} diff --git a/mediamp-core/src/commonMain/kotlin/package.kt b/mediamp-core/src/commonMain/kotlin/package.kt new file mode 100644 index 0000000..d50a3f3 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/package.kt @@ -0,0 +1,10 @@ +package org.openani.mediamp + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun Common(modifier: Modifier = Modifier) { + Text("1") +} \ No newline at end of file diff --git a/mediamp-core/src/commonMain/kotlin/ui/VideoControllerState.kt b/mediamp-core/src/commonMain/kotlin/ui/VideoControllerState.kt new file mode 100644 index 0000000..a09db72 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/VideoControllerState.kt @@ -0,0 +1,197 @@ +package org.openani.mediamp.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import me.him188.ani.app.ui.foundation.interaction.hoverable +import me.him188.ani.utils.platform.annotations.TestOnly + + +/** + * @param initialVisibility 变更不会更新 + */ +@Composable +fun rememberVideoControllerState( + initialVisibility: ControllerVisibility = VideoControllerState.DEFAULT_INITIAL_VISIBILITY +): VideoControllerState { + return remember { + VideoControllerState(initialVisibility) + } +} + +@Immutable +data class ControllerVisibility( + val topBar: Boolean, + val bottomBar: Boolean, + val floatingBottomEnd: Boolean, + val rhsBar: Boolean, + val detachedSlider: Boolean +) { + companion object { + @Stable + val Visible = ControllerVisibility( + topBar = true, + bottomBar = true, + floatingBottomEnd = false, + rhsBar = true, + detachedSlider = false, + ) + + @Stable + val Invisible = ControllerVisibility( + topBar = false, + bottomBar = false, + floatingBottomEnd = true, + rhsBar = false, + detachedSlider = false, + ) + + @Stable + val DetachedSliderOnly = ControllerVisibility( + topBar = false, + bottomBar = false, + floatingBottomEnd = false, + rhsBar = false, + detachedSlider = true, + ) + } +} + +@Stable +class VideoControllerState( + initialVisibility: ControllerVisibility = DEFAULT_INITIAL_VISIBILITY +) { + companion object { + val DEFAULT_INITIAL_VISIBILITY = ControllerVisibility.Invisible + } + + private var fullVisible by mutableStateOf(initialVisibility == ControllerVisibility.Visible) + private val hasProgressBarRequester by derivedStateOf { progressBarRequesters.isNotEmpty() } + + /** + * 当前 UI 应当显示的状态 + */ + val visibility: ControllerVisibility by derivedStateOf { + // 根据 hasProgressBarRequester, alwaysOn 和 fullVisible 计算正确的 `ControllerVisibility` + if (alwaysOn) return@derivedStateOf ControllerVisibility.Visible + if (fullVisible) return@derivedStateOf ControllerVisibility.Visible + if (hasProgressBarRequester) return@derivedStateOf ControllerVisibility.DetachedSliderOnly + ControllerVisibility.Invisible + } + + /** + * 切换显示或隐藏整个控制器. + * + * 此操作拥有比 [setRequestProgressBar] 更低的优先级. + * 如果此时有人请求显示进度条, `toggleEntireVisible(false)` 将会延迟到那个人取消请求后才隐藏进度条. + * 如果此时没有人请求显示进度条, 此函数将立即生效. + * + * @param visible 为 `true` 时显示整个控制器 + */ + fun toggleFullVisible(visible: Boolean? = null) { + fullVisible = visible ?: !fullVisible + } + + val setFullVisible: (visible: Boolean) -> Unit = { + fullVisible = it + } + + private val alwaysOnRequests = SnapshotStateList() + + /** + * 总是显示. 也就是不要在 5 秒后自动隐藏. + */ + val alwaysOn: Boolean by derivedStateOf { + alwaysOnRequests.isNotEmpty() + } + + /** + * 请求控制器总是显示. + */ + fun setRequestAlwaysOn(requester: Any, isAlwaysOn: Boolean) { + if (isAlwaysOn) { + if (requester in alwaysOnRequests) return + alwaysOnRequests.add(requester) + } else { + alwaysOnRequests.remove(requester) + } + } + + private val progressBarRequesters = SnapshotStateList() + + /** + * 请求显示进度条 + * 当目前没有显示进度条时, 将显示独立的进度条. + * 若目前已经有进度条, 则会保持该状态, 防止自动关闭. + * + * @param requester 是谁希望请求显示进度条. 在 [cancelRequestProgressBarVisible] 时需要传入相同实例. 同一时刻有任一 requester 则会让进度条一直显示. + */ + fun setRequestProgressBar(requester: Any) { + if (requester in progressBarRequesters) return + progressBarRequesters.add(requester) + } + + /** + * 取消显示进度条 + */ + fun cancelRequestProgressBarVisible(requester: Any) { + progressBarRequesters.remove(requester) + } + + @TestOnly + fun getAlwaysOnRequesters(): List { + return alwaysOnRequests + } +} + +interface AlwaysOnRequester { + fun request() + fun cancelRequest() +} + +@Composable +fun rememberAlwaysOnRequester( + controllerState: VideoControllerState, + debugName: String +): AlwaysOnRequester { + val requester = remember(controllerState, debugName) { + object : AlwaysOnRequester { + override fun request() { + controllerState.setRequestAlwaysOn(this, true) + } + + override fun cancelRequest() { + controllerState.setRequestAlwaysOn(this, false) + } + + override fun toString(): String { + return "AlwaysOnRequester($debugName)" + } + } + } + DisposableEffect(requester) { + onDispose { + requester.cancelRequest() + } + } + return requester +} + +fun Modifier.hoverToRequestAlwaysOn( + requester: AlwaysOnRequester +): Modifier = hoverable( + onHover = { + requester.request() + }, + onUnhover = { + requester.cancelRequest() + }, +) diff --git a/mediamp-core/src/commonMain/kotlin/ui/VideoLoadingIndicator.kt b/mediamp-core/src/commonMain/kotlin/ui/VideoLoadingIndicator.kt new file mode 100644 index 0000000..9eb32ea --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/VideoLoadingIndicator.kt @@ -0,0 +1,37 @@ +package org.openani.mediamp.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor +import me.him188.ani.app.ui.foundation.theme.aniDarkColorTheme + +@Composable +fun VideoLoadingIndicator( + showProgress: Boolean, + text: @Composable () -> Unit, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.labelLarge, +) { + MaterialTheme(aniDarkColorTheme()) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + if (showProgress) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 3.dp) + } + + Row(Modifier.padding(top = 8.dp)) { + ProvideTextStyleContentColor(textStyle, color = MaterialTheme.colorScheme.onSurface) { + text() + } + } + } + } +} \ No newline at end of file diff --git a/mediamp-core/src/commonMain/kotlin/ui/VideoPlayer.kt b/mediamp-core/src/commonMain/kotlin/ui/VideoPlayer.kt new file mode 100644 index 0000000..3f64aee --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/VideoPlayer.kt @@ -0,0 +1,18 @@ +package org.openani.mediamp.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.openani.mediamp.ui.state.PlayerState + + +/** + * Displays a video player itself. There is no control bar or any other UI elements. + * + * The size of the video player is undefined by default. It may take the entire screen or vise versa. + * Please apply a size [Modifier] to control the size of the video player. + */ +@Composable +expect fun VideoPlayer( + playerState: PlayerState, + modifier: Modifier, +) diff --git a/mediamp-core/src/commonMain/kotlin/ui/VideoScaffold.kt b/mediamp-core/src/commonMain/kotlin/ui/VideoScaffold.kt new file mode 100644 index 0000000..6dc907c --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/VideoScaffold.kt @@ -0,0 +1,323 @@ +package org.openani.mediamp.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.theme.aniDarkColorTheme +import me.him188.ani.app.ui.foundation.theme.slightlyWeaken +import org.openani.mediamp.ui.guesture.VideoGestureHost +import org.openani.mediamp.ui.progress.PlayerControllerBar +import org.openani.mediamp.ui.top.PlayerTopBar + +/** + * 视频播放器框架, 可以自定义组合控制器等部分. + * + * 视频播放器框架由以下层级组成, 由上至下: + * + * - 悬浮消息: [floatingMessage], 例如正在缓冲 + * - 控制器: [topBar], [rhsBar] 和 [bottomBar] + * - 手势: [gestureHost] + * - 弹幕: [danmakuHost] + * - 视频: [video] + * - 右侧侧边栏: [rhsSheet] + * + * @param topBar [PlayerTopBar] + * @param video [VideoPlayer]. video 不会接受到点击事件. + * @param danmakuHost 为 `DanmakuHost` 留的区域 + * @param gestureHost 手势区域, 例如快进/快退, 音量调节等. See [VideoGestureHost] + * @param floatingMessage 悬浮消息, 例如正在缓冲. 将会对齐到中央 + * @param rhsBar 右侧控制栏, 锁定手势等. + * @param bottomBar [PlayerControllerBar] + * @param expanded 当前是否处于全屏模式. 全屏时此框架会 [Modifier.fillMaxSize], 否则会限制为一个 16:9 的框. + */ +@Composable +fun VideoScaffold( + expanded: Boolean, + modifier: Modifier = Modifier, + contentWindowInsets: WindowInsets = WindowInsets.safeContent, // TODO: 目前只对部分元素有效 + maintainAspectRatio: Boolean = !expanded, + controllerState: VideoControllerState, + gestureLocked: () -> Boolean = { false }, + topBar: @Composable RowScope.() -> Unit = {}, + /** + * @see VideoPlayer + */ + video: @Composable BoxScope.() -> Unit = {}, + danmakuHost: @Composable BoxScope.() -> Unit = {}, + gestureHost: @Composable BoxWithConstraintsScope.() -> Unit = {}, + floatingMessage: @Composable BoxScope.() -> Unit = {}, + rhsButtons: @Composable ColumnScope.() -> Unit = {}, + gestureLock: @Composable ColumnScope.() -> Unit = {}, + bottomBar: @Composable RowScope.() -> Unit = {}, + detachedProgressSlider: @Composable () -> Unit = {}, + floatingBottomEnd: @Composable RowScope.() -> Unit = {}, + rhsSheet: @Composable () -> Unit = {}, + leftBottomTips: @Composable () -> Unit = {}, +) { + val gestureLockedState by derivedStateOf(gestureLocked) // delayed access to minimize recomposition + + BoxWithConstraints( + modifier.then(if (expanded) Modifier.fillMaxHeight() else Modifier.fillMaxWidth()), + contentAlignment = Alignment.Center, + ) { // 16:9 box + Box( + Modifier + .then( + if (!maintainAspectRatio) { + Modifier.fillMaxSize() + } else { + Modifier.fillMaxWidth().height(maxWidth * 9 / 16) // 16:9 box + }, + ), + ) { + Box( + Modifier + .background(Color.Transparent) + .matchParentSize(), // no window insets for video + ) { + video() + Box(Modifier.matchParentSize()) // 防止点击事件传播到 video 里 + } + + // 弹幕 + Box( + Modifier + .matchParentSize() + .fillMaxWidth() + .padding(vertical = 8.dp) + .windowInsetsPadding(contentWindowInsets), + ) { + CompositionLocalProvider(LocalContentColor provides aniDarkColorTheme().onBackground) { + danmakuHost() + } + } + + // 控制手势 + BoxWithConstraints(Modifier.matchParentSize(), contentAlignment = Alignment.Center) { + gestureHost() + } + + Box(Modifier) { + Column(Modifier.fillMaxSize().background(Color.Transparent)) { + // 顶部控制栏: 返回键, 标题, 设置 + AnimatedVisibility( + visible = controllerState.visibility.topBar && !gestureLockedState, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box { + Box( + Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + 0f to Color.Transparent.copy(0.72f), + 0.32f to Color.Transparent.copy(0.45f), + 1f to Color.Transparent, + ), + ), + ) + val alwaysOnRequester = rememberAlwaysOnRequester(controllerState, "topBar") + Column( + Modifier + .hoverToRequestAlwaysOn(alwaysOnRequester) + .fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth() + .windowInsetsPadding(contentWindowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider(LocalContentColor provides aniDarkColorTheme().onBackground) { + topBar() + } + } + Spacer(Modifier.height(16.dp)) + } + + } + } + + Box(Modifier.weight(1f, fill = true).fillMaxWidth()) + + Column { + // 底部控制栏: 播放/暂停, 进度条, 切换全屏 + AnimatedVisibility( + visible = controllerState.visibility.bottomBar && !gestureLockedState, + enter = fadeIn(), + exit = fadeOut(), + ) { + val alwaysOnRequester = rememberAlwaysOnRequester(controllerState, "bottomBar") + Column( + Modifier + .hoverToRequestAlwaysOn(alwaysOnRequester) + .pointerInput(Unit) { + awaitEachGesture { + val event = awaitPointerEvent() + if (event.changes.all { it.pressed }) { + //点击 bottom bar 里的按钮时 请求 always on + alwaysOnRequester.request() + } + var releaseEvent = awaitPointerEvent() + while (releaseEvent.changes.any { it.pressed }) { + releaseEvent = awaitPointerEvent() + } + alwaysOnRequester.cancelRequest() + } + } + .fillMaxWidth() + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 1 - 0.32f to Color.Transparent.copy(0.45f), + 1f to Color.Transparent.copy(0.72f), + ), + ), + ) { + Spacer(Modifier.height(if (expanded) 12.dp else 6.dp)) + Row( + Modifier.fillMaxWidth() + .windowInsetsPadding(contentWindowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)), + verticalAlignment = Alignment.CenterVertically, + ) { + MaterialTheme(aniDarkColorTheme()) { + CompositionLocalProvider(LocalContentColor provides Color.White) { + bottomBar() + } + } + } + } + + } + AnimatedVisibility( + visible = controllerState.visibility.detachedSlider && !gestureLockedState, + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + Modifier.padding(horizontal = 4.dp, vertical = 12.dp) + .windowInsetsPadding(contentWindowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)), + ) { + MaterialTheme(aniDarkColorTheme()) { + detachedProgressSlider() + } + } + } + } + } + AnimatedVisibility( + controllerState.visibility.floatingBottomEnd && !expanded, + Modifier.align(Alignment.BottomEnd), + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + Modifier.padding(horizontal = 4.dp, vertical = 2.dp) + .windowInsetsPadding(contentWindowInsets.only(WindowInsetsSides.End)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + MaterialTheme(aniDarkColorTheme()) { + CompositionLocalProvider(LocalContentColor provides Color.White) { + floatingBottomEnd() + } + } + } + } + } + Column( + Modifier.fillMaxSize().background(Color.Transparent) + .windowInsetsPadding(contentWindowInsets.only(WindowInsetsSides.End)), + ) { + Box(Modifier.weight(1f, fill = true).fillMaxWidth()) { + Column( + Modifier.padding(end = 16.dp).align(Alignment.CenterEnd), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AnimatedVisibility( + visible = controllerState.visibility.rhsBar && !gestureLockedState, + enter = fadeIn(), + exit = fadeOut(), + ) { + rhsButtons() + } + + // Separate from controllers, to fix position when controllers are/aren't hidden + AnimatedVisibility( + visible = controllerState.visibility.rhsBar, + enter = fadeIn(), + exit = fadeOut(), + ) { + gestureLock() + } + } + } + } + + Box(Modifier.matchParentSize()) { + Column(Modifier.windowInsetsPadding(contentWindowInsets)) { + Box(Modifier.weight(0.5f)) + Row( + Modifier.weight(0.5f), + verticalAlignment = Alignment.CenterVertically, + ) { + leftBottomTips() + } + } + } + // 悬浮消息, 例如正在缓冲 + Box( + Modifier.matchParentSize().windowInsetsPadding(contentWindowInsets), + contentAlignment = Alignment.Center, + ) { + ProvideTextStyle(MaterialTheme.typography.labelSmall) { + CompositionLocalProvider(LocalContentColor provides aniDarkColorTheme().onBackground.slightlyWeaken()) { + floatingMessage() + } + } + } + + // 右侧 sheet + Box(Modifier.matchParentSize().windowInsetsPadding(contentWindowInsets)) { + MaterialTheme(aniDarkColorTheme()) { + rhsSheet() + } + } + } + } +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/FastSkipState.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/FastSkipState.kt new file mode 100644 index 0000000..9ca4a0c --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/FastSkipState.kt @@ -0,0 +1,158 @@ +package org.openani.mediamp.ui.guesture + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.openani.mediamp.ui.state.PlayerState + +@Composable +fun rememberPlayerFastSkipState( + playerState: PlayerState, + gestureIndicatorState: GestureIndicatorState, +): FastSkipState { + return remember(playerState) { + PlayerFastSkipState(playerState, gestureIndicatorState).fastSkipState + } +} + +class PlayerFastSkipState( + private val playerState: PlayerState, + private val gestureIndicatorState: GestureIndicatorState, +) { + private var originalSpeed = 0f + private var gestureIndicatorTicket = 0 + val fastSkipState: FastSkipState = FastSkipState( + onStart = { skipDirection -> + originalSpeed = playerState.playbackSpeed.value + playerState.setPlaybackSpeed( + when (skipDirection) { + SkipDirection.FORWARD -> 3f + SkipDirection.BACKWARD -> error("Backward skipping is not supported") + }, + ) + gestureIndicatorTicket = gestureIndicatorState.startFastForward() + }, + onStop = { + playerState.setPlaybackSpeed(originalSpeed) + gestureIndicatorState.stopFastForward(gestureIndicatorTicket) + }, + ) +} + +@Stable +class FastSkipState( + private val onStart: (skipDirection: SkipDirection) -> Unit, + private val onStop: () -> Unit, +) { + var skippingDirection: SkipDirection? by mutableStateOf(null) + + var ticket: Int = 0 + + fun startSkipping(direction: SkipDirection): Int { + skippingDirection = direction + onStart(direction) + return ++ticket + } + + fun stopSkipping(ticket: Int) { + if (ticket == this.ticket) { + skippingDirection = null + onStop() + } + } +} + +enum class SkipDirection { + FORWARD, BACKWARD +} + +fun Modifier.longPressFastSkip( + state: FastSkipState, + direction: SkipDirection, +): Modifier { + var ticket = 0 + return detectLongPressGesture( + onStart = { + ticket = state.startSkipping(direction) + }, + onEnd = { + state.stopSkipping(ticket) + }, + ) +} +// pointerInput(Unit) { +// detectLongPressGesture() +//// detectTapGestures( +//// onPress = { +//// val ticket = state.startSkipping(direction) +//// awaitPointerEventScope { +//// var event = awaitPointerEvent() +//// while (event.changes.any { it.pressed }) { +//// event = awaitPointerEvent() +//// } +//// +//// state.stopSkipping(ticket) +//// } +//// } +//// ) +//} + +fun Modifier.detectLongPressGesture( + onStart: () -> Unit, + onEnd: () -> Unit, + longPressTimeout: Long = 500L +): Modifier = pointerInput(Unit) { + coroutineScope { + val touchSlop = viewConfiguration.touchSlop + var isLongPressDetected = false + + awaitEachGesture { + val initialPosition = awaitFirstDown(requireUnconsumed = false).position + // note: we don't consume the down event + + // Starts a job to mark long press detected if the user does not move the pointer, + // i.e. is holding at the same position for a certain time). + val longPressJob = launch { + delay(longPressTimeout) + onStart() + isLongPressDetected = true + } + + var change = awaitPointerEvent() + while (change.changes.any { it.pressed }) { // Pointer is still down + val pointer = change.changes[0] + if (isLongPressDetected) { + // Consume all events so that we won't trigger other gestures like swiping + change.changes.forEach { it.consume() } + } + if ((pointer.position - initialPosition).getDistance() > touchSlop) { + // User is swiping. + // Note, this can also happen if the long press has already been detected. + longPressJob.cancel() // Stop detecting long press if it hasn't been detected yet + } + change = awaitPointerEvent() + } + // Not pressing anymore + if (isLongPressDetected) { + // Consume the pointer up event + change.changes.forEach { it.consume() } + } + + longPressJob.cancel() + if (isLongPressDetected) { + onEnd() + isLongPressDetected = false + } + } + } +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/GestureLock.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/GestureLock.kt new file mode 100644 index 0000000..610ee5a --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/GestureLock.kt @@ -0,0 +1,164 @@ +package org.openani.mediamp.ui.guesture + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.LockOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import me.him188.ani.app.ui.foundation.LocalPlatform +import me.him188.ani.app.ui.foundation.theme.aniDarkColorTheme +import me.him188.ani.app.ui.foundation.theme.aniLightColorTheme +import me.him188.ani.app.ui.foundation.theme.slightlyWeaken +import org.openani.mediamp.ui.ControllerVisibility +import org.openani.mediamp.ui.VideoControllerState +import org.openani.mediamp.ui.progress.MediaProgressSliderState +import org.openani.mediamp.ui.state.PlayerState +import kotlin.time.Duration.Companion.seconds + +@Composable +fun GestureLock( + isLocked: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { +// val background = aniDarkColorTheme().onSurface +// SmallFloatingActionButton( +// onClick = onClick, +// modifier = modifier, +// containerColor = background, +// ) { +// CompositionLocalProvider(LocalContentColor provides aniDarkColorTheme().contentColorFor(background)) { +// if (isLocked) { +// Icon(Icons.Outlined.LockOpen, contentDescription = "Lock screen") +// } else { +// Icon(Icons.Outlined.Lock, contentDescription = "Unlock screen") +// } +// } +// } + Surface( + modifier, + shape = RoundedCornerShape(16.dp), + color = aniDarkColorTheme().background.copy(0.05f), + border = BorderStroke(0.5.dp, aniLightColorTheme().outline.slightlyWeaken()), + ) { + IconButton(onClick) { + val color = if (isLocked) { + aniDarkColorTheme().primary + } else { + Color.White + } + CompositionLocalProvider(LocalContentColor provides color) { + if (isLocked) { + Icon(Icons.Outlined.Lock, contentDescription = "UnLock screen") + } else { + Icon(Icons.Outlined.LockOpen, contentDescription = "Lock screen") + } + } + } + } +// Surface( +// modifier, +// shape = MaterialTheme.shapes.small, +// shadowElevation = 1.dp, +// ) { +// IconButton( +// onClick = onClick, +// ) { +// if (isLocked) { +// Icon(Icons.Rounded.Lock, contentDescription = "Lock screen") +// } else { +// Icon(Icons.Rounded.LockOpen, contentDescription = "Unlock screen") +// } +// } +// } +} + +/** + * Handles click events and auto-hide controller. + * + * @see LockableVideoGestureHost + */ +@Composable +fun LockedScreenGestureHost( + controllerVisibility: () -> ControllerVisibility, + setFullVisible: (visible: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier + .clickable( + remember { MutableInteractionSource() }, + indication = null, + onClick = { setFullVisible(true) }, + ).fillMaxSize(), + ) + + if (controllerVisibility() == ControllerVisibility.Visible) { + LaunchedEffect(true) { + delay(2.seconds) + setFullVisible(false) + } + } + return +} + + +@Composable +fun LockableVideoGestureHost( + controllerState: VideoControllerState, + seekerState: SwipeSeekerState, + progressSliderState: MediaProgressSliderState, + indicatorState: GestureIndicatorState, + fastSkipState: FastSkipState, + playerState: PlayerState, + locked: Boolean, + enableSwipeToSeek: Boolean, + audioController: LevelController, + brightnessController: LevelController, + modifier: Modifier = Modifier, + onTogglePauseResume: () -> Unit = {}, + onToggleFullscreen: () -> Unit = {}, + onExitFullscreen: () -> Unit = {}, + family: GestureFamily = LocalPlatform.current.mouseFamily, +) { + if (locked) { + LockedScreenGestureHost( + { controllerState.visibility }, + controllerState.setFullVisible, + modifier.testTag("LockedScreenGestureHost"), + ) + } else { + VideoGestureHost( + controllerState, + seekerState, + progressSliderState, + indicatorState, + fastSkipState, + playerState, + enableSwipeToSeek = enableSwipeToSeek, + audioController = audioController, + brightnessController = brightnessController, + onTogglePauseResume = onTogglePauseResume, + onToggleFullscreen = onToggleFullscreen, + onExitFullscreen = onExitFullscreen, + family = family, + ) + } +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/KeyboardSeek.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/KeyboardSeek.kt new file mode 100644 index 0000000..234e2a1 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/KeyboardSeek.kt @@ -0,0 +1,55 @@ +package org.openani.mediamp.ui.guesture + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import me.him188.ani.app.ui.foundation.effects.ComposeKey +import me.him188.ani.app.ui.foundation.effects.onKey + +@Stable +class KeyboardHorizontalDirectionState( + val onBackward: () -> Unit, + val onForward: () -> Unit, +) + + +fun Modifier.onKeyboardHorizontalDirection( + state: KeyboardHorizontalDirectionState, +): Modifier = onKeyboardHorizontalDirection( + onBackward = state.onBackward, + onForward = state.onForward, +) + +fun Modifier.onKeyboardHorizontalDirection( + onBackward: () -> Unit, + onForward: () -> Unit, +): Modifier = composed( + inspectorInfo = { + name = "keyboardSeek" + }, +) { + val layoutDirection = LocalLayoutDirection.current + val backwardKey = if (layoutDirection == LayoutDirection.Ltr) { + ComposeKey.DirectionLeft + } else { + ComposeKey.DirectionRight + } + val forwardKey = if (layoutDirection == LayoutDirection.Ltr) { + ComposeKey.DirectionRight + } else { + ComposeKey.DirectionLeft + } + + val onBackwardState by rememberUpdatedState(onBackward) + val onForwardState by rememberUpdatedState(onForward) + onKey(backwardKey) { + onBackwardState() + }.onKey(forwardKey) { + onForwardState() + } +} + diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/PlayerFloatingButtonBox.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/PlayerFloatingButtonBox.kt new file mode 100644 index 0000000..438f817 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/PlayerFloatingButtonBox.kt @@ -0,0 +1,28 @@ +package org.openani.mediamp.ui.guesture + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.theme.aniDarkColorTheme +import me.him188.ani.app.ui.foundation.theme.aniLightColorTheme +import me.him188.ani.app.ui.foundation.theme.slightlyWeaken + +@Composable +fun PlayerFloatingButtonBox( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Surface( + modifier, + shape = RoundedCornerShape(16.dp), + color = aniDarkColorTheme().background.copy(0.05f), + contentColor = Color.White, + border = BorderStroke(0.5.dp, aniLightColorTheme().outline.slightlyWeaken()), + ) { + content() + } +} \ No newline at end of file diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/ScreenshotButton.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/ScreenshotButton.kt new file mode 100644 index 0000000..be0b8da --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/ScreenshotButton.kt @@ -0,0 +1,30 @@ +package org.openani.mediamp.ui.guesture + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PhotoCamera +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun ScreenshotButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + PlayerFloatingButtonBox( + modifier = modifier, + content = { + IconButton(onClick) { + val color = Color.White + CompositionLocalProvider(LocalContentColor provides color) { + Icon(Icons.Rounded.PhotoCamera, contentDescription = "Lock screen") + } + } + }, + ) +} + diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/SteppedDraggable.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/SteppedDraggable.kt new file mode 100644 index 0000000..e71544c --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/SteppedDraggable.kt @@ -0,0 +1,182 @@ +package org.openani.mediamp.ui.guesture + +import androidx.annotation.MainThread +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.DragScope +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.CoroutineScope + + +interface SteppedDraggableState : DraggableState { + fun onDragStarted(offset: Offset, orientation: Orientation) + fun onDragStopped(velocity: Float) +} + +enum class StepDirection { + /** + * - [Orientation.Horizontal]: To the right + * - [Orientation.Vertical]: Down + */ + FORWARD, + BACKWARD, +} + +private class SteppedDraggableStateImpl( + @MainThread private val onStep: (StepDirection) -> Unit, + private val stepSizePx: Float, +) : SteppedDraggableState { + var startOffset: Float by mutableFloatStateOf(Float.NaN) + var currentOffset: Float by mutableFloatStateOf(0f) + var lastCallbackOffset: Float by mutableFloatStateOf(0f) + override fun onDragStarted(offset: Offset, orientation: Orientation) { + startOffset = if (orientation == Orientation.Horizontal) { + offset.x + } else { + offset.y + } + currentOffset = startOffset + } + + override fun onDragStopped(velocity: Float) { + startOffset = Float.NaN + currentOffset = 0f + } + + override fun dispatchRawDelta(delta: Float) { + draggableState.dispatchRawDelta(delta) + } + + override suspend fun drag(dragPriority: MutatePriority, block: suspend DragScope.() -> Unit) { + draggableState.drag(dragPriority, block) + } + + private val draggableState: DraggableState = DraggableState { delta -> + currentOffset += delta + val deltaOffset = currentOffset - startOffset + val step = (deltaOffset / stepSizePx).toInt() + val callbackOffset = step * stepSizePx + if (callbackOffset != lastCallbackOffset) { + if (callbackOffset > lastCallbackOffset) { + onStep(StepDirection.BACKWARD) // delta is inverted + } else { + onStep(StepDirection.FORWARD) + } + lastCallbackOffset = callbackOffset + } + } +} + +@Composable +fun rememberSteppedDraggableState( + stepSize: Dp, + @MainThread onStep: (StepDirection) -> Unit, +): SteppedDraggableState { + val onStepState by rememberUpdatedState(onStep) + val stepSizePx by rememberUpdatedState(with(LocalDensity.current) { stepSize.toPx() }) + return remember { + SteppedDraggableStateImpl( + onStep = { onStepState(it) }, + stepSizePx = stepSizePx, + ) + } +} + +fun Modifier.steppedDraggable( + state: SteppedDraggableState, + orientation: Orientation, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + startDragImmediately: Boolean = false, + onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, + onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, + reverseDirection: Boolean = false, +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "steppedDraggable" + properties["state"] = state + properties["orientation"] = orientation + properties["enabled"] = enabled + properties["interactionSource"] = interactionSource + properties["startDragImmediately"] = startDragImmediately + properties["onDragStarted"] = onDragStarted + properties["onDragStopped"] = onDragStopped + properties["reverseDirection"] = reverseDirection + }, +) { + val onDragStartedState by rememberUpdatedState(onDragStarted) + val onDragStoppedState by rememberUpdatedState(onDragStopped) + draggable( + state = state, + orientation = orientation, + enabled = enabled, + interactionSource = interactionSource, + startDragImmediately = startDragImmediately, + onDragStarted = { offset -> + state.onDragStarted(offset, orientation) + onDragStartedState(offset) + }, + onDragStopped = { + state.onDragStopped(it) + onDragStoppedState(it) + }, + reverseDirection = reverseDirection, + ) +} + + +//fun Modifier.combinedSteppedDraggable( +// division: List>, +// orientation: Orientation, +// enabled: Boolean = true, +// interactionSource: MutableInteractionSource? = null, +// startDragImmediately: Boolean = false, +// onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, +// onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, +// reverseDirection: Boolean = false, +//): Modifier = composed( +// inspectorInfo = debugInspectorInfo { +// name = "steppedDraggable" +// properties["division"] = division +// properties["orientation"] = orientation +// properties["enabled"] = enabled +// properties["interactionSource"] = interactionSource +// properties["startDragImmediately"] = startDragImmediately +// properties["onDragStarted"] = onDragStarted +// properties["onDragStopped"] = onDragStopped +// properties["reverseDirection"] = reverseDirection +// } +//) { +// val onDragStartedState by rememberUpdatedState(onDragStarted) +// val onDragStoppedState by rememberUpdatedState(onDragStopped) +// draggable( +// state = state.draggableState, +// orientation = orientation, +// enabled = enabled, +// interactionSource = interactionSource, +// startDragImmediately = startDragImmediately, +// onDragStarted = { offset -> +// state.onDragStarted(offset, orientation) +// onDragStartedState(offset) +// }, +// onDragStopped = { +// state.onDragStopped(it) +// onDragStoppedState(it) +// }, +// reverseDirection = reverseDirection, +// ) +//} diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeSeekerState.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeSeekerState.kt new file mode 100644 index 0000000..98ffd4a --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeSeekerState.kt @@ -0,0 +1,157 @@ +package org.openani.mediamp.ui.guesture + +import androidx.annotation.UiThread +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import kotlinx.coroutines.CoroutineScope +import kotlin.math.roundToInt + + +@Composable +fun rememberSwipeSeekerState( + screenWidthPx: Int, + swipeSeekerConfig: SwipeSeekerConfig = SwipeSeekerConfig.Default, + @UiThread onSeek: (offsetSeconds: Int) -> Unit, +): SwipeSeekerState { + val onSeekState by rememberUpdatedState(onSeek) + return remember(swipeSeekerConfig, screenWidthPx) { + SwipeSeekerState( + screenWidthPx, + swipeSeekerConfig, + ) { onSeekState(it) } + } +} + +@Immutable +data class SwipeSeekerConfig( + /** + * 从屏幕左边滑到屏幕的最右边的最大距离 + */ + val maxDragDelta: Float = 0f, + /** + * 从屏幕左边滑到屏幕的最右边会跳转的秒数 + */ + // 设计上是从左到右 90 秒正好跳过 op/ed, 而全面屏手机有全面屏手势, + // 用户不能从最左边开始滑. 因此稍微留了点余量. + // 实测差不多可以滑到 87 秒, 看三秒 op 让他知道他完了 op + val maxDragSeconds: Int = 97, +) { + companion object { + val Default = SwipeSeekerConfig() + } +} + +@Stable +class SwipeSeekerState( + /** + * 可滑动区域宽度 + */ + private val screenWidthPx: Int, + private val swipeSeekerConfig: SwipeSeekerConfig = SwipeSeekerConfig.Default, + /** + * 当一次滑动结束时的回调. `offsetSeconds` 为本次快进的秒数 + */ + @UiThread val onSeek: (offsetSeconds: Int) -> Unit, +) { + /** + * [Float.NaN] iff not dragging + */ + private var seekDelta: Float by mutableFloatStateOf(Float.NaN) + + @UiThread + private fun onSwipeStarted() { + seekDelta = 0f + } + + @UiThread + private fun onSwipeStopped() { + if (seekDelta.isNaN()) return + onSeek(deltaSeconds) + seekDelta = Float.NaN + } + + @UiThread + private fun onSwipeOffset(offsetPx: Float) { + seekDelta += offsetPx + } + + /** + * 是否正在快进, 即用户是否正在滑动屏幕 + */ + val isSeeking: Boolean by derivedStateOf { + !seekDelta.isNaN() + } + + /** + * 当前正在快进的秒数. + * + * 当用户手指在屏幕上滑动时, [deltaSeconds] 将更新, 反映假如用户此时松开手指, 将会跳转的秒数. + * - 若用户从屏幕左边滑到屏幕的右边, [deltaSeconds] 将会是 [SwipeSeekerConfig.maxDragSeconds]. + * + * 当未在滑动时, [deltaSeconds] 为 `0`. + * + * 负数表示快退, 正数表示快进 + */ + val deltaSeconds: Int by derivedStateOf { + if (seekDelta.isNaN()) { + 0 + } else { + val percentage = seekDelta / screenWidthPx + (percentage * swipeSeekerConfig.maxDragSeconds).roundToInt() + } + } + + + companion object { + fun Modifier.swipeToSeek( + seekerState: SwipeSeekerState, + orientation: Orientation, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + reverseDirection: Boolean = false, + onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, + onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, + onDelta: (Float) -> Unit = {}, + ): Modifier { + return composed( + inspectorInfo = { + name = "videoSeeker" + properties["seekerState"] = seekerState + }, + ) { + draggable( + rememberDraggableState { + seekerState.onSwipeOffset(it) + onDelta(it) + }, + orientation, + onDragStarted = { + seekerState.onSwipeStarted() + onDragStarted(it) + }, + onDragStopped = { + seekerState.onSwipeStopped() + onDragStopped(it) + }, + enabled = enabled, + interactionSource = interactionSource, + reverseDirection = reverseDirection, + ) + } + } + } +} \ No newline at end of file diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeVolumeControl.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeVolumeControl.kt new file mode 100644 index 0000000..25fe211 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/SwipeVolumeControl.kt @@ -0,0 +1,129 @@ +package org.openani.mediamp.ui.guesture + +import androidx.annotation.MainThread +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.CoroutineScope +import me.him188.ani.app.platform.features.AudioManager +import me.him188.ani.app.platform.features.BrightnessManager +import me.him188.ani.app.platform.features.StreamType +import me.him188.ani.app.tools.MonoTasker + +interface LevelController { + val level: Float + + @MainThread + fun increaseLevel(step: Float = 0.05f) + + @MainThread + fun decreaseLevel(step: Float = 0.05f) +} + +object NoOpLevelController : LevelController { + override val level: Float + get() = 0f + + override fun increaseLevel(step: Float) { + } + + override fun decreaseLevel(step: Float) { + } +} + +fun AudioManager.asLevelController( + streamType: StreamType, +): LevelController = object : LevelController { + override val level: Float + get() = getVolume(streamType) + + override fun increaseLevel(step: Float) { + val current = getVolume(streamType) + setVolume(streamType, (current + step).coerceAtMost(1f)) + } + + override fun decreaseLevel(step: Float) { + val current = getVolume(streamType) + setVolume(streamType, (current - step).coerceAtLeast(0f)) + } +} + +fun BrightnessManager.asLevelController(): LevelController = object : LevelController { + override val level: Float + get() = getBrightness() + + override fun increaseLevel(step: Float) { + val current = getBrightness() + setBrightness((current + step).coerceAtMost(1f)) + } + + override fun decreaseLevel(step: Float) { + val current = getBrightness() + setBrightness((current - step).coerceAtLeast(0f)) + } +} + +fun Modifier.swipeLevelControlWithIndicator( + controller: LevelController, + stepSize: Dp, + orientation: Orientation, + indicatorState: GestureIndicatorState, + indicatorTasker: MonoTasker, + step: Float = 0.05f, + setup: () -> Unit = {} +): Modifier = this then swipeLevelControl( + controller = controller, stepSize = stepSize, orientation = orientation, step = step, + afterStep = { + indicatorTasker.launch { + setup() + indicatorState.progressValue = controller.level + } + }, + onDragStarted = { + indicatorTasker.launch { + indicatorState.visible = true + } + }, + onDragStopped = { + indicatorTasker.launch { + indicatorState.visible = false + } + }, +) + +fun Modifier.swipeLevelControl( + controller: LevelController, + stepSize: Dp, + orientation: Orientation, + step: Float = 0.05f, + afterStep: (StepDirection) -> Unit = {}, + onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {}, + onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {}, +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "swipeLevelControl" + properties["controller"] = controller + properties["stepSize"] = stepSize + properties["orientation"] = orientation + }, +) { + steppedDraggable( + rememberSteppedDraggableState( + stepSize = stepSize, + onStep = { direction -> + when (direction) { + StepDirection.FORWARD -> controller.increaseLevel(step) + StepDirection.BACKWARD -> controller.decreaseLevel(step) + } + afterStep(direction) + }, + ), + orientation = orientation, + onDragStarted = onDragStarted, + onDragStopped = onDragStopped, + ) + +} \ No newline at end of file diff --git a/mediamp-core/src/commonMain/kotlin/ui/guesture/VideoGestureHost.kt b/mediamp-core/src/commonMain/kotlin/ui/guesture/VideoGestureHost.kt new file mode 100644 index 0000000..32e6844 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/guesture/VideoGestureHost.kt @@ -0,0 +1,829 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp.ui.guesture + +import androidx.annotation.UiThread +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemGestures +import androidx.compose.foundation.layout.systemGesturesPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.BrightnessHigh +import androidx.compose.material.icons.rounded.BrightnessLow +import androidx.compose.material.icons.rounded.BrightnessMedium +import androidx.compose.material.icons.rounded.FastForward +import androidx.compose.material.icons.rounded.FastRewind +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import me.him188.ani.app.tools.rememberUiMonoTasker +import me.him188.ani.app.ui.foundation.LocalPlatform +import me.him188.ani.app.ui.foundation.effects.ComposeKey +import me.him188.ani.app.ui.foundation.effects.onKey +import me.him188.ani.app.ui.foundation.effects.onPointerEventMultiplatform +import me.him188.ani.app.ui.foundation.ifThen +import me.him188.ani.app.ui.foundation.layout.isSystemInFullscreen +import me.him188.ani.app.ui.foundation.theme.aniDarkColorTheme +import me.him188.ani.app.utils.fixToString +import me.him188.ani.utils.platform.Platform +import org.openani.mediamp.ui.VideoControllerState +import org.openani.mediamp.ui.guesture.GestureIndicatorState.State.BRIGHTNESS +import org.openani.mediamp.ui.guesture.GestureIndicatorState.State.FAST_BACKWARD +import org.openani.mediamp.ui.guesture.GestureIndicatorState.State.FAST_FORWARD +import org.openani.mediamp.ui.guesture.GestureIndicatorState.State.PAUSED_ONCE +import org.openani.mediamp.ui.guesture.GestureIndicatorState.State.RESUMED_ONCE +import org.openani.mediamp.ui.guesture.GestureIndicatorState.State.SEEKING +import org.openani.mediamp.ui.guesture.GestureIndicatorState.State.VOLUME +import org.openani.mediamp.ui.guesture.SwipeSeekerState.Companion.swipeToSeek +import org.openani.mediamp.ui.progress.MediaProgressSliderState +import org.openani.mediamp.ui.rememberAlwaysOnRequester +import org.openani.mediamp.ui.state.PlayerState +import org.openani.mediamp.ui.state.SupportsAudio +import org.openani.mediamp.ui.top.needWorkaroundForFocusManager +import kotlin.math.absoluteValue +import kotlin.time.Duration.Companion.seconds + +@Stable +private fun renderTime(seconds: Int): String { + return "${(seconds / 60).fixToString(2)}:${(seconds % 60).fixToString(2)}" +} + +@Composable +fun rememberGestureIndicatorState(): GestureIndicatorState = remember { GestureIndicatorState() } + +@Stable +class GestureIndicatorState { + internal enum class State { + PAUSED_ONCE, + RESUMED_ONCE, + VOLUME, + BRIGHTNESS, + SEEKING, + FAST_FORWARD, + FAST_BACKWARD, + } + + internal var visible: Boolean by mutableStateOf(false) + internal var state: State? by mutableStateOf(null) + internal var progressValue: Float by mutableFloatStateOf(0f) + internal var deltaSeconds: Int by mutableIntStateOf(0) + private var counter: Int = 0 + + private inline fun startShow( + state: State, + setup: () -> Unit = {}, + ): Int { + val ticket = ++counter + setup() + this.state = state + visible = true + return ticket + } + + private inline fun show( + state: State, + setup: () -> Unit = {}, + action: () -> Unit + ) { + val ticket = ++counter + try { + setup() + this.state = state + visible = true + action() + } finally { + if (this.counter == ticket && // no one changed the state after us + this.state == state + ) { + visible = false + } + } + } + + private companion object { + private const val LONG: Long = 700 + private const val SHORT: Long = 500 + } + + @UiThread + suspend fun showPausedLong() { + show(PAUSED_ONCE) { + delay(LONG) + } + } + + @UiThread + suspend fun showResumedLong() { + show(RESUMED_ONCE) { + delay(LONG) + } + } + + @UiThread + suspend fun showVolumeRange(currentRatio: Float) { + show(VOLUME, setup = { progressValue = currentRatio }) { + delay(SHORT) + } + } + + @UiThread + suspend fun showBrightnessRange(currentRatio: Float) { + show(BRIGHTNESS, setup = { progressValue = currentRatio }) { + delay(SHORT) + } + } + + @UiThread + suspend fun showSeeking( + deltaSeconds: Int, + ) { + show(SEEKING, setup = { this.deltaSeconds = deltaSeconds }) { + delay(SHORT) + } + } + + @UiThread + fun startFastForward(): Int { + startShow(FAST_FORWARD, setup = { }) + return counter + } + + @UiThread + fun stopFastForward(ticket: Int) { + stopShow(ticket) + } + + @UiThread + fun startFastBackward(): Int { + startShow(FAST_BACKWARD, setup = { }) + return counter + } + + @UiThread + fun stopFastBackward(ticket: Int) { + stopShow(ticket) + } + + private fun stopShow(ticket: Int) { + if (ticket == this.counter) { + visible = false + } + } +} + +/** + * 展示当前快进/快退秒数的指示器. + * + * `<< 00:00` / `>> 00:00` + */ +@Composable +fun GestureIndicator( + state: GestureIndicatorState, +) { + val shape = MaterialTheme.shapes.small + val colors = aniDarkColorTheme() + var lastDelta by remember { + mutableIntStateOf(state.deltaSeconds) + } + + AnimatedVisibility( + visible = state.visible, + enter = fadeIn(spring(stiffness = Spring.StiffnessMedium)), + exit = fadeOut(tween(durationMillis = 500)), + label = "SeekPositionIndicator", + ) { + Surface( + Modifier.alpha(0.8f), + color = colors.surface, + shape = shape, + shadowElevation = 1.dp, + contentColor = colors.onSurface, + ) { + val iconSize = 36.dp + ProvideTextStyle(MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)) { + Row( + Modifier.background(Color.Transparent) + .padding(horizontal = 12.dp, vertical = 8.dp) + .height(iconSize), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Used by volume and brightness + val progressIndicator: @Composable () -> Unit = remember(state, colors) { + // This remember is needed because Compose does not remember lambdas + // and can cause performance problem in this fast-changing composable. + { + LinearProgressIndicator( + progress = { state.progressValue }, + modifier = Modifier.width(80.dp), + color = colors.primary, + trackColor = colors.onSurface.copy(alpha = 0.5f), + drawStopIndicator = {}, + ) + } + } + + when (state.state) { + RESUMED_ONCE -> { + Icon( + Icons.Rounded.PlayArrow, null, + Modifier.size(iconSize).background(Color.Transparent), + ) + } + + PAUSED_ONCE -> { + Icon(Icons.Rounded.Pause, null, Modifier.size(iconSize)) + } + + SEEKING -> { + val deltaDuration = state.deltaSeconds + // 记忆变为 0 之前的 delta, 这样在快进/快退结束后, 会显示上一次的 delta, 而不是显示 0 + val duration = if (deltaDuration == 0) { + lastDelta + } else { + deltaDuration.also { + lastDelta = deltaDuration + } + } + + Icon( + if (duration > 0) { + Icons.Rounded.FastForward + } else { + Icons.Rounded.FastRewind + }, + null, + Modifier.size(iconSize), + ) + val text = renderTime(duration.absoluteValue) + Text( + text, + maxLines = 1, + ) + } + + VOLUME -> { + Icon( + Icons.AutoMirrored.Rounded.VolumeUp, null, + Modifier.size(iconSize), + ) + progressIndicator() + } + + BRIGHTNESS -> { + Icon( + when (state.progressValue) { + in 0.67..1.0 -> Icons.Rounded.BrightnessHigh + in 0.33..0.67 -> Icons.Rounded.BrightnessMedium + else -> Icons.Rounded.BrightnessLow + }, + null, + Modifier.size(iconSize), + ) + progressIndicator() + } + + FAST_FORWARD -> { + Icon(Icons.Rounded.FastForward, null, Modifier.size(iconSize)) + } + + FAST_BACKWARD -> { + Icon(Icons.Rounded.FastForward, null, Modifier.size(iconSize)) + } + + null -> {} + } + } + } + } + } +} + +@Stable +val Platform.mouseFamily: GestureFamily + get() = when (this) { + is Platform.Desktop -> GestureFamily.MOUSE + is Platform.Android, is Platform.Ios -> GestureFamily.TOUCH + } + +@Immutable +enum class GestureFamily( + val useDesktopGestureLayoutWorkaround: Boolean, + val clickToPauseResume: Boolean, + val clickToToggleController: Boolean, + val doubleClickToFullscreen: Boolean, + val doubleClickToPauseResume: Boolean, + val swipeToSeek: Boolean, + val swipeRhsForVolume: Boolean, + val swipeLhsForBrightness: Boolean, + val longPressForFastSkip: Boolean, + val scrollForVolume: Boolean, + val autoHideController: Boolean, + val keyboardSpaceForPauseResume: Boolean = true, + val keyboardUpDownForVolume: Boolean = true, + val keyboardLeftRightToSeek: Boolean = true, + val mouseHoverForController: Boolean = true, // not supported on mobile + val escToExitFullscreen: Boolean = true, +) { + TOUCH( + useDesktopGestureLayoutWorkaround = false, + clickToPauseResume = false, + clickToToggleController = true, + doubleClickToFullscreen = false, + doubleClickToPauseResume = true, + swipeToSeek = true, + swipeRhsForVolume = true, + swipeLhsForBrightness = true, + longPressForFastSkip = true, + mouseHoverForController = false, + scrollForVolume = false, + autoHideController = true, + ), + MOUSE( + useDesktopGestureLayoutWorkaround = true, + clickToPauseResume = true, + clickToToggleController = false, + doubleClickToFullscreen = true, + doubleClickToPauseResume = false, + swipeToSeek = false, + swipeRhsForVolume = false, + swipeLhsForBrightness = false, + longPressForFastSkip = false, + scrollForVolume = true, + autoHideController = false, + ) +} + +val VIDEO_GESTURE_MOUSE_MOVE_SHOW_CONTROLLER_DURATION = 3.seconds +val VIDEO_GESTURE_TOUCH_SHOW_CONTROLLER_DURATION = 3.seconds + +@Composable +fun VideoGestureHost( + controllerState: VideoControllerState, + seekerState: SwipeSeekerState, + progressSliderState: MediaProgressSliderState, + indicatorState: GestureIndicatorState, + fastSkipState: FastSkipState, + playerState: PlayerState, + enableSwipeToSeek: Boolean, + audioController: LevelController, + brightnessController: LevelController, + modifier: Modifier = Modifier, + family: GestureFamily = LocalPlatform.current.mouseFamily, + onTogglePauseResume: () -> Unit = {}, + onToggleFullscreen: () -> Unit = {}, + onExitFullscreen: () -> Unit = {}, +) { + val onTogglePauseResumeState by rememberUpdatedState(onTogglePauseResume) + + BoxWithConstraints { + Row(Modifier.align(Alignment.TopCenter).padding(top = 80.dp)) { + LaunchedEffect(seekerState.deltaSeconds) { + if (seekerState.isSeeking) { + indicatorState.showSeeking(seekerState.deltaSeconds) + } + } + MaterialTheme(aniDarkColorTheme()) { + GestureIndicator(indicatorState) + } + } + val maxHeight = maxHeight + + + // TODO: 临时解决方案, 安卓和 PC 需要不同的组件层级关系才能实现各种快捷手势 + val needWorkaroundForFocusManager = needWorkaroundForFocusManager + if (family.useDesktopGestureLayoutWorkaround) { + val indicatorTasker = rememberUiMonoTasker() + val focusRequester = remember { FocusRequester() } + val manager = LocalFocusManager.current + val keyboardFocus = remember { FocusRequester() } // focus 了才能用键盘快捷键 + + Box( + modifier + .focusRequester(keyboardFocus) + .ifThen(family.swipeToSeek) { + swipeToSeek(seekerState, Orientation.Horizontal) + } + .ifThen(family.keyboardLeftRightToSeek) { + onKeyboardHorizontalDirection( + onBackward = { + seekerState.onSeek(-5) + }, + onForward = { + seekerState.onSeek(5) + }, + ) + } + .ifThen(family.keyboardUpDownForVolume && playerState is SupportsAudio) { + if (playerState !is SupportsAudio) { + return@ifThen this + } + onKeyEvent { + if (it.type == KeyEventType.KeyUp) return@onKeyEvent false + val consumed = when { + it.isShiftPressed && it.key == ComposeKey.DirectionUp -> { + playerState.volumeUp(0.01f) + true + } + + it.isShiftPressed && it.key == ComposeKey.DirectionDown -> { + playerState.volumeDown(0.01f) + true + } + + it.key == ComposeKey.DirectionUp -> { + playerState.volumeUp() + true + } + + it.key == ComposeKey.DirectionDown -> { + playerState.volumeDown() + true + } + + else -> false + } + if (consumed) { + playerState.toggleMute(false) + indicatorTasker.launch { + indicatorState.showVolumeRange(playerState.volume.value / playerState.maxValue) + } + } + consumed + } + } + .ifThen(family.keyboardSpaceForPauseResume) { + onKey(ComposeKey.Spacebar) { + onTogglePauseResumeState() + } + } + .ifThen(family.mouseHoverForController) { + val scope = rememberUiMonoTasker() + // 没有人请求 alwaysOn 时自动隐藏控制器 + LaunchedEffect(true) { + snapshotFlow { controllerState.alwaysOn }.collect { + if (!it) { + controllerState.toggleFullVisible(true) + keyboardFocus.requestFocus() + scope.launch { + delay(VIDEO_GESTURE_MOUSE_MOVE_SHOW_CONTROLLER_DURATION) + controllerState.toggleFullVisible(false) + } + } + } + } + // 这里不能用 hover, 因为在当控制器隐藏后, hover 状态仍然有, 于是下次移动鼠标时不会重复触发 hover 事件, 也就无法显示 + // See test case: `mouse - mouseHoverForController - center screen twice` + onPointerEventMultiplatform(PointerEventType.Move) { _ -> + controllerState.toggleFullVisible(true) + keyboardFocus.requestFocus() + scope.launch { + delay(VIDEO_GESTURE_MOUSE_MOVE_SHOW_CONTROLLER_DURATION) + controllerState.toggleFullVisible(false) + } + } + } + .ifThen(family.escToExitFullscreen) { + onKey(ComposeKey.Escape) { + if (needWorkaroundForFocusManager) { + manager.clearFocus() + } + onExitFullscreen() + } + }.ifThen(family.scrollForVolume && playerState is SupportsAudio) { + if (playerState !is SupportsAudio) { + return@ifThen this + } + onPointerEventMultiplatform(PointerEventType.Scroll) { event -> + event.changes.firstOrNull()?.scrollDelta?.y?.run { + playerState.toggleMute(false) + if (this < 0) playerState.volumeUp() + else if (this > 0) playerState.volumeDown() + + indicatorTasker.launch { + indicatorState.showVolumeRange(playerState.volume.value / playerState.maxValue) + } + } + } + } + .fillMaxSize(), + ) { + Box( + Modifier + .ifThen(needWorkaroundForFocusManager) { + onFocusEvent { + if (it.hasFocus) { + focusRequester.requestFocus() + } + } + } + .matchParentSize() + .combinedClickable( + remember { MutableInteractionSource() }, + indication = null, + onClick = remember(family) { + { + if (family.clickToPauseResume) { + onTogglePauseResumeState() + } + if (family.clickToToggleController) { + controllerState.toggleFullVisible() + } + } + }, + onDoubleClick = remember(family, onToggleFullscreen) { + { + if (needWorkaroundForFocusManager) { + manager.clearFocus() + } + if (family.doubleClickToFullscreen) { + onToggleFullscreen() + } + if (family.doubleClickToPauseResume) { + onTogglePauseResumeState() + } + } + }, + ), + + ) + + Row(Modifier.focusRequester(focusRequester).matchParentSize()) { + Box( + Modifier + .ifThen(family.swipeLhsForBrightness) { + swipeLevelControlWithIndicator( + brightnessController, + ((maxHeight - 100.dp) / 40).coerceAtLeast(2.dp), + Orientation.Vertical, + indicatorState, + indicatorTasker, + step = 0.01f, + setup = { + indicatorState.state = BRIGHTNESS + }, + ) + } + .weight(1f) + .fillMaxHeight(), + ) + + Box(Modifier.weight(1f).fillMaxHeight()) + + Box( + Modifier + .ifThen(family.swipeRhsForVolume) { + swipeLevelControlWithIndicator( + audioController, + ((maxHeight - 100.dp) / 40).coerceAtLeast(2.dp), + Orientation.Vertical, + indicatorState, + indicatorTasker, + step = 0.05f, + setup = { + indicatorState.state = VOLUME + }, + ) + } + .weight(1f) + .fillMaxHeight(), + ) + } + + SideEffect { + focusRequester.requestFocus() + } + } + } else { + + val indicatorTasker = rememberUiMonoTasker() + val focusManager by rememberUpdatedState(LocalFocusManager.current) // workaround for #288 + + if (family.autoHideController) { + LaunchedEffect(controllerState.visibility, controllerState.alwaysOn) { + if (controllerState.alwaysOn) return@LaunchedEffect + if (controllerState.visibility.bottomBar) { + delay(VIDEO_GESTURE_TOUCH_SHOW_CONTROLLER_DURATION) + controllerState.toggleFullVisible(false) + } + } + } + + @Composable + fun Modifier.combineClickableWithFamilyGesture() = this then + combinedClickable( + remember { MutableInteractionSource() }, + indication = null, + onClick = remember(family) { + { + if (family.clickToPauseResume) { + onTogglePauseResumeState() + } + if (family.clickToToggleController) { + focusManager.clearFocus() + controllerState.toggleFullVisible() + } + } + }, + onDoubleClick = remember(family, onToggleFullscreen) { + { + if (family.doubleClickToFullscreen) { + onToggleFullscreen() + } + if (family.doubleClickToPauseResume) { + onTogglePauseResumeState() + } + } + }, + ) + Box( + modifier + .testTag("VideoGestureHost") + .ifThen(needWorkaroundForFocusManager) { + onFocusEvent { + if (it.hasFocus) { + focusManager.clearFocus() + } + } + } + .combineClickableWithFamilyGesture() + .ifThen(family.swipeToSeek && enableSwipeToSeek) { + val swipeToSeekRequester = rememberAlwaysOnRequester(controllerState, "swipeToSeek") + swipeToSeek( + seekerState, + Orientation.Horizontal, + onDragStarted = { + if (controllerState.visibility.bottomBar) { + swipeToSeekRequester.request() + } + controllerState.setRequestProgressBar(swipeToSeekRequester) + }, + onDragStopped = { + if (controllerState.visibility.bottomBar) { + swipeToSeekRequester.cancelRequest() + } + controllerState.cancelRequestProgressBarVisible(swipeToSeekRequester) + progressSliderState.finishPreview() + }, + ) { + progressSliderState.run { + if (totalDurationMillis == 0L) return@run + val offsetRatio = + (currentPositionMillis + seekerState.deltaSeconds.times(1000)).toFloat() / totalDurationMillis + previewPositionRatio(offsetRatio.coerceIn(0f, 1f)) + } + } + } + .ifThen(family.keyboardLeftRightToSeek) { + onKeyboardHorizontalDirection( + onBackward = { + seekerState.onSeek(-5) + }, + onForward = { + seekerState.onSeek(5) + }, + ) + } + .ifThen(family.keyboardUpDownForVolume) { + audioController?.let { controller -> + onKey(ComposeKey.DirectionUp) { + controller.increaseLevel(0.10f) + } + onKey(ComposeKey.DirectionDown) { + controller.decreaseLevel(0.10f) + } + } + } + .ifThen(family.keyboardSpaceForPauseResume) { + onKey(ComposeKey.Spacebar) { + onTogglePauseResumeState() + } + } + .fillMaxSize(), + ) { + Row( + Modifier.matchParentSize() + .systemGesturesPadding() + .ifThen(family.longPressForFastSkip) { + longPressFastSkip(fastSkipState, SkipDirection.FORWARD) + }, + ) { + Box( + Modifier + .ifThen(family.swipeLhsForBrightness) { + swipeLevelControlWithIndicator( + brightnessController, + ((maxHeight - 100.dp) / 40).coerceAtLeast(2.dp), + Orientation.Vertical, + indicatorState, + indicatorTasker, + step = 0.01f, + setup = { + indicatorState.state = BRIGHTNESS + }, + ) + } + .weight(1f) + .fillMaxHeight(), + ) + + Box(Modifier.weight(1f).fillMaxHeight()) + + Box( + Modifier + .ifThen(family.swipeRhsForVolume) { + swipeLevelControlWithIndicator( + audioController, + ((maxHeight - 100.dp) / 40).coerceAtLeast(2.dp), + Orientation.Vertical, + indicatorState, + indicatorTasker, + step = 0.05f, + setup = { + indicatorState.state = VOLUME + }, + ) + } + .weight(1f) + .fillMaxHeight(), + ) + } + } + + // 状态栏区域响应点击手势 + Box( + Modifier.fillMaxWidth() + .ifThen(isSystemInFullscreen()) { + windowInsetsTopHeight(WindowInsets.systemGestures) + } + .combineClickableWithFamilyGesture(), + ) + } + } +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/AudioPresentation.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/AudioPresentation.kt new file mode 100644 index 0000000..424889c --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/AudioPresentation.kt @@ -0,0 +1,16 @@ +package org.openani.mediamp.ui.progress + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import org.openani.mediamp.ui.state.AudioTrack + +@Immutable +class AudioPresentation( + val audioTrack: AudioTrack, + val displayName: String, +) + +@Stable +val AudioTrack.audioName: String + get() = name ?: labels.firstOrNull()?.value ?: internalId + diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/AudioSwitcher.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/AudioSwitcher.kt new file mode 100644 index 0000000..5c2005f --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/AudioSwitcher.kt @@ -0,0 +1,110 @@ +package org.openani.mediamp.ui.progress + +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import me.him188.ani.app.ui.foundation.AbstractViewModel +import me.him188.ani.app.ui.foundation.dialogs.PlatformPopupProperties +import org.openani.mediamp.ui.state.AudioTrack +import org.openani.mediamp.ui.state.TrackGroup + +@Stable +class AudioTrackState( + current: StateFlow, + candidates: Flow>, +) : AbstractViewModel() { + val options = candidates.map { tracks -> + tracks.map { track -> + AudioPresentation(track, track.audioName) + } + }.flowOn(Dispatchers.Default).shareInBackground() + + val value = combine(options, current) { options, current -> + options.firstOrNull { it.audioTrack.id == current?.id } + }.flowOn(Dispatchers.Default) +} + + +@Composable +fun PlayerControllerDefaults.AudioSwitcher( + playerState: TrackGroup, + modifier: Modifier = Modifier, + onSelect: (AudioTrack?) -> Unit = { playerState.select(it) }, +) { + val state = remember(playerState) { + AudioTrackState(playerState.current, playerState.candidates) + } + AudioSwitcher(state, onSelect, modifier) +} + +@Composable +fun PlayerControllerDefaults.AudioSwitcher( + state: AudioTrackState, + onSelect: (AudioTrack?) -> Unit, + modifier: Modifier = Modifier, +) { + val options by state.options.collectAsStateWithLifecycle(emptyList()) + AudioSwitcher( + value = state.value.collectAsStateWithLifecycle(null).value, + onValueChange = { onSelect(it?.audioTrack) }, + optionsProvider = { options }, + modifier, + ) +} + +/** + * 选音轨. + */ +@Composable +fun PlayerControllerDefaults.AudioSwitcher( + value: AudioPresentation?, + onValueChange: (AudioPresentation?) -> Unit, + optionsProvider: () -> List, + modifier: Modifier = Modifier, +) { + val optionsProviderUpdated by rememberUpdatedState(optionsProvider) + val options by remember { + derivedStateOf { + optionsProviderUpdated() + null + } + } + if (options.size <= 2) return // 1 for `null`, 只有一个的时候也不要显示 + return OptionsSwitcher( + value = value, + onValueChange = onValueChange, + optionsProvider = { options }, + renderValue = { + if (it == null) { + Text("自动") + } else { + Text(it.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, + renderValueExposed = { + Text( + remember(it) { it?.displayName ?: "音轨" }, + Modifier.widthIn(max = 64.dp), + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + }, + modifier, + properties = PlatformPopupProperties( + clippingEnabled = false, + ), + ) +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressIndicatorText.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressIndicatorText.kt new file mode 100644 index 0000000..61952c2 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressIndicatorText.kt @@ -0,0 +1,116 @@ +package org.openani.mediamp.ui.progress + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.testTag +import kotlin.math.roundToLong + +const val TAG_MEDIA_PROGRESS_INDICATOR_TEXT = "MediaProgressIndicatorText" + +/** + * "88:88:88 / 88:88:88" + */ +@Composable +fun MediaProgressIndicatorText( + state: MediaProgressSliderState, + modifier: Modifier = Modifier +) { + val text by remember(state) { + derivedStateOf { + val currentPositionMillis = if (state.isPreviewing) { + state.displayPositionRatio.times(state.totalDurationMillis).roundToLong() + } else { + state.currentPositionMillis + } + + renderSeconds(currentPositionMillis / 1000, state.totalDurationMillis / 1000) + } + } + val reserve by remember(state) { + derivedStateOf { + renderSecondsReserve(state.totalDurationMillis / 1000) + } + } + Box(modifier, contentAlignment = Alignment.Center) { + Text(reserve, Modifier.alpha(0f)) // fix width + Text( + text = text, + style = LocalTextStyle.current.copy( + color = Color.DarkGray, + drawStyle = Stroke( + miter = 3f, + width = 2f, + join = StrokeJoin.Round, + ), + ), + ) // border + Text( + text = text, + Modifier.testTag(TAG_MEDIA_PROGRESS_INDICATOR_TEXT), + ) + } +} + + +/** + * Returns the most wide text that [renderSeconds] may return for that [totalSecs]. This can be used to reserve space for the text. + */ +@Stable +private fun renderSecondsReserve( + totalSecs: Long? +): String = when (totalSecs) { // 8 is usually the visually widest character + null -> "88:88 / 88:88" + in 0..59 -> "88:88 / 88:88" + in 60..3599 -> "88:88 / 88:88" + in 3600..Int.MAX_VALUE -> "88:88:88 / 88:88:88" + else -> "88:88 / 88:88" +} + +/** + * Renders position into format like "888:88:88 / 888:88:88" (hours:minutes:seconds) + * @see renderSecondsReserve + */ +@Stable +internal fun renderSeconds(current: Long, total: Long?): String { + if (total == null) { + return "00:${current.fixToString(2)} / 00:00" + } + return if (current < 60 && total < 60) { + "00:${current.fixToString(2)} / 00:${total.fixToString(2)}" + } else if (current < 3600 && total < 3600) { + val startM = (current / 60).fixToString(2) + val startS = (current % 60).fixToString(2) + val endM = (total / 60).fixToString(2) + val endS = (total % 60).fixToString(2) + """$startM:$startS / $endM:$endS""" + } else { + val startH = (current / 3600).fixToString(2) + val startM = (current % 3600 / 60).fixToString(2) + val startS = (current % 60).fixToString(2) + val endH = (total / 3600).fixToString(2) + val endM = (total % 3600 / 60).fixToString(2) + val endS = (total % 60).fixToString(2) + """$startH:$startM:$startS / $endH:$endM:$endS""" + } +} + +private fun Long.fixToString(length: Int, prefix: Char = '0'): String { + val str = this.toString() + return if (str.length >= length) { + str + } else { + prefix.toString().repeat(length - str.length) + str + } +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressSlider.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressSlider.kt new file mode 100644 index 0000000..3b48130 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/MediaProgressSlider.kt @@ -0,0 +1,557 @@ +package org.openani.mediamp.ui.progress + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import me.him188.ani.app.ui.foundation.dialogs.PlatformPopupProperties +import me.him188.ani.app.ui.foundation.effects.onPointerEventMultiplatform +import me.him188.ani.app.ui.foundation.theme.slightlyWeaken +import me.him188.ani.app.ui.foundation.theme.weaken +import org.openani.mediamp.ui.state.Chapter +import org.openani.mediamp.ui.state.Chunk +import org.openani.mediamp.ui.state.ChunkState +import org.openani.mediamp.ui.state.MediaCacheProgressState +import org.openani.mediamp.ui.state.PlayerState +import kotlin.math.roundToInt +import kotlin.math.roundToLong + +const val TAG_PROGRESS_SLIDER_PREVIEW_POPUP = "ProgressSliderPreviewPopup" +const val TAG_PROGRESS_SLIDER = "ProgressSlider" + +/** + * 播放器进度滑块的状态. + * + * - 支持从 [currentPositionMillis] 同步当前播放位置, 从 [totalDurationMillis] 同步总时长. + * - 使用 [onPreview] 和 [onPreviewFinished] 来处理用户拖动进度条的事件. + * + * @see MediaProgressSlider + */ +@Stable +class MediaProgressSliderState( + currentPositionMillis: () -> Long, + totalDurationMillis: () -> Long, + chapters: State>, + /** + * 当用户正在拖动进度条时触发. 每有一个 change 都会调用. + */ + private val onPreview: (positionMillis: Long) -> Unit, + /** + * 当用户松开进度条时触发. 此时播放器应当要跳转到该位置. + */ + private val onPreviewFinished: (positionMillis: Long) -> Unit, +) { + val currentPositionMillis: Long by derivedStateOf(currentPositionMillis) + val totalDurationMillis: Long by derivedStateOf(totalDurationMillis) + val chapters by chapters + + private var previewPositionRatio: Float by mutableFloatStateOf(Float.NaN) + + val isPreviewing: Boolean by derivedStateOf { + !previewPositionRatio.isNaN() + } + + /** + * Sets the slider to move to the given position. + * [onPreview] will be triggered. + */ + fun previewPositionRatio(ratio: Float) { + previewPositionRatio = ratio + onPreview((totalDurationMillis * ratio).roundToLong()) + } + + /** + * The ratio of the current display position to the total duration. Range is `0..1` + */ + val displayPositionRatio by derivedStateOf { + val previewPositionRatio = this.previewPositionRatio + if (!previewPositionRatio.isNaN()) { + return@derivedStateOf previewPositionRatio + } + + val total = this.totalDurationMillis + if (total == 0L) { + return@derivedStateOf 0f + } + this.currentPositionMillis.toFloat() / total + } + + fun finishPreview() { + val ratio = this.previewPositionRatio + if (ratio.isNaN()) return + onPreviewFinished((ratio * totalDurationMillis).roundToLong()) + previewPositionRatio = Float.NaN + } +} + +/** + * 便捷方法, 从 [PlayerState.currentPositionMillis] 创建 [MediaProgressSliderState] + */ +@Composable +fun rememberMediaProgressSliderState( + playerState: PlayerState, + onPreview: (positionMillis: Long) -> Unit, + onPreviewFinished: (positionMillis: Long) -> Unit, +): MediaProgressSliderState { + val currentPosition by playerState.currentPositionMillis.collectAsStateWithLifecycle() + val videoProperties by playerState.videoProperties.collectAsStateWithLifecycle() + val totalDuration by remember { + derivedStateOf { + videoProperties?.durationMillis ?: 0L + } + } + + val onPreviewUpdated by rememberUpdatedState(onPreview) + val onPreviewFinishedUpdated by rememberUpdatedState(onPreviewFinished) + val chapters = playerState.chapters.collectAsStateWithLifecycle() + return remember { + MediaProgressSliderState( + { currentPosition }, + { totalDuration }, + chapters, + onPreviewUpdated, + onPreviewFinishedUpdated, + ) + } +} + +object MediaProgressSliderDefaults { + @Composable + fun colors( + trackBackgroundColor: Color = MaterialTheme.colorScheme.surface, + trackProgressColor: Color = MaterialTheme.colorScheme.primary, + thumbColor: Color = MaterialTheme.colorScheme.primary, + cachedProgressColor: Color = MaterialTheme.colorScheme.onSurface.weaken(), + downloadingColor: Color = Color.Yellow, + notAvailableColor: Color = MaterialTheme.colorScheme.error.slightlyWeaken(), + chapterColor: Color = MaterialTheme.colorScheme.onSurface, + previewTimeBackgroundColor: Color = MaterialTheme.colorScheme.surface, + previewTimeTextColor: Color = MaterialTheme.colorScheme.onSurface, + ): MediaProgressSliderColors { + return MediaProgressSliderColors( + trackBackgroundColor, + trackProgressColor, + thumbColor, + cachedProgressColor, + downloadingColor, + notAvailableColor, + chapterColor, + previewTimeBackgroundColor, + previewTimeTextColor, + ) + } +} + +@Immutable +class MediaProgressSliderColors( + val trackBackgroundColor: Color, + val trackProgressColor: Color, + val thumbColor: Color, + val cachedProgressColor: Color, + val downloadingColor: Color, + val notAvailableColor: Color, + val chapterColor: Color, + val previewTimeBackgroundColor: Color, + val previewTimeTextColor: Color, +) + +/** + * 视频播放器的进度条, 支持拖动调整播放位置, 支持显示缓冲进度. + */ +@Composable +fun MediaProgressSlider( + state: MediaProgressSliderState, + cacheState: MediaCacheProgressState, + colors: MediaProgressSliderColors = MediaProgressSliderDefaults.colors(), + enabled: Boolean = true, + showPreviewTimeTextOnThumb: Boolean = true, +// drawThumb: @Composable DrawScope.() -> Unit = { +// drawCircle( +// MaterialTheme.colorScheme.primary, +// radius = 12f, +// ) +// }, + modifier: Modifier = Modifier, +) { + Box( + modifier.fillMaxWidth() + .height(24.dp) + .testTag(TAG_PROGRESS_SLIDER), + contentAlignment = Alignment.CenterStart, + ) { + Box( + Modifier.fillMaxWidth().height(6.dp) + .padding(horizontal = 2.dp) // half thumb width + .clip(CircleShape), + ) { + Canvas(Modifier.matchParentSize()) { + // draw track + drawRect( + colors.trackBackgroundColor, + topLeft = Offset(0f, 0f), + size = Size(size.width, size.height), + ) + } + + Canvas(Modifier.matchParentSize()) { + // draw cached progress + + cacheState.version // subscribe to state change + + var currentX = 0f + + // 连续的缓存区块连着画, 否则会因精度缺失导致不连续 + forEachConsecutiveChunk(cacheState.chunks) { state, weight -> + val color = when (state) { + ChunkState.NONE -> Color.Unspecified + ChunkState.DOWNLOADING -> colors.downloadingColor + ChunkState.DONE -> colors.cachedProgressColor + ChunkState.NOT_AVAILABLE -> colors.notAvailableColor + } + if (color != Color.Unspecified) { + val size = Size( + weight * size.width, + size.height, + )// TODO: draw more cache states (colors) + drawRect( + color, + topLeft = Offset(currentX, 0f), + size = size, + ) + } + currentX += weight * size.width + } + } + + Canvas(Modifier.matchParentSize()) { + // draw play progress + val xPlay = size.width * state.displayPositionRatio + + drawRect( + colors.trackProgressColor, + topLeft = Offset(0f, 0f), + size = Size(xPlay, size.height), + ) + + // 下面的是有 gap 的视线, 但是会抖动, 不知道为什么 +// val thumbWidth = 4.dp.toPx() +// val gapWidthEach = 3.dp.toPx() // thumb width + gap +// val actualXPlay = (xPlay - (gapWidthEach + thumbWidth / 2)).fastCoerceAtLeast(0f) +// drawRect( +// trackProgressColor, +// topLeft = Offset(0f, 0f), +// size = Size(actualXPlay, size.height), +// ) +// val drawBackgroundWidth = xPlay - actualXPlay +// if (drawBackgroundWidth != 0f) { +// // 画上背景, 覆盖掉加载中颜色 +// drawRect( +// trackBackgroundColor, +// topLeft = Offset(actualXPlay, 0f), +// size = Size(drawBackgroundWidth, size.height), +// blendMode = BlendMode.Src, // override +// ) +// } +// drawRect( +// trackBackgroundColor, +// topLeft = Offset(xPlay, 0f), +// size = Size(gapWidthEach + thumbWidth / 2, size.height), +// blendMode = BlendMode.Src, // override +// ) + } + + Canvas(Modifier.matchParentSize()) { + if (state.totalDurationMillis == 0L) return@Canvas + state.chapters.forEach { + val percent = it.offsetMillis.toFloat().div(state.totalDurationMillis) + drawCircle( + color = colors.chapterColor, + radius = 2.dp.toPx(), + center = Offset(size.width * percent, this.center.y), + blendMode = BlendMode.Src, // override background + ) + } + } + } + + var mousePosX by rememberSaveable { mutableStateOf(0f) } + var thumbWidth by rememberSaveable { mutableIntStateOf(0) } + var sliderWidth by rememberSaveable { mutableIntStateOf(0) } + + fun renderPreviewTime(previewTimeMillis: Long): String { + state.chapters.find { + previewTimeMillis in it.offsetMillis.. Int, + previewTimeBackgroundColor: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val popupPositionProviderState = remember { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val anchor = IntRect( + offset = IntOffset( + offsetX(), + with(density) { -8.dp.toPx().toInt() }, + ) + anchorBounds.topLeft, + size = IntSize.Zero, + ) + val tooltipArea = IntRect( + IntOffset( + anchor.left - popupContentSize.width, + anchor.top - popupContentSize.height, + ), + IntSize( + popupContentSize.width * 2, + popupContentSize.height * 2, + ), + ) + val position = Alignment.TopCenter.align(popupContentSize, tooltipArea.size, layoutDirection) + + return IntOffset( + x = (tooltipArea.left + position.x).coerceIn(0, windowSize.width - popupContentSize.width), + y = (tooltipArea.top + position.y).coerceIn(0, windowSize.height - popupContentSize.height), + ) + } + } + } + Popup( + properties = PlatformPopupProperties(usePlatformInsets = false), + popupPositionProvider = popupPositionProviderState, + ) { + Box( + modifier = modifier + .testTag(TAG_PROGRESS_SLIDER_PREVIEW_POPUP) + .clip(shape = CircleShape) + .background(previewTimeBackgroundColor) + .animateContentSize(), + ) { + Box( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + content() + } + } + } +} + +@Composable +fun PreviewTimeText( + text: String, + previewTimeTextColor: Color, +) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { + Text( + // 占位置 + text = text, + Modifier.alpha(0f), + fontFamily = FontFamily.Monospace, + ) + Text( + text = text, + color = previewTimeTextColor, + textAlign = TextAlign.Center, + ) + } +} + +private inline fun forEachConsecutiveChunk( + chunks: List, + action: (state: ChunkState, weight: Float) -> Unit +) { + if (chunks.isEmpty()) return + + var currentState: ChunkState = chunks[0].state + var start = 0 + var end = 0 + + for (index in 1..chunks.lastIndex) { + val chunk = chunks[index] + if (chunk.state != currentState) { + action(currentState, chunks.subList(start, end + 1).sumOf { it.weight }) + currentState = chunk.state + start = index + } + end = index + } + // Handle the final chunk + action(currentState, chunks.subList(start, end + 1).sumOf { it.weight }) +} + +@OverloadResolutionByLambdaReturnType +inline fun Iterable.sumOf(selector: (T) -> Float): Float { + var sum: Float = 0.toFloat() + for (element in this) { + sum += selector(element) + } + return sum +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/PlayerControllerBar.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/PlayerControllerBar.kt new file mode 100644 index 0000000..6c799d3 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/PlayerControllerBar.kt @@ -0,0 +1,690 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp.ui.progress + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.automirrored.rounded.VolumeDown +import androidx.compose.material.icons.automirrored.rounded.VolumeMute +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp +import androidx.compose.material.icons.rounded.Fullscreen +import androidx.compose.material.icons.rounded.FullscreenExit +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.Subtitles +import androidx.compose.material.icons.rounded.SubtitlesOff +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.OutlinedTextFieldDefaults.Container +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import me.him188.ani.app.ui.foundation.dialogs.PlatformPopupProperties +import me.him188.ani.app.ui.foundation.effects.onKey +import me.him188.ani.app.ui.foundation.ifThen +import me.him188.ani.app.ui.foundation.theme.aniDarkColorTheme +import me.him188.ani.app.ui.foundation.theme.aniLightColorTheme +import me.him188.ani.app.ui.foundation.theme.slightlyWeaken +import me.him188.ani.app.ui.foundation.theme.stronglyWeaken +import org.openani.mediamp.ui.VideoControllerState +import org.openani.mediamp.ui.state.MediaCacheProgressState +import org.openani.mediamp.ui.top.needWorkaroundForFocusManager +import kotlin.math.roundToInt + +const val TAG_SELECT_EPISODE_ICON_BUTTON = "SelectEpisodeIconButton" +const val TAG_SPEED_SWITCHER_TEXT_BUTTON = "SpeedSwitcherTextButton" +const val TAG_SPEED_SWITCHER_DROPDOWN_MENU = "SpeedSwitcherDropdownMenu" +const val TAG_DANMAKU_ICON_BUTTON = "DanmakuIconButton" + +@Stable +object PlayerControllerDefaults { + /** + * To pause/play + */ + @Composable + fun PlaybackIcon( + isPlaying: () -> Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + ) { + IconButton( + onClick = onClick, + modifier, + ) { + if (isPlaying()) { + Icon(Icons.Rounded.Pause, contentDescription = "Pause", Modifier.size(36.dp)) + } else { + Icon(Icons.Rounded.PlayArrow, contentDescription = "Play", Modifier.size(36.dp)) + } + } + } + + /** + * To turn danmaku on/off + */ + @Composable + fun DanmakuIcon( + danmakuEnabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + ) { + IconButton( + onClick = onClick, + modifier.testTag(TAG_DANMAKU_ICON_BUTTON), + ) { + if (danmakuEnabled) { + Icon(Icons.Rounded.Subtitles, contentDescription = "禁用弹幕") + } else { + Icon(Icons.Rounded.SubtitlesOff, contentDescription = "启用弹幕") + } + } + } + + @Composable + fun AudioIcon( + volume: Float, + isMute: Boolean, + maxValue: Float, + onClick: () -> Unit, + onchange: (Float) -> Unit, + controllerState: VideoControllerState, + modifier: Modifier = Modifier, + ) { + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val audioIconRequester = remember { Any() } + + LaunchedEffect(true) { + snapshotFlow { isHovered }.collect { + controllerState.setRequestAlwaysOn(audioIconRequester, isHovered) + } + } + Box( + modifier = modifier.hoverable(hoverInteraction), + contentAlignment = Alignment.BottomCenter, + ) { + val iconButton = @Composable { + IconButton( + onClick = onClick, + ) { + when { + isMute -> { + Icon(Icons.AutoMirrored.Rounded.VolumeOff, contentDescription = "静音") + } + + volume < 0.33f -> { + Icon(Icons.AutoMirrored.Rounded.VolumeMute, contentDescription = "音量") + } + + volume < 0.66f -> { + Icon(Icons.AutoMirrored.Rounded.VolumeDown, contentDescription = "音量") + } + + else -> { + Icon(Icons.AutoMirrored.Rounded.VolumeUp, contentDescription = "音量") + } + } + } + } + + iconButton() + + Popup( + alignment = Alignment.BottomCenter, + ) { + Surface( + modifier = Modifier + .hoverable(hoverInteraction) + .clip(shape = CircleShape), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedVisibility( + visible = isHovered && !isMute, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = volume.times(100).roundToInt().toString(), + modifier = Modifier.padding(8.dp), + ) + val colors = SliderDefaults.colors( + inactiveTrackColor = MaterialTheme.colorScheme.onSurface, + ) + VerticalSlider( + value = volume, + onValueChange = onchange, + modifier = Modifier.width(96.dp), + thumb = {}, + colors = colors, + track = { sliderState -> + SliderDefaults.Track( + colors = colors, + enabled = true, + sliderState = sliderState, + thumbTrackGapSize = 0.dp, + ) + }, + valueRange = 0f..maxValue, + ) + } + } + + AnimatedVisibility( + visible = isHovered && !isMute, + enter = fadeIn(), + exit = fadeOut(), + ) { + iconButton() + } + } + } + } + } + } + + @Composable + fun NextEpisodeIcon( + onClick: () -> Unit, + modifier: Modifier = Modifier, + ) { + IconButton( + onClick, + modifier, + ) { + Icon(Icons.Rounded.SkipNext, "下一集", Modifier.size(36.dp)) + } + } + + @Composable + fun SelectEpisodeIcon( + onClick: () -> Unit, + modifier: Modifier = Modifier, + ) { + TextButton( + onClick, + modifier.testTag(TAG_SELECT_EPISODE_ICON_BUTTON), + colors = ButtonDefaults.textButtonColors( + contentColor = LocalContentColor.current, + ), + ) { + Text("选集") + } + } + + // TODO: DANMAKU_PLACEHOLDERS i18n + // See #120 + @Stable + private val DANMAKU_PLACEHOLDERS = listOf( + "来发一条弹幕吧~", + "小心,我要发射弹幕啦!", + "每一条弹幕背后,都有一个不为人知的秘密", + "召唤弹幕精灵!", + "这一刻的感受,只有你最懂", + "让弹幕变得不一样", + "弹幕世界大门已开", + "字里行间,藏着宇宙的秘密", + "在光与影的交织中,你的话语是唯一的真实", + "有趣的灵魂万里挑一", + "说点什么", + "长期征集有趣的弹幕广告词", + "广告位招租", + "\uD83E\uDD14", + "梦开始的地方", + "心念成形", + "發個彈幕炒熱氣氛!", + "來個彈幕吧!", + "發個友善的彈幕吧!", + "是不是忍不住想發彈幕了呢?", + ) + + fun randomDanmakuPlaceholder(): String = DANMAKU_PLACEHOLDERS.random() + + /** + * To send danmaku + */ + @Composable + fun DanmakuSendButton( + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, + ) { + IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = "发送") + } + } + + @Composable + fun inVideoDanmakuTextFieldColors(): TextFieldColors { + return OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface.stronglyWeaken(), + focusedContainerColor = MaterialTheme.colorScheme.surface.stronglyWeaken(), + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface.slightlyWeaken(), + focusedTextColor = MaterialTheme.colorScheme.onSurface, + ) + } + + @Composable + fun inTabDanmakuTextFieldColors(): TextFieldColors { + return OutlinedTextFieldDefaults.colors( + ) + } + + /** + * To edit danmaku and send it by [trailingIcon] + */ + @Composable + fun DanmakuTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + onSend: () -> Unit = {}, + isSending: () -> Boolean = { false }, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + placeholder: @Composable () -> Unit = { + Text( + remember { randomDanmakuPlaceholder() }, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = { + if (isSending()) { + CircularProgressIndicator( + Modifier.size(20.dp), +// strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurface, + ) + } else { + DanmakuSendButton( + onClick = { onSend() }, + enabled = value.isNotBlank(), + ) + } + }, + enabled: Boolean = true, + singleLine: Boolean = true, + isError: Boolean = false, + shape: Shape = MaterialTheme.shapes.medium, + style: TextStyle = MaterialTheme.typography.bodyMedium, + colors: TextFieldColors = inVideoDanmakuTextFieldColors() + ) { + MaterialTheme(aniLightColorTheme()) { + BasicTextField( + value, + onValueChange, + modifier.onKey(Key.Enter) { + onSend() + }.height(38.dp), + textStyle = style.copy(color = colors.unfocusedTextColor), + cursorBrush = SolidColor(rememberUpdatedState(if (isError) colors.errorCursorColor else colors.cursorColor).value), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { onSend() }), + decorationBox = { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value, + innerTextField, + enabled = enabled, + singleLine = singleLine, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + contentPadding = PaddingValues(vertical = 7.dp, horizontal = 16.dp), + colors = colors, + placeholder = { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + placeholder() + } + } + }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + container = { + Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + shape = shape, + ) + }, + ) + }, + ) + } + } + + /** + * To enter/exit fullscreen + */ + @Composable + fun FullscreenIcon( + isFullscreen: Boolean, + onClickFullscreen: () -> Unit, + modifier: Modifier = Modifier, + ) { + val focusManager by rememberUpdatedState(LocalFocusManager.current) // workaround for #288 + IconButton( + onClick = onClickFullscreen, + modifier.ifThen(needWorkaroundForFocusManager) { + onFocusEvent { + if (it.hasFocus) { + focusManager.clearFocus() + } + } + }, + ) { + if (isFullscreen) { + Icon(Icons.Rounded.FullscreenExit, contentDescription = "Exit Fullscreen", Modifier.size(32.dp)) + } else { + Icon(Icons.Rounded.Fullscreen, contentDescription = "Enter Fullscreen", Modifier.size(32.dp)) + } + } + } + + /** + * Set 1x, 2x playback speed. + * @param optionsProvider The options to choose from. Note that when the value changes, it will not reflect in the UI. + */ + @Composable + fun SpeedSwitcher( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + optionsProvider: () -> List = { listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 3f) }, + onExpandedChanged: (expanded: Boolean) -> Unit = {}, + ) { + return OptionsSwitcher( + value = value, + onValueChange = onValueChange, + optionsProvider = optionsProvider, + renderValue = { Text(remember(it) { "${it}x" }) }, + renderValueExposed = { Text(remember(it) { if (it == 1.0f) "倍速" else """${it}x""" }) }, + modifier, + properties = PlatformPopupProperties( + clippingEnabled = false, + ), + textButtonTestTag = TAG_SPEED_SWITCHER_TEXT_BUTTON, + dropdownMenuTestTag = TAG_SPEED_SWITCHER_DROPDOWN_MENU, + onExpandedChanged = onExpandedChanged, + ) + } + + /** + * @param optionsProvider The options to choose from. Note that when the value changes, it will not reflect in the UI. + */ + @Composable + fun OptionsSwitcher( + value: T, + onValueChange: (T) -> Unit, + optionsProvider: () -> List, + renderValue: @Composable (T) -> Unit, + renderValueExposed: @Composable (T) -> Unit = renderValue, + modifier: Modifier = Modifier, + enabled: Boolean = true, + properties: PopupProperties = PopupProperties(), + textButtonTestTag: String = "textButton", + dropdownMenuTestTag: String = "dropDownMenu", + onExpandedChanged: (expanded: Boolean) -> Unit = {}, + ) { + Box(modifier, contentAlignment = Alignment.Center) { + var expanded by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(true) { + snapshotFlow { expanded }.collect { + onExpandedChanged(expanded) + } + } + TextButton( + { expanded = true }, + colors = ButtonDefaults.textButtonColors( + contentColor = LocalContentColor.current, + ), + enabled = enabled, + modifier = Modifier.testTag(textButtonTestTag), + ) { + renderValueExposed(value) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + properties = properties, + modifier = Modifier.testTag(dropdownMenuTestTag), + ) { + val options = remember(optionsProvider) { optionsProvider() } + for (option in options) { + DropdownMenuItem( + text = { + val color = if (value == option) { + MaterialTheme.colorScheme.primary + } else { + LocalContentColor.current + } + CompositionLocalProvider(LocalContentColor provides color) { + renderValue(option) + } + }, + onClick = { + expanded = false + onValueChange(option) + }, + ) + } + } + } + } + + @Composable + fun MediaProgressSlider( + progressSliderState: MediaProgressSliderState, + cacheProgressState: MediaCacheProgressState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + showPreviewTimeTextOnThumb: Boolean = true, + ) { + org.openani.mediamp.ui.progress.MediaProgressSlider( + progressSliderState, cacheProgressState, + enabled = enabled, + showPreviewTimeTextOnThumb = showPreviewTimeTextOnThumb, + modifier = modifier, + ) + } + + @Composable + fun LeftBottomTips( + onClick: () -> Unit, + modifier: Modifier = Modifier + ) { + Box( + modifier = modifier.clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.CenterStart, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { + Text( + text = "即将跳过 OP 或 ED", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + TextButton(onClick = onClick) { + Text("取消") + } + } + } + } + } +} + +/** + * The controller bar of a video player. Usually at the bottom of the screen (the video player). + * + * See [PlayerControllerDefaults] for components. + * + * @param startActions [PlayerControllerDefaults.PlaybackIcon], [PlayerControllerDefaults.DanmakuIcon] + * @param progressIndicator [MediaProgressIndicatorText] + * @param progressSlider [MediaProgressSlider] + * @param danmakuEditor [PlayerControllerDefaults.DanmakuTextField] + * @param endActions [PlayerControllerDefaults.FullscreenIcon] + * @param expanded Whether the controller bar is expanded. + * If `true`, the [progressIndicator] and [progressSlider] will be shown on a separate row above. The bottom row will contain a [danmakuEditor]. + * If `false`, the entire bar will be only one row. [danmakuEditor] will be ignored. + */ +@Composable +fun PlayerControllerBar( + startActions: @Composable RowScope.() -> Unit, + progressIndicator: @Composable RowScope.() -> Unit, + progressSlider: @Composable RowScope.() -> Unit, + danmakuEditor: @Composable RowScope.() -> Unit, + endActions: @Composable RowScope.() -> Unit, + expanded: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier + .clickable(remember { MutableInteractionSource() }, null, onClick = {}) // Consume touch event + .padding( + horizontal = if (expanded) 8.dp else 4.dp, + vertical = if (expanded) 4.dp else 2.dp, + ), + ) { + Column { + ProvideTextStyle(MaterialTheme.typography.labelMedium) { + Row( + Modifier + .padding(start = if (expanded) 8.dp else 4.dp) + .padding(vertical = if (expanded) 4.dp else 2.dp), + ) { + progressIndicator() + } + if (expanded) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + MaterialTheme(aniDarkColorTheme()) { + progressSlider() + } + } + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(if (expanded) 8.dp else 4.dp), + ) { + // 播放 / 暂停按钮 + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + startActions() + } + + Row( + Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + MaterialTheme(aniDarkColorTheme()) { + if (expanded) { + ProvideTextStyle(MaterialTheme.typography.labelSmall) { + danmakuEditor() + } + } else { + progressSlider() + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + MaterialTheme(aniDarkColorTheme()) { + endActions() + } + } + } + } +} \ No newline at end of file diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleLanguage.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleLanguage.kt new file mode 100644 index 0000000..2bb4955 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleLanguage.kt @@ -0,0 +1,16 @@ +package org.openani.mediamp.ui.progress + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import org.openani.mediamp.ui.state.SubtitleTrack + +@Immutable +class SubtitlePresentation( + val subtitleTrack: SubtitleTrack, + val displayName: String, +) + +@Stable +val SubtitleTrack.subtitleLanguage: String + get() = language ?: labels.firstOrNull()?.value ?: internalId + diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleSwitcher.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleSwitcher.kt new file mode 100644 index 0000000..2192844 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/SubtitleSwitcher.kt @@ -0,0 +1,110 @@ +package org.openani.mediamp.ui.progress + +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import me.him188.ani.app.ui.foundation.AbstractViewModel +import me.him188.ani.app.ui.foundation.dialogs.PlatformPopupProperties +import org.openani.mediamp.ui.state.SubtitleTrack +import org.openani.mediamp.ui.state.TrackGroup + +@Stable +class SubtitleTrackState( + current: StateFlow, + candidates: Flow>, +) : AbstractViewModel() { + val options = candidates.map { tracks -> + tracks.map { track -> + SubtitlePresentation(track, track.subtitleLanguage) + } + }.flowOn(Dispatchers.Default).shareInBackground() + + val value = combine(options, current) { options, current -> + options.firstOrNull { it.subtitleTrack.id == current?.id } + }.flowOn(Dispatchers.Default) +} + + +@Composable +fun PlayerControllerDefaults.SubtitleSwitcher( + playerState: TrackGroup, + modifier: Modifier = Modifier, + onSelect: (SubtitleTrack?) -> Unit = { playerState.select(it) }, +) { + val state = remember(playerState) { + SubtitleTrackState(playerState.current, playerState.candidates) + } + SubtitleSwitcher(state, onSelect, modifier) +} + +@Composable +fun PlayerControllerDefaults.SubtitleSwitcher( + state: SubtitleTrackState, + onSelect: (SubtitleTrack?) -> Unit, + modifier: Modifier = Modifier, +) { + val options by state.options.collectAsStateWithLifecycle(emptyList()) + SubtitleSwitcher( + value = state.value.collectAsStateWithLifecycle(null).value, + onValueChange = { onSelect(it?.subtitleTrack) }, + optionsProvider = { options }, + modifier, + ) +} + +/** + * 选字幕 + */ +@Composable +fun PlayerControllerDefaults.SubtitleSwitcher( + value: SubtitlePresentation?, + onValueChange: (SubtitlePresentation?) -> Unit, + optionsProvider: () -> List, + modifier: Modifier = Modifier, +) { + val optionsProviderUpdated by rememberUpdatedState(optionsProvider) + val options by remember { + derivedStateOf { + optionsProviderUpdated() + null + } + } + if (options.size <= 1) return // 1 for `null` + return OptionsSwitcher( + value = value, + onValueChange = onValueChange, + optionsProvider = { options }, + renderValue = { + if (it == null) { + Text("关闭") + } else { + Text(it.displayName, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + }, + renderValueExposed = { + Text( + remember(it) { it?.displayName ?: "字幕" }, + Modifier.widthIn(max = 64.dp), + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + }, + modifier, + properties = PlatformPopupProperties( + clippingEnabled = false, + ), + ) +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/progress/VerticalSlider.kt b/mediamp-core/src/commonMain/kotlin/ui/progress/VerticalSlider.kt new file mode 100644 index 0000000..c7ad8e1 --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/progress/VerticalSlider.kt @@ -0,0 +1,75 @@ +package org.openani.mediamp.ui.progress + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints + +// copy from https://gist.github.com/Debdutta-Panda/d47a84b3e2f82b4dd4b1f0cf131e73d8 +@Composable +fun VerticalSlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + /*@IntRange(from = 0)*/ + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + colors: SliderColors = SliderDefaults.colors(), + thumb: @Composable (SliderState) -> Unit = { + SliderDefaults.Thumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled, + ) + }, + track: @Composable (SliderState) -> Unit = { sliderState -> + SliderDefaults.Track( + colors = colors, + enabled = enabled, + sliderState = sliderState, + ) + }, +) { + Slider( + colors = colors, + interactionSource = interactionSource, + onValueChangeFinished = onValueChangeFinished, + steps = steps, + valueRange = valueRange, + enabled = enabled, + value = value, + onValueChange = onValueChange, + modifier = Modifier + .graphicsLayer { + rotationZ = 270f + transformOrigin = TransformOrigin(0f, 0f) + } + .layout { measurable, constraints -> + val placeable = measurable.measure( + Constraints( + minWidth = constraints.minHeight, + maxWidth = constraints.maxHeight, + minHeight = constraints.minWidth, + maxHeight = constraints.maxHeight, + ), + ) + layout(placeable.height, placeable.width) { + placeable.place(-placeable.width, 0) + } + } + .then(modifier), + thumb = thumb, + track = track, + ) +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/state/MediaCacheProgressState.kt b/mediamp-core/src/commonMain/kotlin/ui/state/MediaCacheProgressState.kt new file mode 100644 index 0000000..e261dfb --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/state/MediaCacheProgressState.kt @@ -0,0 +1,88 @@ +package org.openani.mediamp.ui.state + +import androidx.compose.runtime.Stable + +/** + * 视频播放器进度条的缓存进度 + */ +@Stable +interface MediaCacheProgressState { + /** + * 区块列表. 每个区块的宽度由 [Chunk.weight] 决定. + * + * 所有 chunks 的 weight 之和应当 (约) 等于 1, 否则将会导致绘制超出进度条的区域 (即会被忽略). + */ + val chunks: List + + /** + * 当前的版本. 当 [chunks] 更新时, 该值会递增. + */ + val version: Int + + /** + * 是否已经全部缓存完成. 当已经缓存完成时, UI 可能会优化性能, 不再考虑 [chunks] 更新. + */ + val isFinished: Boolean +} + +interface UpdatableMediaCacheProgressState : MediaCacheProgressState { + suspend fun update() +} + +// Not stable +interface Chunk { + @Stable + val weight: Float // always return the same value + + // This can change, and change will not notify compose state + val state: ChunkState +} + +enum class ChunkState { + /** + * 初始状态 + */ + NONE, + + /** + * 正在下载 + */ + DOWNLOADING, + + /** + * 下载完成 + */ + DONE, + + /** + * 对应 BT 的没有任何 peer 有这个 piece 的状态 + */ + NOT_AVAILABLE +} + +private val StaticMediaCacheProgressStateNone = StaticMediaCacheProgressState(ChunkState.NONE) +private val StaticMediaCacheProgressStateDone = StaticMediaCacheProgressState(ChunkState.DONE) + +@Stable +fun staticMediaCacheProgressState( + chunkState: ChunkState +): UpdatableMediaCacheProgressState { + if (chunkState == ChunkState.NONE) return StaticMediaCacheProgressStateNone + if (chunkState == ChunkState.DONE) return StaticMediaCacheProgressStateDone + return StaticMediaCacheProgressState(chunkState) +} + +private class StaticMediaCacheProgressState(chunkState: ChunkState) : UpdatableMediaCacheProgressState { + override suspend fun update() { + } + + override val chunks: List = listOf( + object : Chunk { + override val weight: Float get() = 1f + override val state: ChunkState = chunkState + }, + ) + + override val version: Int get() = 0 + override val isFinished: Boolean = chunkState == ChunkState.DONE +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/state/PlayerState.kt b/mediamp-core/src/commonMain/kotlin/ui/state/PlayerState.kt new file mode 100644 index 0000000..26e9f0a --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/state/PlayerState.kt @@ -0,0 +1,570 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package org.openani.mediamp.ui.state + +import androidx.annotation.UiThread +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import me.him188.ani.app.platform.Context +import me.him188.ani.utils.logging.error +import me.him188.ani.utils.logging.info +import me.him188.ani.utils.logging.thisLogger +import org.openani.mediamp.data.FileVideoData +import org.openani.mediamp.data.VideoData +import org.openani.mediamp.data.VideoProperties +import org.openani.mediamp.data.VideoSource +import org.openani.mediamp.data.VideoSourceOpenException +import org.openani.mediamp.ui.VideoPlayer +import kotlin.concurrent.Volatile +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.reflect.KClass + +/** + * A controller for the [VideoPlayer]. + */ +@Stable +interface PlayerState { + /** + * Current state of the player. + * + * State can be changed internally e.g. buffer exhausted or externally by e.g. [pause], [resume]. + */ + val state: StateFlow + + /** + * The video source that is currently being played. + */ + val videoSource: StateFlow?> + + /** + * The video data of the currently playing video. + */ + val videoData: Flow + + /** + * 视频数据缓存进度 + */ + val cacheProgress: MediaCacheProgressState + + /** + * Sets the video source to play, by [opening][VideoSource.open] the [source], + * updating [videoSource], and resetting the progress to 0. + * + * Suspends until the new source has been updated. + * + * If this function failed to [start video streaming][VideoSource.open], it will throw an exception. + * + * This function must not be called on the main thread as it will call [VideoSource.open]. + * + * @param source the video source to play. `null` to stop playing. + * @throws VideoSourceOpenException 当打开失败时抛出, 包含原因 + */ + @Throws(VideoSourceOpenException::class, CancellationException::class) + suspend fun setVideoSource(source: VideoSource<*>) + + /** + * 停止播放并清除上次[设置][setVideoSource]的视频源. 之后还可以通过 [setVideoSource] 恢复播放. + */ + suspend fun clearVideoSource() + + /** + * Properties of the video being played. + * + * Note that it may not be available immediately after [setVideoSource] returns, + * since the properties may be callback from the underlying player implementation. + */ + val videoProperties: StateFlow + + /** + * 是否正在 buffer (暂停视频中) + */ + val isBuffering: Flow + + /** + * Current position of the video being played. + * + * `0` if no video is being played. + */ + val currentPositionMillis: StateFlow + + @UiThread + fun getExactCurrentPositionMillis(): Long + + /** + * `0..100` + */ + val bufferedPercentage: StateFlow + + /** + * 当前播放进度比例 `0..1` + */ + val playProgress: Flow + + /** + * 暂停播放, 直到 [pause] + */ + @UiThread + fun pause() + + /** + * 恢复播放 + */ + @UiThread + fun resume() + + /** + * 停止播放, 之后不能恢复, 必须 [setVideoSource] + */ + @UiThread + fun stop() + + /** + * 视频播放速度 (倍速) + * + * 1.0 为原速度, 2.0 为两倍速度, 0.5 为一半速度, etc. + */ + val playbackSpeed: StateFlow + + @UiThread + fun setPlaybackSpeed(speed: Float) + + /** + * 跳转到指定位置 + */ + @UiThread + fun seekTo(positionMillis: Long) + + /** + * 快进或快退一段时间. 正数为快进, 负数为快退. + */ + @UiThread + fun skip(deltaMillis: Long) { + seekTo(currentPositionMillis.value + deltaMillis) + } + + val subtitleTracks: TrackGroup + + val audioTracks: TrackGroup + + fun saveScreenshotFile(filename: String) + + val chapters: StateFlow> +} + +@Immutable +data class Chapter( + val name: String, + val durationMillis: Long, + val offsetMillis: Long +) + +fun PlayerState.togglePause() { + if (state.value.isPlaying) { + pause() + } else { + resume() + } +} + +typealias CacheProgressStateFactory = (T, State) -> UpdatableMediaCacheProgressState? + +// TODO: 这可能不是很好, 但这是最不入侵现有代码的修改方案了 +object CacheProgressStateFactoryManager : SynchronizedObject() { + private val factories: MutableMap, CacheProgressStateFactory<*>> = + mutableMapOf() + + fun register(kClass: KClass, factory: CacheProgressStateFactory) = synchronized(this) { + factories[kClass] = factory + } + + fun create(videoData: VideoData, isCacheFinished: State): UpdatableMediaCacheProgressState? = + synchronized(this) { + return factories[videoData::class]?.let { factory -> + @Suppress("UNCHECKED_CAST") + factory as CacheProgressStateFactory + factory(videoData, isCacheFinished) + } + } +} + +abstract class AbstractPlayerState( + parentCoroutineContext: CoroutineContext, +) : PlayerState, SynchronizedObject() { + protected val backgroundScope = CoroutineScope( + parentCoroutineContext + SupervisorJob(parentCoroutineContext[Job]), + ).apply { + coroutineContext.job.invokeOnCompletion { + close() + } + } + + protected val logger = thisLogger() + override val videoSource: MutableStateFlow?> = MutableStateFlow(null) + + override val state: MutableStateFlow = MutableStateFlow(PlaybackState.PAUSED_BUFFERING) + + /** + * Currently playing resource that should be closed when the controller is closed. + * @see setVideoSource + */ + protected val openResource = MutableStateFlow(null) + + open class Data( + open val videoSource: VideoSource<*>, + open val videoData: VideoData, + open val releaseResource: () -> Unit, + ) + + override val isBuffering: Flow by lazy { + state.map { it == PlaybackState.PAUSED_BUFFERING } + } + + final override val videoData: Flow = openResource.map { + it?.videoData + } + + private val isCacheFinishedState = videoData.flatMapLatest { + it?.isCacheFinished ?: flowOf(false) + }.produceState(false, backgroundScope) + + private val cacheProgressFlow = videoData.map { + when (it) { + null -> staticMediaCacheProgressState(ChunkState.NONE) + + is FileVideoData -> staticMediaCacheProgressState(ChunkState.DONE) + + else -> + CacheProgressStateFactoryManager.create(it, isCacheFinishedState) + ?: staticMediaCacheProgressState(ChunkState.NONE) + } + }.stateIn(backgroundScope, SharingStarted.WhileSubscribed(), staticMediaCacheProgressState(ChunkState.NONE)) + + protected open suspend fun cacheProgressLoop() { + while (true) { + cacheProgressFlow.value.update() + delay(1000) + } + } + + init { + backgroundScope.launch { + cacheProgressLoop() + } + } + + override val cacheProgress: UpdatableMediaCacheProgressState by + cacheProgressFlow.produceState(staticMediaCacheProgressState(ChunkState.NONE), backgroundScope) + + final override val playProgress: Flow by lazy { + combine(videoProperties.filterNotNull(), currentPositionMillis) { properties, duration -> + if (properties.durationMillis == 0L) { + return@combine 0f + } + (duration / properties.durationMillis).toFloat().coerceIn(0f, 1f) + } + } + + final override suspend fun setVideoSource(source: VideoSource<*>) { + val previousResource = openResource.value + if (source == previousResource?.videoSource) { + return + } + + openResource.value = null + previousResource?.releaseResource?.invoke() + + val opened = try { + openSource(source) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + logger.error(e) { "Failed to open VideoSource: $source" } + throw e + } + + try { + logger.info { "Initializing player with VideoSource: $source" } + state.value = PlaybackState.PAUSED_BUFFERING + startPlayer(opened) + logger.info { "Player is now initialized with media and will play when ready" } + } catch (e: CancellationException) { + opened.releaseResource() + throw e + } catch (e: Throwable) { + logger.error(e) { "Player failed to initialize" } + opened.releaseResource() + throw e + } + + this.openResource.value = opened + } + + final override suspend fun clearVideoSource() { + logger.info { "clearVideoSource: Cleaning up player" } + cleanupPlayer() + this.videoSource.value = null + this.openResource.value = null + } + + fun closeVideoSource() { + synchronized(this) { + val value = openResource.value + openResource.value = null + value?.releaseResource?.invoke() + } + } + + final override fun stop() { + stopImpl() + closeVideoSource() + } + + protected abstract fun stopImpl() + + /** + * 开始播放 + */ + protected abstract suspend fun startPlayer(data: D) + + /** + * 停止播放, 因为要释放资源了 + */ + protected abstract suspend fun cleanupPlayer() + + @Throws(VideoSourceOpenException::class, CancellationException::class) + protected abstract suspend fun openSource(source: VideoSource<*>): D + + @Volatile + private var closed = false + fun close() { + if (closed) return + synchronized(this) { + if (closed) return + closed = true + + closeImpl() + closeVideoSource() + backgroundScope.cancel() + } + } + + protected abstract fun closeImpl() +} + + +enum class PlaybackState( + val isPlaying: Boolean, +) { + /** + * Player is loaded and will be playing as soon as metadata and first frame is available. + */ + READY(isPlaying = false), + + /** + * 用户主动暂停. buffer 继续充, 但是充好了也不要恢复 [PLAYING]. + */ + PAUSED(isPlaying = false), + + PLAYING(isPlaying = true), + + /** + * 播放中但因没 buffer 就暂停了. buffer 填充后恢复 [PLAYING]. + */ + PAUSED_BUFFERING(isPlaying = false), + + FINISHED(isPlaying = false), + + ERROR(isPlaying = false), + ; +} + +fun interface PlayerStateFactory { + /** + * Creates a new [PlayerState] + * [parentCoroutineContext] must have a [Job] so that the player state is bound to the parent coroutine context scope. + * + * @param context the platform context to create the underlying player implementation. + * It is only used by the constructor and not stored. + */ + fun create(context: Context, parentCoroutineContext: CoroutineContext): PlayerState +} + +interface SupportsAudio { + + val volume: StateFlow + val isMute: StateFlow + val maxValue: Float + + fun toggleMute(mute: Boolean? = null) + + @UiThread + fun setVolume(volume: Float) + + @UiThread + fun volumeUp(value: Float = 0.05f) + + @UiThread + fun volumeDown(value: Float = 0.05f) +} + +/** + * For previewing + */ +class DummyPlayerState( + parentCoroutineContext: CoroutineContext = EmptyCoroutineContext, +) : AbstractPlayerState(parentCoroutineContext), SupportsAudio { + override val state: MutableStateFlow = MutableStateFlow(PlaybackState.PLAYING) + override fun stopImpl() { + + } + + override suspend fun cleanupPlayer() { + // no-op + } + + override suspend fun openSource(source: VideoSource<*>): Data { + val data = source.open() + return Data( + source, + data, + releaseResource = { + backgroundScope.launch(NonCancellable) { + data.close() + } + }, + ) + } + + override fun closeImpl() { + } + + override suspend fun startPlayer(data: Data) { + // no-op + } + + override suspend fun cacheProgressLoop() { + // no-op + // 测试的时候 delay 会被直接跳过, 导致死循环 + } + + override val videoSource: MutableStateFlow?> = MutableStateFlow(null) + + override val videoProperties: MutableStateFlow = MutableStateFlow( + VideoProperties( + title = "Test Video", + durationMillis = 100_000, + ), + ) + override val currentPositionMillis = MutableStateFlow(10_000L) + override fun getExactCurrentPositionMillis(): Long { + return currentPositionMillis.value + } + + override val bufferedPercentage: StateFlow = MutableStateFlow(50) + + override fun pause() { + state.value = PlaybackState.PAUSED + } + + override fun resume() { + state.value = PlaybackState.PLAYING + } + + override val playbackSpeed: MutableStateFlow = MutableStateFlow(1.0f) + + override fun setPlaybackSpeed(speed: Float) { + playbackSpeed.value = speed + } + + override fun seekTo(positionMillis: Long) { + this.currentPositionMillis.value = positionMillis + } + + override val subtitleTracks: TrackGroup = emptyTrackGroup() + override val audioTracks: TrackGroup = emptyTrackGroup() + + override val volume: MutableStateFlow = MutableStateFlow(0f) + override val isMute: MutableStateFlow = MutableStateFlow(false) + override val maxValue: Float = 1f + + override fun toggleMute(mute: Boolean?) { + isMute.value = mute ?: !isMute.value + } + + override fun setVolume(volume: Float) { + this.volume.value = volume + } + + override fun volumeUp(value: Float) { + setVolume(volume.value + value) + } + + override fun volumeDown(value: Float) { + setVolume(volume.value - value) + } + + override fun saveScreenshotFile(filename: String) { + } + + override val chapters: StateFlow> = MutableStateFlow( + persistentListOf( + Chapter("chapter1", durationMillis = 90_000L, 0L), + Chapter("chapter2", durationMillis = 5_000L, 90_000L), + ), + ) +} + +/** + * Collects the flow on the main thread into a [State]. + */ +private fun Flow.produceState( + initialValue: T, + scope: CoroutineScope, + coroutineContext: CoroutineContext = EmptyCoroutineContext, +): State { + val state = mutableStateOf(initialValue) + scope.launch(coroutineContext + Dispatchers.Main) { + flowOn(Dispatchers.Default) // compute in background + .collect { + // update state in main + state.value = it + } + } + return state +} diff --git a/mediamp-core/src/commonMain/kotlin/ui/state/TrackGroup.kt b/mediamp-core/src/commonMain/kotlin/ui/state/TrackGroup.kt new file mode 100644 index 0000000..a92574d --- /dev/null +++ b/mediamp-core/src/commonMain/kotlin/ui/state/TrackGroup.kt @@ -0,0 +1,67 @@ +package org.openani.mediamp.ui.state + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow + +@Stable +interface TrackGroup { + val current: StateFlow + + val candidates: Flow> + + fun select(track: T?): Boolean +} + +fun emptyTrackGroup(): TrackGroup = object : TrackGroup { + override val current: StateFlow = MutableStateFlow(null) + override val candidates: Flow> = emptyFlow() + + override fun select(track: T?): Boolean = false +} + +@Immutable +interface Track + +@Immutable +data class SubtitleTrack( + val id: String, + val internalId: String, + val language: String?, + val labels: List