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 0000000..7454180 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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