├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── sam │ │ │ └── video │ │ │ ├── App.kt │ │ │ ├── timeline │ │ │ ├── MainActivity.kt │ │ │ ├── adapter │ │ │ │ ├── TagAdapter.kt │ │ │ │ └── VideoFrameAdapter.kt │ │ │ ├── bean │ │ │ │ ├── TagLineViewData.kt │ │ │ │ ├── TimeLineAreaData.kt │ │ │ │ ├── VideoClip.kt │ │ │ │ └── VideoFrameData.kt │ │ │ ├── listener │ │ │ │ ├── Click.java │ │ │ │ ├── OnFrameClickListener.kt │ │ │ │ ├── SelectAreaMagnetOnChangeListener.kt │ │ │ │ ├── TagSelectAreaMagnetOnChangeListener.kt │ │ │ │ └── VideoPlayerOperate.kt │ │ │ └── widget │ │ │ │ ├── ActiveFullTextTagLineView.kt │ │ │ │ ├── ActiveWideTextTagLineView.kt │ │ │ │ ├── MaxHeightRecyclerView.kt │ │ │ │ ├── RoundImageView.java │ │ │ │ ├── RoundRectMask.java │ │ │ │ ├── RoundTextView.java │ │ │ │ ├── RulerView.kt │ │ │ │ ├── SelectAreaView.kt │ │ │ │ ├── TagItemDecoration.kt │ │ │ │ ├── TagLineView.kt │ │ │ │ ├── TagPopWindow.kt │ │ │ │ ├── TimeChangeListener.kt │ │ │ │ ├── TimeLineBaseValue.kt │ │ │ │ ├── TransImageView.java │ │ │ │ ├── VideoFrameItemDecoration.kt │ │ │ │ ├── VideoFrameRecyclerView.kt │ │ │ │ ├── WideTextTagLineView.kt │ │ │ │ └── ZoomFrameLayout.kt │ │ │ └── util │ │ │ ├── AppExecutors.java │ │ │ ├── Collections.kt │ │ │ ├── ContextExt.kt │ │ │ ├── MediaStoreUtil.java │ │ │ ├── ScreenUtil.java │ │ │ ├── SelectAreaEventHandle.kt │ │ │ └── VideoUtils.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable-xxhdpi │ │ ├── video_cover_duration_default.webp │ │ ├── video_cover_duration_disable.webp │ │ ├── video_edit_frame_pic_icon.webp │ │ ├── video_frame_cursor.webp │ │ ├── video_select_left.webp │ │ └── video_select_right.webp │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── shape_video_edit_filter_place_bg.xml │ │ ├── tag_select_white_border_corners.xml │ │ ├── video_edit__tip_circle_bg.xml │ │ ├── video_edit__tip_circle_bg_16dp.xml │ │ ├── video_edit__tip_line_bg_gradient.xml │ │ ├── video_item_index_bg.xml │ │ └── video_item_placeholder.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_start.xml │ │ ├── item_tag_img.xml │ │ ├── item_tag_text.xml │ │ └── item_video_frame.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ └── video_edit_tag_color_res.xml │ └── test │ └── java │ └── com │ └── sam │ └── video │ └── timeline │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── 时间轴.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | .idea 16 | gradle 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VideoTimeLine 一组视频时间轴自定义控件 2 | ![时间轴](./时间轴.png) 3 | 简介: 4 | ### 标签,可显示文字/图片 5 | 对应源码:TagLineView.kt 6 | 7 | 可以用来外挂一些视频素材,或者给视频打标签之类的作用。 8 | 起始时间接近的标签会被归为一组,点击可以进行切换,点击显示的弹窗的小角标会动态根据标签位置显示,时间轴滑动时会停留在屏幕的左边缘。 9 | 10 | ### 主轴,按时间轴缩放值抽取一定的帧显示 11 | 对应源码:VideoFrameRecyclerView.kt 12 | 13 | 支持双指缩放,双击放大、还原。 14 | 使用 glide 取帧方式加载帧,觉得速度太慢的可以使用 ffmpeg 框架取帧 15 | 16 | ### 时间选择控件 17 | 对应源码:SelectAreaView.kt ,可以选择标签/视频的时长 18 | 19 | ## Getting started 20 | 直接运行 app,参考 MainActivity 里面一些控件的调用方式 21 | 22 | ## License 23 | VideoTimeLine 使用 Apache License 2.0 协议, 详情请参考 [LICENSE](./LICENSE)。 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | apply plugin: 'kotlin-android-extensions' 6 | 7 | android { 8 | compileSdkVersion 28 9 | defaultConfig { 10 | applicationId "com.sam.video.timeline" 11 | minSdkVersion 19 12 | targetSdkVersion 28 13 | versionCode 1 14 | versionName "1.0" 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | compileOptions { 24 | sourceCompatibility = 1.8 25 | targetCompatibility = 1.8 26 | } 27 | } 28 | 29 | dependencies { 30 | implementation fileTree(dir: 'libs', include: ['*.jar']) 31 | implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 32 | implementation 'androidx.appcompat:appcompat:1.1.0' 33 | implementation 'androidx.core:core-ktx:1.1.0' 34 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3' 35 | testImplementation 'junit:junit:4.12' 36 | androidTestImplementation 'androidx.test.ext:junit:1.1.1' 37 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' 38 | implementation 'androidx.recyclerview:recyclerview:1.1.0' 39 | implementation 'com.github.bumptech.glide:glide:4.8.0' 40 | 41 | implementation 'androidx.dynamicanimation:dynamicanimation:1.0.0' 42 | api "com.github.CymChad:BaseRecyclerViewAdapterHelper:$quickAdapterVersion" 43 | 44 | api 'androidx.lifecycle:lifecycle-livedata-core:2.0.0' 45 | api 'androidx.lifecycle:lifecycle-viewmodel:2.0.0' 46 | api 'androidx.lifecycle:lifecycle-extensions:2.0.0' 47 | 48 | api "com.github.bumptech.glide:glide:$glideVersion" 49 | annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" 50 | api 'androidx.annotation:annotation:1.1.0@jar' 51 | api 'com.google.android:flexbox:1.0.0' 52 | 53 | 54 | } 55 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/App.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video 2 | 3 | import android.app.Application 4 | 5 | 6 | class App : Application() { 7 | 8 | override fun onCreate() { 9 | instance = this 10 | super.onCreate() 11 | } 12 | 13 | companion object { 14 | lateinit var instance: App 15 | private set 16 | 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline 2 | 3 | import android.Manifest 4 | import android.app.Activity 5 | import android.content.Intent 6 | import android.content.pm.PackageManager 7 | import android.os.Bundle 8 | import android.provider.MediaStore 9 | import android.view.MotionEvent 10 | import android.view.ScaleGestureDetector 11 | import android.view.View 12 | import android.widget.Toast 13 | import androidx.annotation.MainThread 14 | import androidx.appcompat.app.AppCompatActivity 15 | import androidx.core.app.ActivityCompat 16 | import androidx.recyclerview.widget.RecyclerView 17 | import com.sam.video.timeline.bean.TagLineViewData 18 | import com.sam.video.timeline.bean.VideoClip 19 | import com.sam.video.timeline.listener.Click 20 | import com.sam.video.timeline.listener.OnFrameClickListener 21 | import com.sam.video.timeline.listener.SelectAreaMagnetOnChangeListener 22 | import com.sam.video.timeline.listener.TagSelectAreaMagnetOnChangeListener 23 | import com.sam.video.timeline.widget.TagLineView 24 | import com.sam.video.timeline.widget.TagPopWindow 25 | import com.sam.video.timeline.widget.TimeLineBaseValue 26 | import com.sam.video.util.MediaStoreUtil 27 | import com.sam.video.util.ScreenUtil 28 | import com.sam.video.util.VideoUtils 29 | import com.sam.video.util.getScreenWidth 30 | import kotlinx.android.synthetic.main.activity_main.* 31 | import java.util.* 32 | 33 | class MainActivity : AppCompatActivity(), View.OnClickListener { 34 | private val videos = mutableListOf() 35 | val timeLineValue = TimeLineBaseValue() 36 | 37 | override fun onCreate(savedInstanceState: Bundle?) { 38 | super.onCreate(savedInstanceState) 39 | setContentView(R.layout.activity_main) 40 | tvAddVideo.setOnClickListener(this) 41 | tvAddTag.setOnClickListener(this) 42 | ivRemove.setOnClickListener(this) 43 | zoomFrameLayout.setOnClickListener(this) 44 | 45 | val halfScreenWidth = rvFrame.context.getScreenWidth() / 2 46 | rvFrame.setPadding(halfScreenWidth, 0, halfScreenWidth, 0) 47 | 48 | rvFrame.addOnItemTouchListener(object : OnFrameClickListener(rvFrame) { 49 | override fun onScale(detector: ScaleGestureDetector): Boolean { 50 | return true 51 | } 52 | 53 | override fun onLongClick(e: MotionEvent): Boolean { 54 | return false 55 | 56 | } 57 | 58 | override fun onClick(e: MotionEvent): Boolean { 59 | //点击的位置 60 | rvFrame.findVideoByX(e.x)?.let { 61 | if (rvFrame.findVideoByX(rvFrame.paddingLeft.toFloat()) == it) { 62 | //已选中,切换状态 63 | selectVideo = if (selectVideo == it) { 64 | null 65 | } else { 66 | it 67 | } 68 | } else { 69 | //移动用户点击的位置到中间 70 | rvFrame.postDelayed( 71 | { 72 | if (selectVideo != null) { 73 | selectVideo = rvFrame.findVideoByX(e.x) 74 | } 75 | rvFrame.smoothScrollBy((e.x - rvFrame.paddingLeft).toInt(), 0) 76 | }, 77 | 100 78 | ) 79 | } 80 | } ?: run { 81 | selectVideo?.let { selectVideo = null } 82 | return false 83 | } 84 | 85 | return true 86 | } 87 | }) 88 | 89 | rvFrame.addOnScrollListener(object : RecyclerView.OnScrollListener() { 90 | override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { 91 | super.onScrollStateChanged(recyclerView, newState) 92 | if (newState == RecyclerView.SCROLL_STATE_IDLE) { 93 | clearSelectVideoIfNeed() 94 | } 95 | } 96 | 97 | }) 98 | 99 | tagView.onItemClickListener = object : TagLineView.OnItemClickListener { 100 | override fun onItemClick(item: TagLineViewData, x: Float) { 101 | selectTag(item) 102 | } 103 | 104 | override fun onItemGroupClick(groupData: List, x: Float) { 105 | val activeItem = tagView.activeItem 106 | val isActiveGroup = activeItem != null && groupData.indexOfFirst { it === activeItem } >= 0 107 | if (isActiveGroup) { 108 | showTagPopWindow(groupData, x) 109 | } else { 110 | selectTag(groupData[0]) 111 | } 112 | 113 | } 114 | 115 | 116 | } 117 | 118 | bindVideoData() 119 | requestPermission() 120 | } 121 | 122 | private var tagPopWindow: TagPopWindow? = null 123 | private val windowHeight = ScreenUtil.getInstance().realSizeHeight 124 | private fun showTagPopWindow(tagList: List, x: Float) { 125 | val popWindow = tagPopWindow ?: TagPopWindow(zoomFrameLayout.context).also { 126 | tagPopWindow = it 127 | it.onItemClickListener = Click.OnItemViewClickListener { _, t, _ -> 128 | selectTag(t, true) 129 | tagPopWindow?.dismiss() 130 | } 131 | } 132 | popWindow.updateData(tagList, tagView.activeItem) 133 | val location = IntArray(2) 134 | tagView.getLocationInWindow(location) 135 | popWindow.showAtTriangleX(tagView, x.toInt(), windowHeight - location[1]) //这里的位置计算考虑了最高选中的情况 136 | } 137 | 138 | private val videoSelectAreaChangeListener by lazy { 139 | object : SelectAreaMagnetOnChangeListener(selectAreaView.context) { 140 | override val timeJumpOffset: Long 141 | get() = selectAreaView.eventHandle.timeJumpOffset 142 | 143 | override val timeLineValue = (this@MainActivity).timeLineValue 144 | 145 | var downStartAtMs: Long = 0L 146 | var downEndAtMs: Long = 0L 147 | var downSpeed: Float = 1f 148 | override fun onTouchDown() { 149 | isOperateAreaSelect = true 150 | val selectVideo = selectVideo ?: return 151 | 152 | //更新边缘,此处边缘不限 153 | startTimeEdge = 0 154 | endTimeEdge = Long.MAX_VALUE 155 | 156 | downStartAtMs = selectVideo.startAtMs 157 | downEndAtMs = selectVideo.endAtMs 158 | } 159 | 160 | override fun onTouchUp() { 161 | isOperateAreaSelect = false 162 | } 163 | 164 | override fun onChange( 165 | startOffset: Long, 166 | endOffset: Long, 167 | fromUser: Boolean 168 | ): Boolean { 169 | if (filterOnChange(startOffset, endOffset)) { 170 | return true 171 | } 172 | val selectVideo = selectVideo ?: return false 173 | if (startOffset != 0L) { 174 | // - 起始位置移动时,相对时间轴的开始位置其实是不变的,变的是当前选择视频的开始位置+长度 (此时因为总的时间轴变长,所以区域变化了) 175 | val oldStartTime = selectVideo.startAtMs 176 | selectVideo.startAtMs += (downSpeed * startOffset).toLong() 177 | //起始位置 + 吸附产生的时间差 178 | selectVideo.startAtMs += checkTimeJump( 179 | selectAreaView.startTime, 180 | startOffset < 0 181 | ) - selectAreaView.startTime 182 | 183 | if (selectVideo.startAtMs < 0) { 184 | selectVideo.startAtMs = 0 185 | } 186 | if (selectVideo.startAtMs > selectVideo.endAtMs - timeLineValue.minClipTime) { 187 | selectVideo.startAtMs = selectVideo.endAtMs - timeLineValue.minClipTime 188 | } 189 | 190 | 191 | selectAreaView.endTime = 192 | selectAreaView.startTime + selectVideo.durationMs //这样是经过换算的 193 | val realOffsetTime = selectVideo.startAtMs - oldStartTime 194 | if (fromUser) { //光标位置反向移动,保持时间轴和手的相对位置 195 | timeLineValue.time -= (realOffsetTime / downSpeed).toLong() 196 | if (timeLineValue.time < 0) { 197 | timeLineValue.time = 0 198 | } 199 | } 200 | updateVideoClip() 201 | return realOffsetTime != 0L 202 | } else if (endOffset != 0L) { 203 | // - 结束位置移动时,范围的起始位置也不变,结束位置会变。 204 | val oldEndMs = selectVideo.endAtMs 205 | selectVideo.endAtMs += (downSpeed * endOffset).toLong() 206 | selectAreaView.endTime = selectAreaView.startTime + selectVideo.durationMs 207 | 208 | selectVideo.endAtMs += checkTimeJump( 209 | selectAreaView.endTime, 210 | endOffset < 0 211 | ) - selectAreaView.endTime 212 | if (selectVideo.endAtMs < selectVideo.startAtMs + timeLineValue.minClipTime) { 213 | selectVideo.endAtMs = selectVideo.startAtMs + timeLineValue.minClipTime 214 | } 215 | if (selectVideo.endAtMs > selectVideo.originalDurationMs) { 216 | selectVideo.endAtMs = selectVideo.originalDurationMs 217 | } 218 | selectAreaView.endTime = selectAreaView.startTime + selectVideo.durationMs 219 | val realOffsetTime = selectVideo.endAtMs - oldEndMs 220 | if (!fromUser) { 221 | //结束位置,如果是动画,光标需要跟着动画 222 | timeLineValue.time += (realOffsetTime / downSpeed).toLong() 223 | if (timeLineValue.time < 0) { 224 | timeLineValue.time = 0 225 | } 226 | } 227 | updateVideoClip() 228 | return realOffsetTime != 0L 229 | } 230 | return false 231 | } 232 | } 233 | } 234 | 235 | private val tagSelectAreaChangeListener: TagSelectAreaMagnetOnChangeListener by lazy { 236 | object : TagSelectAreaMagnetOnChangeListener(tagView, tagView.context) { 237 | override val timeJumpOffset: Long 238 | get() = selectAreaView.eventHandle.timeJumpOffset 239 | 240 | override fun onTouchDown() { 241 | endTimeEdge = timeLineValue?.duration ?: 0L 242 | } 243 | 244 | 245 | override val timeLineValue: TimeLineBaseValue? 246 | get() = zoomFrameLayout.timeLineValue 247 | 248 | override fun afterSelectAreaChange(realOffset: Long, fromUser: Boolean) { 249 | handleAfterSelectAreaChange(realOffset, fromUser) 250 | } 251 | } 252 | } 253 | 254 | private fun handleAfterSelectAreaChange(realOffset: Long, fromUser: Boolean) { 255 | if (realOffset == 0L) { 256 | return 257 | } 258 | 259 | tagView.dataChange() 260 | tagView.activeItem?.let { TagLineViewData -> 261 | selectAreaView.startTime = TagLineViewData.startTime 262 | selectAreaView.endTime = TagLineViewData.endTime 263 | } 264 | 265 | if (fromUser) { 266 | selectAreaView.invalidate() 267 | } else { 268 | timeLineValue.time += realOffset 269 | zoomFrameLayout.dispatchUpdateTime() 270 | } 271 | 272 | } 273 | 274 | /** 275 | * 选中标签 276 | * @param moveTop 移到顶部 277 | */ 278 | private fun selectTag(item: TagLineViewData, moveTop: Boolean = false) { 279 | selectVideo = null 280 | rvFrame.hasBorder = false 281 | tagView.activeItem = item 282 | selectAreaView.startTime = item.startTime 283 | selectAreaView.endTime = item.endTime 284 | selectAreaView.visibility = View.VISIBLE 285 | selectAreaView.onChangeListener = tagSelectAreaChangeListener 286 | selectAreaView.invalidate() 287 | if (moveTop) { 288 | tagView.bringTagToTop(item) 289 | } else { 290 | tagView.invalidate() 291 | } 292 | ivRemove.visibility = View.VISIBLE 293 | tagSelectAreaChangeListener.updateTimeSetData(item) 294 | 295 | } 296 | /** 297 | * 清除选中模式 298 | */ 299 | private fun clearTagSelect() { 300 | selectAreaView.visibility = View.GONE 301 | rvFrame.hasBorder = true 302 | tagView.activeItem = null 303 | } 304 | 305 | 306 | private fun bindVideoData() { 307 | zoomFrameLayout.scaleEnable = true 308 | rvFrame.videoData = videos 309 | 310 | zoomFrameLayout.timeLineValue = timeLineValue 311 | zoomFrameLayout.dispatchTimeLineValue() 312 | zoomFrameLayout.dispatchScaleChange() 313 | } 314 | 315 | /** 316 | * 更新全局的时间轴 317 | * @param fromUser 用户操作引起的,此时不更改缩放尺度 318 | */ 319 | private fun updateTimeLineValue(fromUser: Boolean = false) { 320 | /** 321 | 1、UI定一个默认初始长度(约一屏或一屏半),用户导入视频初始都伸缩为初始长度;初始精度根据初始长度和视频时长计算出来; 322 | 2、若用户导入视频拉伸到最长时,总长度还短于初始长度,则原始视频最长能拉到多长就展示多长; 323 | 3、最大精度:即拉伸到极限时,一帧时长暂定0.25秒; 324 | */ 325 | timeLineValue.apply { 326 | val isFirst = duration == 0L 327 | duration = totalDurationMs 328 | if (time > duration) { 329 | time = duration 330 | } 331 | 332 | // if (fromUser || duration == 0L ) { 333 | // return 334 | // } 335 | if (isFirst) {//首次 336 | resetStandPxInSecond() 337 | } else { 338 | fitScaleForScreen() 339 | } 340 | zoomFrameLayout.dispatchTimeLineValue() 341 | zoomFrameLayout.dispatchScaleChange() 342 | } 343 | } 344 | 345 | private val totalDurationMs: Long //当前正在播放视频的总时长 346 | get() { 347 | var result = 0L 348 | for (video in videos) { 349 | result += video.durationMs 350 | } 351 | return result 352 | } 353 | 354 | /** 355 | * 更新视频的截取信息 356 | * update and dispatch 357 | * */ 358 | private fun updateVideoClip() { 359 | updateTimeLineValue(true) 360 | rvFrame.rebindFrameInfo() 361 | rulerView.invalidate() 362 | selectAreaView.invalidate() 363 | } 364 | 365 | override fun onClick(v: View?) { 366 | when (v) { 367 | tvAddVideo -> startGetVideoIntent() 368 | ivRemove -> removeLastVideo() 369 | tvAddTag -> addTagClick() 370 | zoomFrameLayout -> clearTagSelect() 371 | } 372 | } 373 | 374 | /** 375 | * 是否正在操作区域选择 376 | */ 377 | private var isOperateAreaSelect = false 378 | 379 | private fun clearSelectVideoIfNeed() { 380 | if (selectVideo != null && !selectAreaView.timeInArea() 381 | && !isOperateAreaSelect //未操作区域选择时 382 | ) { 383 | selectVideo = null 384 | } 385 | } 386 | 387 | //打开系统选择视频界面 388 | private fun startGetVideoIntent() { 389 | val intent = Intent(Intent.ACTION_PICK) 390 | intent.setDataAndType( 391 | MediaStore.Video.Media.EXTERNAL_CONTENT_URI, 392 | Constants.VIDEO_TYPE 393 | ) 394 | val chooserIntent = Intent.createChooser(intent, null) 395 | startActivityForResult(chooserIntent, Constants.REQUEST_VIDEO) 396 | } 397 | 398 | private fun removeLastVideo() { 399 | if (videos.size > 0) { 400 | videos.removeAt(videos.size - 1) 401 | } 402 | updateVideos() 403 | } 404 | 405 | 406 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 407 | super.onActivityResult(requestCode, resultCode, data) 408 | val data = data ?: return 409 | if (resultCode == RESULT_OK) { 410 | if (requestCode == Constants.REQUEST_VIDEO) { 411 | val uri = data.data 412 | val path = MediaStoreUtil.audioUriToRealPath(this, uri) ?: return 413 | 414 | val duration = VideoUtils.getVideoDuration(this, path) 415 | videos.add( 416 | VideoClip( 417 | UUID.randomUUID().toString(), path, 418 | duration, 0, duration 419 | ) 420 | ) 421 | updateVideos() 422 | } 423 | } 424 | } 425 | 426 | private fun updateVideos() { 427 | rvFrame.rebindFrameInfo() 428 | updateTimeLineValue(false) 429 | } 430 | 431 | 432 | /** 选段 */ 433 | var selectVideo: VideoClip? = null 434 | set(value) { 435 | field = value 436 | if (value == null) { 437 | //取消选中 438 | rvFrame.hasBorder = true 439 | selectAreaView.visibility = View.GONE 440 | } else { 441 | clearTagSelect() 442 | //选中视频 443 | selectAreaView.startTime = 0 444 | selectAreaView.onChangeListener = videoSelectAreaChangeListener 445 | for ((index, item) in videos.withIndex()) { 446 | if (item === value) { 447 | selectAreaView.offsetStart = if (index > 0) { 448 | rvFrame.halfDurationSpace 449 | } else { 450 | 0 451 | } 452 | selectAreaView.offsetEnd = if (index < videos.size - 1) { 453 | rvFrame.halfDurationSpace 454 | } else { 455 | 0 456 | } 457 | break 458 | } 459 | selectAreaView.startTime += item.durationMs 460 | } 461 | selectAreaView.endTime = selectAreaView.startTime + value.durationMs 462 | rvFrame.hasBorder = false 463 | selectAreaView.visibility = View.VISIBLE 464 | } 465 | } 466 | 467 | var mListener: PermissionListener? = null 468 | private fun requestRuntimePermission( 469 | permissions: Array, 470 | listener: PermissionListener 471 | ) { // 获取栈顶Activity 472 | val topActivity: Activity = this 473 | mListener = listener 474 | // 需要请求的权限列表 475 | val requestPermisssionList: MutableList = 476 | ArrayList() 477 | // 检查权限 是否已被授权 478 | for (permission in permissions) { 479 | if (ActivityCompat.checkSelfPermission( 480 | topActivity, 481 | permission 482 | ) != PackageManager.PERMISSION_GRANTED 483 | ) // 未授权时添加该权限 484 | requestPermisssionList.add(permission) 485 | } 486 | if (requestPermisssionList.isEmpty()) // 所有权限已经被授权过 回调Listener onGranted方法 已授权 487 | listener.onGranted() else // 进行请求权限操作 488 | ActivityCompat.requestPermissions( 489 | topActivity, 490 | requestPermisssionList.toTypedArray(), 491 | Constants.REQUEST_CODE 492 | ) 493 | } 494 | 495 | 496 | // 请求权限的回调 497 | override fun onRequestPermissionsResult( 498 | requestCode: Int, 499 | permissions: Array, 500 | grantResults: IntArray 501 | ) { 502 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 503 | when (requestCode) { 504 | Constants.REQUEST_CODE -> { 505 | val deniedPermissionList: MutableList = 506 | ArrayList() 507 | // 检查返回授权结果不为空 508 | if (grantResults.size > 0) { // 判断授权结果 509 | var i = 0 510 | while (i < grantResults.size) { 511 | val result = grantResults[i] 512 | if (result != PackageManager.PERMISSION_GRANTED) // 保存被用户拒绝的权限 513 | deniedPermissionList.add(permissions[i]) 514 | i++ 515 | } 516 | if (deniedPermissionList.isEmpty()) { // 都被授权 回调Listener onGranted方法 已授权 517 | mListener?.onGranted() 518 | } else { // 有权限被拒绝 回调Listner onDeynied方法 519 | mListener?.onDenied(deniedPermissionList) 520 | } 521 | } 522 | } 523 | } 524 | } 525 | 526 | fun addTagClick() { 527 | addTag("Android") 528 | addTag("面试官") 529 | addTag("山言两语") 530 | 531 | tagView.addTextTag( 532 | "啦啦啦", 533 | 500L, 534 | 3000L, 535 | tagView.getRandomColorForText() 536 | ) 537 | } 538 | 539 | /** 540 | * 添加 视频贴纸标签 541 | */ 542 | @MainThread 543 | private fun addTag(text: String) { 544 | tagView.addTextTag( 545 | text, 546 | 0L, 547 | 1000L, 548 | tagView.getRandomColorForText() 549 | ) 550 | } 551 | 552 | 553 | interface PermissionListener { 554 | /** 555 | * 授权成功 556 | */ 557 | fun onGranted() 558 | 559 | /** 560 | * 授权失败 561 | * 562 | * @param deniedPermission 563 | */ 564 | fun onDenied(deniedPermission: List?) 565 | } 566 | 567 | object Constants { 568 | const val VIDEO_TYPE = "video/*" 569 | const val AUDIO_TYPE = "audio/*" 570 | const val KEY_VIDEO_EXTRA = "video_path" 571 | const val KEY_VIDEO_ARRAY_EXTRA = "video_array" 572 | const val REQUEST_VIDEO = 1 573 | const val REQUEST_AUDIO = 2 574 | 575 | const val REQUEST_CODE = 1 //用于运行时权限请求的请求码 576 | } 577 | 578 | private fun requestPermission() { 579 | requestRuntimePermission( 580 | arrayOf( 581 | Manifest.permission.WRITE_EXTERNAL_STORAGE, 582 | Manifest.permission.READ_EXTERNAL_STORAGE 583 | ), object : PermissionListener { 584 | override fun onGranted() { 585 | } 586 | 587 | override fun onDenied(deniedPermission: List?) { 588 | Toast.makeText(this@MainActivity, "拒绝权限", Toast.LENGTH_SHORT).show() 589 | finish() 590 | } 591 | }) 592 | } 593 | 594 | } 595 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/adapter/TagAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.adapter 2 | 3 | import android.content.Context 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.bumptech.glide.Glide 9 | import com.chad.library.adapter.base.BaseViewHolder 10 | import com.sam.video.timeline.R 11 | import com.sam.video.timeline.bean.TagLineViewData 12 | import com.sam.video.timeline.bean.TagType 13 | import kotlinx.android.synthetic.main.item_tag_img.view.* 14 | import kotlinx.android.synthetic.main.item_tag_img.view.selectView 15 | import kotlinx.android.synthetic.main.item_tag_text.view.* 16 | 17 | 18 | /** 19 | * 文字贴纸 弹窗列表 adapter 20 | * @author SamWang(33691286@qq.com) 21 | * @date 2019-08-07 22 | */ 23 | class TagAdapter(val context: Context, val data: MutableList) : 24 | RecyclerView.Adapter(), View.OnClickListener { 25 | var selectedItem: TagLineViewData? = null 26 | 27 | 28 | override fun getItemViewType(position: Int): Int { 29 | return data[position].itemType 30 | } 31 | 32 | //fixme 不知为何,没有复用viewHolder,滑动一直调create,导致滑动很卡 33 | override fun onCreateViewHolder(parent: ViewGroup, itemType: Int): BaseViewHolder { 34 | val layout: Int = if (itemType == TagType.ITEM_TYPE_IMG) { 35 | R.layout.item_tag_img 36 | } else { //TEXT 37 | R.layout.item_tag_text 38 | } 39 | val itemView: View = LayoutInflater.from(context).inflate(layout, parent, false) 40 | 41 | itemView.setOnClickListener(this) 42 | return BaseViewHolder(itemView) 43 | } 44 | 45 | override fun getItemCount(): Int { 46 | return data.size 47 | } 48 | 49 | override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { 50 | val item = data[position] 51 | holder.itemView.selectView.visibility = if(selectedItem === item) View.VISIBLE else View.GONE 52 | if (getItemViewType(position) == TagType.ITEM_TYPE_IMG) { 53 | holder.itemView.iv?.let { 54 | it.setBackgroundColor(item.color) 55 | Glide.with(it).load(item.content).into(it) 56 | } 57 | } else { 58 | holder.itemView.tv?.apply { 59 | setBackgroundColor(item.color) 60 | text = item.content 61 | } 62 | } 63 | } 64 | 65 | var onClickListener: View.OnClickListener? = null 66 | 67 | override fun onClick(v: View?) { 68 | v ?: return 69 | onClickListener?.onClick(v) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/adapter/VideoFrameAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.adapter 2 | 3 | import android.view.Gravity 4 | import android.widget.FrameLayout 5 | import android.widget.ImageView 6 | import com.bumptech.glide.Glide 7 | import com.chad.library.adapter.base.BaseQuickAdapter 8 | import com.chad.library.adapter.base.BaseViewHolder 9 | import com.sam.video.timeline.R 10 | import com.sam.video.timeline.bean.VideoFrameData 11 | import com.sam.video.timeline.widget.RoundRectMask 12 | 13 | /** 14 | * 帧列表 adapter 15 | * @author SamWang(33691286@qq.com) 16 | * @date 2019-07-19 17 | */ 18 | class VideoFrameAdapter(data: MutableList, private val frameWidth: Int) : BaseQuickAdapter( 19 | R.layout.item_video_frame, data) { 20 | 21 | 22 | override fun convert(helper: BaseViewHolder, item: VideoFrameData) { 23 | 24 | val imageView = helper.getView(R.id.iv) 25 | val layoutParams = helper.itemView.layoutParams 26 | layoutParams.width = item.frameWidth 27 | 28 | val maskView = helper.getView(R.id.mask) 29 | maskView.setCornerRadiusDp(4f) 30 | maskView.setCorners(item.isFirstItem, item.isLastItem, item.isFirstItem, item.isLastItem) 31 | val maskLayoutParams = maskView.layoutParams as FrameLayout.LayoutParams 32 | val ivLayoutParams = imageView.layoutParams as FrameLayout.LayoutParams 33 | 34 | if (item.isFirstItem) { 35 | maskLayoutParams.gravity = Gravity.LEFT 36 | ivLayoutParams.gravity = Gravity.RIGHT 37 | } else { 38 | maskLayoutParams.gravity = Gravity.RIGHT 39 | ivLayoutParams.gravity = Gravity.LEFT 40 | } 41 | 42 | maskLayoutParams.width = if (item.isFirstItem && item.isLastItem) { 43 | ivLayoutParams.gravity = Gravity.LEFT 44 | ivLayoutParams.marginStart = -item.offsetX //只有一帧考虑位移 45 | 46 | item.frameWidth //如果一段视频在列表中只有一帧,则要显示全部圆角,遮罩同步缩小 47 | } else { 48 | ivLayoutParams.marginStart = 0 49 | frameWidth 50 | } 51 | 52 | Glide.with(imageView) 53 | .asBitmap() 54 | .load(item.videoData.originalFilePath) 55 | .frame(item.frameClipTime * 1000) 56 | .thumbnail( 57 | //todo 更好的方案是往前找一个已经有的缓存帧 58 | Glide.with(imageView).asBitmap().load(item.videoData.originalFilePath) 59 | ) 60 | .into(imageView) 61 | } 62 | } 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/bean/TagLineViewData.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.bean 2 | 3 | import androidx.annotation.ColorInt 4 | import androidx.annotation.IntDef 5 | 6 | /** 7 | * 标签线 需要的数据 8 | * 9 | * 具体的绘制内容由文字标签给出 10 | * @author SamWang(33691286@qq.com) 11 | * @date 2019-08-03 12 | */ 13 | class TagLineViewData( 14 | @ColorInt var color: Int, 15 | var startTime: Long, 16 | var endTime: Long, 17 | @TagType val itemType: Int, 18 | var content: String, //内容。img->path, text->text 19 | var originData: Any? = null, // 原始数据 20 | var tagDrawStartTime: Long = 0L, //标签开始绘制的时间,用于左边缘停留 21 | var index: Int = 0, //所属层级 22 | var groupHead: TagLineViewData?= null //所属分组的第一个,null时未分组,在屏幕外 23 | ) 24 | 25 | 26 | @IntDef(TagType.ITEM_TYPE_TEXT, TagType.ITEM_TYPE_IMG) 27 | annotation class TagType { 28 | companion object { 29 | const val ITEM_TYPE_TEXT = 1 30 | const val ITEM_TYPE_IMG = 2 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/bean/TimeLineAreaData.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.bean 2 | 3 | /** 4 | * 时间轴区域数据 5 | * 实现该接口以指定时间轴上的一段区域,并在开始和结束位置与相应的视频片断绑定 6 | * @author SamWang(33691286@qq.com) 7 | * @date 2019-12-04 8 | */ 9 | interface TimeLineAreaData { 10 | var start: Long //区域开始位置 11 | var duration: Long //区域持续时间 12 | //----------- 13 | var startVideoClipId: String //起始点所属片段,编辑完贴纸后需记录 14 | var startVideoClipOffsetMs: Long //起始点位于所属片段位置的时间(未处理变速、绝对时长),仅在贴纸调整时会有影响 15 | var endVideoClipId: String //结束位置 16 | var endVideoClipOffsetMs: Long //起始点位于所属片段位置的时间(未处理变速、绝对时长) 17 | 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/bean/VideoClip.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.bean 2 | 3 | 4 | /** 5 | * 视频美化视频片段 6 | * @author WangYingHao 7 | * @since 2019-08-03 8 | */ 9 | data class VideoClip( 10 | var id: String, //唯一标识 11 | var originalFilePath: String,//文件路径 12 | var originalDurationMs: Long = 0,//原始文件时长 13 | var startAtMs: Long = 0, //视频有效起始时刻 14 | var endAtMs: Long = 0//视频有效结束时刻 15 | ){ 16 | val durationMs: Long //视频有效播放时长,受节选、速度、转场吃掉时间影响 17 | get() { 18 | return endAtMs - startAtMs 19 | } 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/bean/VideoFrameData.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.bean 2 | 3 | data class VideoFrameData( 4 | var videoData: VideoClip, 5 | var time: Long, //这个帧片断 开始时间; 与整个视频的开始时间为-“相对时间”;例:任何视频片断的第一帧为时间为0 6 | var frameClipTime: Long, //这个片断 第一个合适的最小时间轴精度时间(避免片断开始时间一直变,图片一直刷),这边是考虑裁剪后的时间-“本视频文件的绝对时间”;例:不同视频的第一帧时间为截断开始的时间附近 7 | var frameWidth: Int, //这一帧的宽度 8 | var isFirstItem: Boolean = false, 9 | var isLastItem: Boolean = false, 10 | var offsetX: Int = 0 //左边偏移,用于第一帧不是从起始位置开始时的开始位置 11 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/listener/Click.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.listener; 2 | 3 | import android.view.View; 4 | 5 | /** 6 | * Created by wyh3 on 2018/7/21. 7 | * 一些通用的、与交互相关的、与业务无关的点击事件监听器 8 | * 抽离出来公用,避免每次用的时候都要重新写一个 9 | */ 10 | public class Click { 11 | 12 | /** 13 | * 天真无暇的click 14 | */ 15 | public interface OnClickListener { 16 | void onClick(); 17 | } 18 | 19 | /** 20 | * 监听控件点击时还想多传一个参数 21 | */ 22 | public interface OnViewClickListener { 23 | void onClick(View view, T t); 24 | } 25 | 26 | public interface OnViewLongClickListener { 27 | void onLongClick(View view, T t); 28 | } 29 | 30 | /** 31 | * 监听对象可能是任意一个抽象的实体 32 | */ 33 | public interface OnObjectClickListener { 34 | void onObjectClick(T t); 35 | } 36 | 37 | /** 38 | * 需要回调位置的监听 39 | */ 40 | public interface OnPositionClickListener { 41 | void onPositionClick(int position); 42 | } 43 | 44 | /** 45 | * 需要回调位置的监听 46 | */ 47 | public interface OnItemClickListener { 48 | void onItemClick(T t, int position); 49 | } 50 | 51 | /** 52 | * 需要回调位置的监听 53 | */ 54 | public interface OnItemViewClickListener { 55 | void onItemClick(View view, T t, int position); 56 | } 57 | 58 | 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/listener/OnFrameClickListener.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.listener 2 | 3 | import android.content.Context 4 | import android.view.GestureDetector 5 | import android.view.MotionEvent 6 | import android.view.ScaleGestureDetector 7 | import androidx.recyclerview.widget.RecyclerView 8 | 9 | /** 10 | * 帧列表点击事件 11 | * 调用 {Recycler.addOnItemTouchListener} 12 | */ 13 | abstract class OnFrameClickListener( 14 | private val recyclerView: RecyclerView 15 | ) : RecyclerView.SimpleOnItemTouchListener() { 16 | private val context: Context = recyclerView.context 17 | 18 | val gestureDetector: GestureDetector by lazy(LazyThreadSafetyMode.NONE) { 19 | val detector = GestureDetector(context, gestureListener) 20 | detector.setIsLongpressEnabled(false) 21 | detector 22 | } 23 | 24 | override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { 25 | //这个方法一直不会被调用。。。。所以需要在外面重置longClickEnable 26 | // Log.d("Sam", " $e ") 27 | if (e.action == MotionEvent.ACTION_DOWN && e.pointerCount == 1) { 28 | gestureDetector.setIsLongpressEnabled(true) 29 | } 30 | gestureDetector.onTouchEvent(e) 31 | } 32 | 33 | override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { 34 | // Log.d("Sam", " $e ") 35 | if (e.action == MotionEvent.ACTION_DOWN && e.pointerCount == 1) { 36 | gestureDetector.setIsLongpressEnabled(true) 37 | } 38 | gestureDetector.onTouchEvent(e) 39 | return false 40 | } 41 | 42 | private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { 43 | override fun onDown(e: MotionEvent?): Boolean { 44 | return true 45 | } 46 | 47 | override fun onSingleTapUp(e: MotionEvent): Boolean { 48 | if (recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { 49 | return false 50 | } 51 | onClick(e) 52 | return false 53 | } 54 | 55 | override fun onLongPress(e: MotionEvent?) { 56 | super.onLongPress(e) 57 | // Log.d("Sam", " onLongPress ${gestureDetector.isLongpressEnabled} ") 58 | if (!gestureDetector.isLongpressEnabled || recyclerView.scrollState != RecyclerView.SCROLL_STATE_IDLE) { 59 | return 60 | } 61 | e?.let { 62 | onLongClick(e) 63 | } 64 | } 65 | 66 | } 67 | 68 | 69 | open fun onLongClick(e: MotionEvent): Boolean { 70 | return false 71 | } 72 | 73 | /** 74 | * item 点击 75 | * @param v 点击的view 76 | * @param position 点击的位置 77 | */ 78 | abstract fun onClick(e: MotionEvent): Boolean 79 | 80 | abstract fun onScale(detector: ScaleGestureDetector): Boolean 81 | 82 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/listener/SelectAreaMagnetOnChangeListener.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.listener 2 | 3 | import android.content.Context 4 | import com.sam.video.timeline.widget.SelectAreaView 5 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_NONE 6 | import com.sam.video.timeline.widget.TimeLineBaseValue 7 | import com.sam.video.util.vibratorOneShot 8 | import java.util.* 9 | import kotlin.math.abs 10 | 11 | 12 | /** 13 | * 区域选择变化监听 14 | * 封装磁吸功能通用代码 15 | * @author SamWang(33691286@qq.com) 16 | * @date 2019-08-20 17 | */ 18 | abstract class SelectAreaMagnetOnChangeListener( 19 | private val context: Context 20 | ): SelectAreaView.OnChangeListener { 21 | protected val timeSet = TreeSet() //磁吸的时间点数据 22 | 23 | private var isJumpOffsetFilter = false //吸附后左右要超出一定的幅度才开始能继续拖动!!! 24 | private var jumpOffsetPx = 0f 25 | 26 | override var mode: Int = MODE_NONE 27 | 28 | /** 29 | * 最长距离,>0时限制 30 | */ 31 | var maxDuration = 0 32 | /** 33 | * 可用的时间区间-开始 34 | */ 35 | var startTimeEdge = 0L 36 | /** 37 | * 可用的时间区间-结束 38 | */ 39 | var endTimeEdge = 0L 40 | 41 | /** 42 | * 更新磁吸数据 43 | * @param item 选中项 44 | */ 45 | fun updateTimeSetData() { 46 | timeSet.clear() 47 | } 48 | 49 | /** 50 | * 过滤吸附范围 51 | * 如果被磁吸吃掉的事件不要再往上传递 52 | */ 53 | fun filterOnChange(startOffset: Long, endOffset: Long):Boolean { 54 | if (isJumpOffsetFilter) { 55 | //过滤 56 | jumpOffsetPx += startOffset + endOffset 57 | if (abs(jumpOffsetPx) < timeJumpOffset * 2) { //这个系数也是自己瞎填的,系数越大就越难从吸附中挣脱出来 58 | //左右范围内不响应 59 | return true 60 | } else { 61 | jumpOffsetPx = 0f 62 | isJumpOffsetFilter = false 63 | } 64 | 65 | } 66 | return false 67 | } 68 | 69 | /** 70 | * 检查时间吸附(跳跃到合适的时间点) 71 | * @param left 左移 72 | * @param triggerJumpEvent 触发吸附效果,子类可以 false,自己再返回值决定是否触发 73 | */ 74 | fun checkTimeJump(time: Long, left: Boolean, triggerJumpEvent: Boolean = true): Long { 75 | 76 | var newTime = -1L 77 | for (t in timeSet) { 78 | if (left) { 79 | if (t > time) { //往左,吸附的时间只会是比它还小的时间 80 | break 81 | } 82 | 83 | if (time - t <= timeJumpOffset) { 84 | newTime = t 85 | } 86 | } else { 87 | //往右移,吸附的时间只会是比它还大的时间 88 | if (t > time) { 89 | if (t - time > timeJumpOffset) { 90 | break 91 | } else { 92 | newTime = t 93 | } 94 | } 95 | } 96 | } 97 | 98 | //当前光标吸附 99 | if (newTime == -1L) { 100 | timeLineValue?.time?.let { t -> 101 | 102 | if (left) { 103 | if (t > time) { //往左,吸附的时间只会是比它还小的时间 104 | return@let 105 | } 106 | if (time - t <= timeJumpOffset) { 107 | newTime = t 108 | } 109 | } else { 110 | //往右移,吸附的时间只会是比它还大的时间 111 | if (t > time) { 112 | if (t - time > timeJumpOffset) { 113 | return@let 114 | } else { 115 | newTime = t 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | if (newTime >= 0 && newTime != time) { 123 | // Log.d("Sam", "----- $newTime : $time ") 124 | if (newTime > endTimeEdge) { 125 | return endTimeEdge 126 | }else if (newTime < startTimeEdge) { 127 | return startTimeEdge 128 | } 129 | 130 | triggerJumpEvent() 131 | return newTime 132 | } 133 | 134 | return time 135 | } 136 | 137 | /** 138 | * 触发吸附事件 139 | */ 140 | fun triggerJumpEvent() { 141 | context.vibratorOneShot() 142 | jumpOffsetPx = 0f 143 | isJumpOffsetFilter = true 144 | } 145 | 146 | override fun onTouchUp() { 147 | isJumpOffsetFilter = false 148 | jumpOffsetPx = 0f 149 | mode = MODE_NONE 150 | } 151 | 152 | abstract val timeLineValue: TimeLineBaseValue? 153 | 154 | abstract val timeJumpOffset: Long 155 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/listener/TagSelectAreaMagnetOnChangeListener.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.listener 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import com.sam.video.timeline.bean.TagLineViewData 6 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_END 7 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_START 8 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_WHOLE 9 | import kotlin.math.abs 10 | import kotlin.math.max 11 | import kotlin.math.min 12 | 13 | 14 | /** 15 | * 区域选择变化监听 - 标签 16 | * 封装磁吸功能通用代码 17 | * @author SamWang(33691286@qq.com) 18 | * @date 2019-08-20 19 | */ 20 | abstract class TagSelectAreaMagnetOnChangeListener( 21 | private val tagView: BaseTagView, context: Context 22 | ) : SelectAreaMagnetOnChangeListener(context) { 23 | 24 | /** 25 | * 获取按下时应该seek的时间 26 | * null时,无需seek 27 | */ 28 | fun getDownSeekTime(): Long? { 29 | val item = tagView.activeItem ?: return null 30 | return when (mode) { 31 | MODE_START, MODE_WHOLE -> item.startTime 32 | MODE_END -> item.endTime 33 | else -> null 34 | } 35 | } 36 | /** 37 | * 更新磁吸数据 38 | * @param item 选中项 39 | */ 40 | open fun updateTimeSetData(item: TagLineViewData) { 41 | updateTimeSetData() 42 | for (datum in tagView.data) { 43 | if (datum != item) { 44 | timeSet.add(datum.startTime) 45 | timeSet.add(datum.endTime) 46 | } 47 | } 48 | } 49 | 50 | override fun onChange(startOffset: Long, endOffset: Long, fromUser: Boolean): Boolean { 51 | if (filterOnChange(startOffset, endOffset)) { 52 | return true 53 | } 54 | 55 | val tagLineViewData = tagView.activeItem ?: return false 56 | val timeLineValue = timeLineValue ?: return false 57 | if (startOffset != 0L) { 58 | val oldTime = tagLineViewData.startTime 59 | 60 | val minStartTime = if (maxDuration > 0) { 61 | max(startTimeEdge, tagLineViewData.endTime - maxDuration) 62 | } else { 63 | startTimeEdge 64 | } 65 | 66 | tagLineViewData.startTime = 67 | checkTimeJump(tagLineViewData.startTime + startOffset, startOffset < 0) 68 | 69 | 70 | if (tagLineViewData.startTime < minStartTime) { 71 | tagLineViewData.startTime = minStartTime 72 | } 73 | 74 | if (tagLineViewData.startTime > tagLineViewData.endTime - timeLineValue.minClipTime) { 75 | tagLineViewData.startTime = tagLineViewData.endTime - timeLineValue.minClipTime 76 | } 77 | Log.d("Sam", "onChange : startTime $oldTime ${tagLineViewData.startTime}") 78 | val realOffset = tagLineViewData.startTime - oldTime 79 | afterSelectAreaChange(realOffset, fromUser) 80 | return realOffset != 0L 81 | } else if (endOffset != 0L) { 82 | // - 结束位置移动时,范围的起始位置也不变,结束位置会变。 83 | val oldTime = tagLineViewData.endTime 84 | tagLineViewData.endTime = 85 | checkTimeJump(tagLineViewData.endTime + endOffset, endOffset < 0) 86 | 87 | val maxEndTime = if (maxDuration > 0) { 88 | min(endTimeEdge, tagLineViewData.startTime + maxDuration) 89 | } else { 90 | endTimeEdge 91 | } 92 | if (tagLineViewData.endTime > maxEndTime) { 93 | tagLineViewData.endTime = maxEndTime 94 | } 95 | 96 | if (tagLineViewData.endTime < tagLineViewData.startTime + timeLineValue.minClipTime) { 97 | tagLineViewData.endTime = tagLineViewData.startTime + timeLineValue.minClipTime 98 | } 99 | // Log.d("Sam", "onChange : startTime $oldTime ${tagLineViewData.endTime} ") 100 | val realOffset = tagLineViewData.endTime - oldTime 101 | afterSelectAreaChange(realOffset, fromUser) 102 | return realOffset != 0L 103 | } 104 | 105 | return false 106 | } 107 | 108 | 109 | override fun onMove(offset: Long, fromUser: Boolean): Boolean { 110 | if (filterOnChange(offset, 0) || offset == 0L) { 111 | return true 112 | } 113 | 114 | val tagLineViewData = tagView.activeItem ?: return false 115 | val timeLineValue = timeLineValue ?: return false 116 | 117 | //开始位置check 118 | var realOffset = offset 119 | val oldStartTime = tagLineViewData.startTime 120 | tagLineViewData.startTime += offset 121 | 122 | val oldEndTime = tagLineViewData.endTime 123 | tagLineViewData.endTime += offset 124 | 125 | //吸附 126 | val left = offset < 0 127 | val jumpStartOffset = checkTimeJump(tagLineViewData.startTime, left, false) - tagLineViewData.startTime 128 | val jumpEndOffset = checkTimeJump(tagLineViewData.endTime, left, false) - tagLineViewData.endTime 129 | //取较小的偏移吸附点 130 | val minJumpOffset = when { 131 | jumpStartOffset == 0L -> jumpEndOffset 132 | jumpEndOffset == 0L -> jumpStartOffset 133 | abs(jumpStartOffset) >= abs(jumpEndOffset) -> jumpEndOffset 134 | else -> jumpStartOffset 135 | } 136 | //判断这个吸附点是否可用 137 | if (minJumpOffset != 0L && tagLineViewData.startTime + minJumpOffset >= startTimeEdge && tagLineViewData.endTime + minJumpOffset <= endTimeEdge) { 138 | tagLineViewData.startTime += minJumpOffset 139 | tagLineViewData.endTime += minJumpOffset 140 | realOffset = minJumpOffset 141 | triggerJumpEvent() 142 | } 143 | 144 | if(tagLineViewData.startTime < startTimeEdge){ 145 | tagLineViewData.startTime = startTimeEdge 146 | realOffset = -oldStartTime 147 | tagLineViewData.endTime = oldEndTime + realOffset 148 | } 149 | 150 | //结束位置check 151 | if (tagLineViewData.endTime > endTimeEdge) { 152 | tagLineViewData.endTime = timeLineValue.duration 153 | realOffset = tagLineViewData.endTime - oldEndTime 154 | tagLineViewData.startTime = oldStartTime + realOffset 155 | } 156 | 157 | val change = realOffset != 0L 158 | 159 | if (change) { 160 | afterSelectAreaChange(realOffset, fromUser) 161 | } 162 | // Log.d("Sam", "onMove : $oldStartTime $oldEndTime - ${tagLineViewData.startTime} ${tagLineViewData.endTime} $realOffset ") 163 | return change 164 | 165 | } 166 | 167 | abstract fun afterSelectAreaChange(realOffset: Long, fromUser: Boolean) 168 | 169 | interface BaseTagView { 170 | val data: MutableList 171 | 172 | var activeItem: TagLineViewData? 173 | } 174 | 175 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/listener/VideoPlayerOperate.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.listener 2 | 3 | import com.sam.video.timeline.widget.TimeChangeListener 4 | 5 | /** 6 | * 视频美化首页 视频播放控件的操作接口 7 | * @author SamWang(33691286@qq.com) 8 | * @date 2019-07-24 9 | */ 10 | interface VideoPlayerOperate : TimeChangeListener { 11 | /** 增删、排序后 更新视频信息 */ 12 | fun updateVideoInfo() 13 | 14 | fun startTrackingTouch() 15 | 16 | fun stopTrackingTouch(ms: Long) 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/ActiveFullTextTagLineView.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.graphics.RectF 6 | import android.util.AttributeSet 7 | import com.sam.video.timeline.bean.TagLineViewData 8 | import com.sam.video.timeline.bean.TagType 9 | 10 | 11 | /** 12 | * 13 | * 文字 激活时全部显示,非激活时限制长度 14 | * 15 | * @author SamWang(33691286@qq.com) 16 | * @date 2019-07-31 17 | */ 18 | class ActiveFullTextTagLineView @JvmOverloads constructor( 19 | context: Context, paramAttributeSet: AttributeSet? = null, 20 | paramInt: Int = 0 21 | ) : WideTextTagLineView(context, paramAttributeSet, paramInt), 22 | TimeLineBaseValue.TimeLineBaseView { 23 | 24 | override fun updatePathBeforeDraw( 25 | item: TagLineViewData, 26 | canvas: Canvas, 27 | zIndex: Int, 28 | isActive: Boolean 29 | ) { 30 | //UPDATE PATH 31 | if (item.itemType == TagType.ITEM_TYPE_TEXT) { 32 | if (zIndex == 0 && isActive) { 33 | updateActivePath(getItemWidth(item)) 34 | } else { 35 | updateNormalPath(getItemWidth(item)) 36 | } 37 | } else { 38 | super.updatePathBeforeDraw(item, canvas, zIndex, isActive) 39 | } 40 | } 41 | 42 | override fun drawContentText(content: String, canvas: Canvas, rect: RectF, isActive: Boolean) { 43 | val paddingLeft = if (isActive) activeTextPadding else normalTextPadding 44 | val text = if(isActive) content else ellipsizeText(content) 45 | canvas.drawText(text, paddingLeft, rect.centerY() + textBaseY, textPaint) 46 | } 47 | 48 | 49 | override fun getItemWidth(item: TagLineViewData): Float { 50 | return if (item == activeItem && item.itemType == TagType.ITEM_TYPE_TEXT) { 51 | val width = textPaint.measureText(item.content) 52 | width + activeTextPadding * 2 53 | } else if (item.itemType == TagType.ITEM_TYPE_TEXT) { 54 | val width = textPaint.measureText(ellipsizeText(item.content)) 55 | width + activeTextPadding * 2 56 | } else { 57 | normalWidth 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/ActiveWideTextTagLineView.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.graphics.Color 6 | import android.graphics.RectF 7 | import android.util.AttributeSet 8 | import com.sam.video.timeline.bean.TagLineViewData 9 | import com.sam.video.timeline.bean.TagType 10 | import com.sam.video.util.getScreenWidth 11 | 12 | 13 | /** 14 | * 15 | * 文字 激活时最多半屏,非激活时一字 16 | * 17 | * @author SamWang(33691286@qq.com) 18 | * @date 2019-07-31 19 | */ 20 | class ActiveWideTextTagLineView @JvmOverloads constructor( 21 | context: Context, paramAttributeSet: AttributeSet? = null, 22 | paramInt: Int = 0 23 | ) : WideTextTagLineView(context, paramAttributeSet, paramInt), 24 | TimeLineBaseValue.TimeLineBaseView { 25 | 26 | override fun initMaxTextWidth(): Float { 27 | return (context.getScreenWidth() / 2).toFloat() 28 | } 29 | override fun updatePathBeforeDraw( 30 | item: TagLineViewData, 31 | canvas: Canvas, 32 | zIndex: Int, 33 | isActive: Boolean 34 | ) { 35 | //UPDATE PATH 36 | if (isActive && zIndex == 0) { 37 | if (item.itemType == TagType.ITEM_TYPE_TEXT) { 38 | val text = ellipsizeText(item.content) 39 | val width = textPaint.measureText(text) 40 | updateActivePath(width + activeTextPadding * 2) 41 | } else { 42 | updateActivePath() 43 | } 44 | } 45 | //这里的场景,未激活的都是同样的尺寸 46 | } 47 | 48 | override fun drawContentText(content: String, canvas: Canvas, rect: RectF, isActive: Boolean) { 49 | if (isActive) { 50 | super.drawContentText(content, canvas, rect, isActive) 51 | } else { 52 | paint.color = Color.WHITE 53 | canvas.drawText(content, 0, 1, rect.centerX(), rect.centerY() + textBaseY, paint) 54 | 55 | } 56 | 57 | } 58 | 59 | 60 | override fun getItemWidth(item: TagLineViewData): Float { 61 | return if (item == activeItem && item.itemType == TagType.ITEM_TYPE_TEXT) { 62 | val width = textPaint.measureText(ellipsizeText(item.content)) 63 | width + activeTextPadding * 2 64 | } else { 65 | normalWidth 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/MaxHeightRecyclerView.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | 6 | /** 7 | * 限制最大高度的rv 8 | * @author SamWang(33691286@qq.com) 9 | * @date 2019-08-07 10 | */ 11 | class MaxHeightRecyclerView @JvmOverloads constructor( 12 | context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 13 | ) : androidx.recyclerview.widget.RecyclerView(context, attrs, defStyleAttr) { 14 | 15 | var maxHeight = 0 16 | override fun onMeasure(widthSpec: Int, heightSpec: Int) { 17 | val newHeightSpec = if (maxHeight > 0) { 18 | MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST) 19 | } else { 20 | heightSpec 21 | } 22 | super.onMeasure(widthSpec, newHeightSpec) 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/RoundImageView.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget; 2 | 3 | import android.content.Context; 4 | import android.content.res.TypedArray; 5 | import android.graphics.Bitmap; 6 | import android.graphics.Canvas; 7 | import android.graphics.Color; 8 | import android.graphics.Paint; 9 | import android.graphics.PorterDuff; 10 | import android.graphics.PorterDuffXfermode; 11 | import android.graphics.RectF; 12 | import android.util.AttributeSet; 13 | 14 | import androidx.appcompat.widget.AppCompatImageView; 15 | 16 | import com.sam.video.timeline.R; 17 | 18 | /** 19 | * 通过 PorterDuffXfermode 得到圆角ImageView 20 | * @author SamWang(33691286@qq.com) 21 | * @date 2019/5/7 22 | */ 23 | public class RoundImageView extends AppCompatImageView { 24 | 25 | private int radius = 0; 26 | private RectF rect; 27 | private Paint paint; 28 | private Bitmap mRectMask; 29 | private PorterDuffXfermode xfermode; 30 | 31 | public RoundImageView(Context context) { 32 | super(context); 33 | init(); 34 | } 35 | 36 | public RoundImageView(Context context, AttributeSet attrs) { 37 | super(context, attrs); 38 | init(); 39 | if (attrs != null) { 40 | final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.RoundImageView); 41 | radius = typedArray.getDimensionPixelOffset(R.styleable.RoundImageView_iv_radius, 0); 42 | typedArray.recycle(); 43 | } 44 | } 45 | 46 | private void init() { 47 | paint = new Paint(Paint.ANTI_ALIAS_FLAG); 48 | paint.setStyle(Paint.Style.FILL); 49 | paint.setColor(Color.WHITE); 50 | xfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_IN); 51 | } 52 | 53 | private void createMask() { 54 | int maskWidth = getMeasuredWidth(); 55 | int maskHeight = getMeasuredHeight(); 56 | if (maskWidth == 0 || maskHeight == 0) { 57 | return; 58 | } 59 | mRectMask = Bitmap.createBitmap(maskWidth, maskHeight, Bitmap.Config.ALPHA_8); 60 | Canvas canvas = new Canvas(mRectMask); 61 | canvas.drawRoundRect(rect, radius, radius, paint); 62 | } 63 | 64 | 65 | public void setRadius(int radius) { 66 | this.radius = radius; 67 | createMask(); 68 | invalidate(); 69 | } 70 | 71 | @Override 72 | protected void onSizeChanged(int w, int h, int oldw, int oldh) { 73 | super.onSizeChanged(w, h, oldw, oldh); 74 | rect = new RectF(0, 0, w, h); 75 | createMask(); 76 | } 77 | 78 | @Override 79 | public void draw(Canvas canvas) { 80 | int id = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG); 81 | super.draw(canvas); 82 | 83 | if (mRectMask != null && !mRectMask.isRecycled()) { 84 | paint.setXfermode(xfermode); 85 | canvas.drawBitmap(mRectMask, 0, 0, paint); 86 | paint.setXfermode(null); 87 | } 88 | canvas.restoreToCount(id); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/RoundRectMask.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget; 2 | 3 | import android.content.Context; 4 | import android.graphics.Canvas; 5 | import android.graphics.Color; 6 | import android.graphics.Paint; 7 | import android.graphics.Path; 8 | import android.graphics.RectF; 9 | import android.util.AttributeSet; 10 | import android.view.View; 11 | 12 | import androidx.annotation.ColorInt; 13 | import androidx.annotation.Nullable; 14 | 15 | /** 16 | * 圆角遮罩 17 | * 18 | * - 可设置四个圆角 19 | * 20 | * @author SamWang(33691286@qq.com) 21 | * @date 2019/3/12 22 | */ 23 | public class RoundRectMask extends View { 24 | 25 | private float density; 26 | 27 | public RoundRectMask(Context context) { 28 | super(context); 29 | init(); 30 | } 31 | 32 | public RoundRectMask(Context context, @Nullable AttributeSet attrs) { 33 | super(context, attrs); 34 | init(); 35 | } 36 | 37 | 38 | public RoundRectMask(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 39 | super(context, attrs, defStyleAttr); 40 | init(); 41 | } 42 | 43 | @ColorInt 44 | private int maskColor = Color.BLACK; 45 | 46 | /** 47 | * 最终绘制的路径 48 | */ 49 | private Path path; 50 | /** 51 | * 上下左右 是否有圆角 52 | */ 53 | private boolean topLeftCorner; 54 | private boolean topRightCorner; 55 | private boolean bottomRightCorner; 56 | private boolean bottomLeftCorner; 57 | private float r; //圆角半径 58 | private float d; //圆角直径 59 | 60 | protected RectF rect = new RectF(); 61 | private Paint paint; 62 | 63 | 64 | public void setCorners(boolean topLeft, boolean topRight, boolean bottomLeft, boolean bottomRight) { 65 | topLeftCorner = topLeft; 66 | topRightCorner = topRight; 67 | bottomLeftCorner = bottomLeft; 68 | bottomRightCorner = bottomRight; 69 | resetRoundRect(); 70 | invalidate(); 71 | } 72 | 73 | @Override 74 | protected void onSizeChanged(int w, int h, int oldw, int oldh) { 75 | super.onSizeChanged(w, h, oldw, oldh); 76 | resetRoundRect(); 77 | } 78 | 79 | public void setCornerRadiusDp(float radiusDp) { 80 | this.r = radiusDp * density; 81 | this.d = this.r * 2; 82 | } 83 | 84 | protected void init() { 85 | density = getResources().getDisplayMetrics().density; 86 | path = new Path(); 87 | 88 | r = 2 * density; 89 | d = 4 * density; 90 | 91 | paint = new Paint(Paint.ANTI_ALIAS_FLAG); 92 | paint.setColor(maskColor); 93 | paint.setStyle(Paint.Style.FILL); 94 | } 95 | 96 | protected void resetRoundRect() { 97 | if (!hasMask()) { 98 | return; 99 | } 100 | 101 | rect.left = 0; 102 | rect.right = getWidth(); 103 | rect.top = 0; 104 | rect.bottom = getHeight(); 105 | 106 | path.reset(); 107 | 108 | if (topLeftCorner) { 109 | path.moveTo(rect.left, rect.top + r); 110 | path.arcTo(new RectF(rect.left ,rect.top,rect.left + d,rect.top + d) ,180,90); 111 | } else { 112 | path.moveTo(rect.left, rect.top); 113 | } 114 | 115 | if (topRightCorner) { 116 | path.lineTo(rect.right - r, rect.top); 117 | path.arcTo(new RectF(rect.right - d ,rect.top,rect.right,rect.top + d) ,270,90); 118 | } else { 119 | path.lineTo(rect.right, rect.top); 120 | } 121 | 122 | if (bottomRightCorner) { 123 | path.lineTo(rect.right, rect.bottom-r); 124 | path.arcTo(new RectF(rect.right - d ,rect.bottom-d,rect.right,rect.bottom ) ,0,90); 125 | 126 | } else { 127 | path.lineTo(rect.right, rect.bottom); 128 | } 129 | 130 | if (bottomLeftCorner) { 131 | path.lineTo(rect.left + r, rect.bottom); 132 | path.arcTo(new RectF(rect.left, rect.bottom - d, rect.left + d, rect.bottom), 90, 90); 133 | 134 | } else { 135 | path.lineTo(rect.left, rect.bottom); 136 | } 137 | 138 | path.close(); 139 | 140 | Path rectPath = new Path(); 141 | rectPath.lineTo(getWidth(), 0); 142 | rectPath.lineTo(getWidth(), getHeight()); 143 | rectPath.lineTo(0, getHeight()); 144 | rectPath.close(); 145 | path.op(rectPath, Path.Op.REVERSE_DIFFERENCE); 146 | } 147 | 148 | private boolean hasMask() { 149 | return r > 0 && (topLeftCorner || topRightCorner || bottomLeftCorner || bottomRightCorner); 150 | } 151 | 152 | @Override 153 | protected void onDraw(Canvas canvas) { 154 | super.onDraw(canvas); 155 | if (!hasMask()) { 156 | return; 157 | } 158 | canvas.drawPath(path, paint); 159 | 160 | 161 | } 162 | } 163 | 164 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/RoundTextView.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget; 2 | 3 | import android.content.Context; 4 | import android.content.res.TypedArray; 5 | import android.graphics.Canvas; 6 | import android.graphics.Path; 7 | import android.graphics.Rect; 8 | import android.graphics.RectF; 9 | import android.util.AttributeSet; 10 | 11 | import androidx.appcompat.widget.AppCompatTextView; 12 | 13 | import com.sam.video.timeline.R; 14 | 15 | 16 | /** 17 | * 只需要设置背景色就能得到圆角的背景 TextView 18 | * 19 | * @author SamWang(33691286@qq.com) 20 | * @date 2019/5/7 21 | */ 22 | public class RoundTextView extends AppCompatTextView { 23 | 24 | private int radius = 0; 25 | private RectF rect; 26 | private Path path; 27 | 28 | public RoundTextView(Context context) { 29 | this(context, null); 30 | init(); 31 | } 32 | 33 | public RoundTextView(Context context, AttributeSet attrs) { 34 | this(context, attrs, -1); 35 | 36 | } 37 | 38 | public RoundTextView(Context context, AttributeSet attrs, int defStyleAttr) { 39 | super(context, attrs, defStyleAttr); 40 | if (attrs != null) { 41 | final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.RoundTextView); 42 | radius = typedArray.getDimensionPixelOffset(R.styleable.RoundTextView_tv_radius, 0); 43 | typedArray.recycle(); 44 | } 45 | init(); 46 | } 47 | 48 | private void init() { 49 | rect = new RectF(); 50 | path = new Path(); 51 | } 52 | 53 | public void setRadius(int radius) { 54 | this.radius = radius; 55 | updatePath(); 56 | invalidate(); 57 | } 58 | 59 | @Override 60 | protected void onSizeChanged(int w, int h, int oldw, int oldh) { 61 | super.onSizeChanged(w, h, oldw, oldh); 62 | rect.set(0, 0, w, h); 63 | updatePath(); 64 | } 65 | 66 | private void updatePath() { 67 | if (path == null) { 68 | init(); 69 | } 70 | path.reset(); 71 | path.addRoundRect(rect, radius, radius, Path.Direction.CW); 72 | } 73 | 74 | private Rect canvasRect = new Rect(); 75 | @Override 76 | public void draw(Canvas canvas) { 77 | if (radius > 0) { 78 | canvas.getClipBounds(canvasRect); 79 | rect.set(canvasRect); 80 | path.reset(); 81 | path.addRoundRect(rect, radius, radius, Path.Direction.CW); 82 | canvas.clipPath(path); 83 | } 84 | super.draw(canvas); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/RulerView.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.graphics.LinearGradient 6 | import android.graphics.Paint 7 | import android.util.AttributeSet 8 | import android.util.TypedValue 9 | import android.view.View 10 | import androidx.core.content.ContextCompat 11 | import com.sam.video.timeline.R 12 | import com.sam.video.util.dp2px 13 | import kotlin.math.ceil 14 | import kotlin.math.roundToInt 15 | import kotlin.math.roundToLong 16 | 17 | 18 | /** 19 | * 时间刻度尺 20 | * 最小高度20dp 21 | * 22 | * @author SamWang(33691286@qq.com) 23 | * @date 2019-08-16 24 | */ 25 | class RulerView @JvmOverloads constructor( 26 | paramContext: Context, paramAttributeSet: AttributeSet? = null, 27 | paramInt: Int = 0 28 | ) : View(paramContext, paramAttributeSet, paramInt), 29 | TimeLineBaseValue.TimeLineBaseView { 30 | override fun updateTime() { 31 | invalidate() 32 | } 33 | 34 | private val dp1: Float by lazy(LazyThreadSafetyMode.NONE) { 35 | TypedValue.applyDimension( 36 | 1, 37 | 1.0f, 38 | resources.displayMetrics 39 | ) 40 | } 41 | private var linearGradient: LinearGradient? = null 42 | private val textPaint: Paint = Paint() 43 | 44 | /** 每小刻度的 单位像素长度 */ 45 | private var rulerPxUnit = 1.0f 46 | /** 尺子的每个小刻度 单位时间长度 */ 47 | private var rulerTimeUnit: Long = 1 48 | /** 大刻度的默认间隔 */ 49 | private var standPxPs: Float = context.dp2px(64f) 50 | // /** 刻度辅助线的高度 */ 51 | // private var marginHeight: Float = 0.toFloat() 52 | 53 | /** 基础数据 */ 54 | override var timeLineValue: TimeLineBaseValue? = null 55 | set(value) { 56 | field = value 57 | invalidate() 58 | } 59 | 60 | private var numberStr = StringBuilder() 61 | private val white30Color: Int = ContextCompat.getColor(context, 62 | R.color.white30 63 | ) 64 | private val white50Color: Int = ContextCompat.getColor(context, 65 | R.color.white50 66 | ) 67 | 68 | init { 69 | textPaint.color = white30Color 70 | textPaint.strokeWidth = this.dp1 71 | textPaint.textSize = context.dp2px(8f) 72 | textPaint.isAntiAlias = true 73 | textPaint.textAlign = Paint.Align.CENTER 74 | } 75 | 76 | override fun onDraw(paramCanvas: Canvas) { 77 | super.onDraw(paramCanvas) 78 | val timeLineValue = this.timeLineValue ?: return 79 | 80 | if (timeLineValue.pxInSecond <= 0.0f) { 81 | return 82 | } 83 | 84 | var startX = measuredWidth / 2.0f //默认从中间开始 85 | 86 | //现在要画的刻度 单位毫秒 87 | var currentRuleFlag = (startX / timeLineValue.pxInSecond * 1000f).toLong() //从0开始中间位置对应的刻度时间 88 | 89 | currentRuleFlag = if (timeLineValue.time <= currentRuleFlag) { 90 | 0L //时间在默认位置的左边,从0开始画 91 | } else { 92 | //时间在默认位置的右边,则起始的位置为右边的偏移量,起始位置调整为整数 93 | (ceil(((timeLineValue.time - currentRuleFlag) / this.rulerTimeUnit).toDouble()) * this.rulerTimeUnit).toLong() 94 | } 95 | 96 | startX -= (timeLineValue.time - currentRuleFlag).toFloat() * timeLineValue.pxInSecond / 1000f 97 | 98 | var minute: Int 99 | while (startX < measuredWidth //画完一屏 100 | // && (timeLineValue.duration <= 0L || currentRuleFlag < timeLineValue.duration) //画完和时间轴一样的长度 101 | ) { 102 | 103 | if (currentRuleFlag / this.rulerTimeUnit % 5L == 0L) { 104 | textPaint.color = white50Color 105 | //5格一个大刻度,带数字的线 106 | paramCanvas.drawLine( 107 | startX, 108 | 0f, 109 | startX, 110 | this.dp1 *5.0f, 111 | textPaint 112 | ) 113 | 114 | val second = currentRuleFlag.toFloat() / 1000.0f % 60.0f 115 | val ms = (1000.0f * second).roundToInt() // 116 | if (ms % 1000 == 0) { 117 | //整秒 118 | numberStr.clear() 119 | numberStr.append(ms / 1000) 120 | numberStr.append("s") 121 | } else { 122 | //x.x s 123 | numberStr.clear() 124 | numberStr.append((second * 100f).roundToInt() / 100.0f) 125 | if (numberStr.indexOf(".") > 0) { 126 | while ((numberStr.indexOf(".") > 0) 127 | && (numberStr.endsWith('0') || numberStr.endsWith('.')) 128 | ) { 129 | numberStr.deleteCharAt(numberStr.lastIndex) 130 | } 131 | } 132 | numberStr.append("s") 133 | } 134 | 135 | minute = (currentRuleFlag / 60_000L).toInt() 136 | if (minute > 0) { //满分钟 137 | numberStr.insert(0, "${minute}m ") 138 | } 139 | paramCanvas.drawText( 140 | numberStr.toString(), 141 | startX, 142 | this.dp1 * 16.0f, 143 | textPaint 144 | ) 145 | 146 | // paramCanvas.drawLine(startX, dp1 * 20, startX, dp1 * 20 + this.marginHeight, textPaint) 147 | 148 | } else { 149 | textPaint.color = white30Color 150 | paramCanvas.drawLine( 151 | startX, 152 | 0f, 153 | startX, 154 | this.dp1 * 3, 155 | textPaint 156 | ) 157 | } 158 | 159 | currentRuleFlag += this.rulerTimeUnit 160 | startX += this.rulerPxUnit 161 | } 162 | 163 | } 164 | 165 | fun setMarginHeight(paramInt: Int) { 166 | // marginHeight = paramInt.toFloat() 167 | // linearGradient = LinearGradient( 168 | // 0.0f, 169 | // marginHeight / 2.0f, 170 | // 0.0f, 171 | // 0.0f, 172 | // colorGradient, 173 | // 0, 174 | // Shader.TileMode.MIRROR 175 | // ) 176 | // paintE.shader = linearGradient 177 | } 178 | 179 | /** 更新刻度单位 自动限制在标准 */ 180 | override fun scaleChange() { 181 | val timeLineBaseValue = timeLineValue ?: return 182 | 183 | var pxInSecondScaled = timeLineBaseValue.pxInSecond 184 | 185 | //限制尺子每格的范围在标准的1-2倍之间 186 | if (pxInSecondScaled < standPxPs) { 187 | while (true) { 188 | if (pxInSecondScaled >= standPxPs) { 189 | break 190 | } 191 | pxInSecondScaled *= 2.0f 192 | } 193 | } 194 | if (pxInSecondScaled >= standPxPs * 2.0f) { 195 | while (true) { 196 | if (pxInSecondScaled < standPxPs * 2.0f) { 197 | break 198 | } 199 | pxInSecondScaled /= 2.0f 200 | } 201 | } 202 | 203 | //经过处理后的每大格一定是1s * 2^n (n可能为负),这样刻度就可以用比较整的数 204 | this.rulerPxUnit = pxInSecondScaled / 5.0f 205 | this.rulerTimeUnit = (this.rulerPxUnit * 1000f / timeLineBaseValue.pxInSecond).roundToLong() 206 | invalidate() 207 | } 208 | 209 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/SelectAreaView.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.graphics.Canvas 6 | import android.graphics.Color 7 | import android.graphics.Paint 8 | import android.util.AttributeSet 9 | import android.view.GestureDetector 10 | import android.view.MotionEvent 11 | import android.view.View 12 | import androidx.annotation.IntDef 13 | import androidx.core.content.ContextCompat 14 | import com.sam.video.timeline.R 15 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_END 16 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_START 17 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_WHOLE 18 | import com.sam.video.util.SelectAreaEventHandle 19 | import com.sam.video.util.color 20 | import com.sam.video.util.dp2px 21 | import kotlin.math.abs 22 | import kotlin.math.ceil 23 | 24 | 25 | /** 26 | * 区域选择view 27 | * 场景: 28 | * 1. 视频编辑,选择视频的范围 29 | * 2. 文字贴纸选择生效的时间范围: 文字贴纸的移动都直接作用于时间范围 30 | * 31 | * 32 | * 使用: 33 | * 切图资源高度为72dp,view高度也最好设置为72 34 | * 35 | * 在边缘停留会自动滚: 36 | * 37 | * @author SamWang(33691286@qq.com) 38 | * @date 2019-07-31 39 | */ 40 | class SelectAreaView @JvmOverloads constructor( 41 | context: Context, paramAttributeSet: AttributeSet? = null, 42 | paramInt: Int = 0 43 | ) : View(context, paramAttributeSet, paramInt), 44 | TimeLineBaseValue.TimeLineBaseView { 45 | 46 | /** 选择的时间范围 */ 47 | var startTime = 0L 48 | var endTime = 0L 49 | 50 | /** 考虑视频间的间隔偏移 */ 51 | var offsetStart = 0 52 | var offsetEnd = 0 53 | 54 | private val cursorWidth = context.dp2px(14f) 55 | private val selectAreaHeight = context.dp2px(48f) 56 | 57 | private val textMarginRight = context.dp2px(2f) //选区时间的右间距 58 | 59 | private val paintBg = Paint(Paint.ANTI_ALIAS_FLAG) 60 | private val selectBgColor = context.color(R.color.video_blue_50) //选择区域色 61 | private val unSelectBgColor = ContextCompat.getColor(context, R.color.black30) //未选择的区域色 62 | private val unSelectBgPaddingTop = context.dp2px(6f) 63 | 64 | //size: 18*48dp 65 | private val leftDrawable = context.resources.getDrawable(R.drawable.video_select_left) 66 | private val rightDrawable = context.resources.getDrawable(R.drawable.video_select_right) 67 | 68 | val eventHandle = SelectAreaEventHandle(context) 69 | var onChangeListener: OnChangeListener? = null 70 | set(value) { 71 | field = value 72 | eventHandle.onChangeListener = value 73 | } 74 | 75 | private val textY2Bottom: Float by lazy { // 文字Y坐标到底部的距离 ,字体的基线 76 | context.dp2px(20f) - abs(paintBg.ascent() + paintBg.descent()) / 2 77 | } 78 | 79 | init { 80 | paintBg.style = Paint.Style.FILL 81 | paintBg.textSize = context.dp2px(10f) 82 | paintBg.textAlign = Paint.Align.RIGHT 83 | } 84 | 85 | /** 基础数据 */ 86 | override var timeLineValue: TimeLineBaseValue? = null 87 | set(value) { 88 | field = value 89 | eventHandle.timeLineValue = value 90 | invalidate() 91 | } 92 | 93 | /** 选择区域 是否在屏内,可以操作 */ 94 | fun isInScreen():Boolean { 95 | return (startTimeX >= 0 && startTimeX <= width) || (endTimeX >= 0 && endTimeX <= width) 96 | } 97 | 98 | /** 99 | * 是否在时间范围 100 | */ 101 | fun timeInArea(): Boolean { 102 | val timeLineValue = timeLineValue ?: return false 103 | return timeLineValue.time in startTime..endTime 104 | } 105 | 106 | private var startTimeX = 0f 107 | private var endTimeX = 0f 108 | 109 | private fun getTime(): String { 110 | return String.format("%.1fs", (endTime - startTime) / 1000f) 111 | } 112 | 113 | /** 114 | * 全部移动模式 115 | */ 116 | var wholeMoveMode = false 117 | 118 | override fun onDraw(canvas: Canvas) { 119 | super.onDraw(canvas) 120 | val timeLineValue = timeLineValue ?: return 121 | canvas.save() 122 | canvas.clipRect(paddingLeft, 0, width, height) 123 | val timeX = width / 2 //当前时间,中间那个标记线的位置 124 | 125 | val timeInPx = timeLineValue.time2px(timeLineValue.time) 126 | val alpha = if (wholeMoveMode) { 127 | 128 128 | } else { 129 | 255 130 | } 131 | leftDrawable.alpha = alpha 132 | rightDrawable.alpha = alpha 133 | 134 | //左 135 | val startTimeInPx = timeLineValue.time2px(startTime) 136 | startTimeX = ceil(timeX + offsetStart + startTimeInPx - timeInPx) //上取整损失一些精度,避免开始的游标和中间线对不上 137 | paintBg.color = unSelectBgColor 138 | canvas.drawRect(0f, unSelectBgPaddingTop, startTimeX, height.toFloat(), paintBg) 139 | 140 | val endTimeInPx = timeLineValue.time2px(endTime) 141 | endTimeX = timeX - offsetEnd + endTimeInPx - timeInPx 142 | 143 | //中 144 | paintBg.color = selectBgColor 145 | canvas.drawRect(startTimeX, (height - selectAreaHeight) / 2 , endTimeX, (height + selectAreaHeight) / 2 , paintBg) 146 | 147 | leftDrawable.setBounds((startTimeX - cursorWidth).toInt(), 0, startTimeX.toInt(), height) 148 | leftDrawable.draw(canvas) 149 | 150 | //时间 151 | paintBg.color = Color.WHITE 152 | val time = getTime() 153 | val textWidth = paintBg.measureText(time) 154 | if (textMarginRight + textWidth < endTimeX - startTimeX) { 155 | canvas.drawText(time,endTimeX - textMarginRight, height - textY2Bottom, paintBg) 156 | } 157 | 158 | // 右 159 | paintBg.color = unSelectBgColor 160 | canvas.drawRect(endTimeX, unSelectBgPaddingTop, width.toFloat(), height.toFloat(), paintBg) 161 | rightDrawable.setBounds(endTimeX.toInt(), 0, (endTimeX + cursorWidth).toInt(), height) 162 | rightDrawable.draw(canvas) 163 | 164 | canvas.restore() 165 | 166 | } 167 | 168 | 169 | private val gestureDetector: GestureDetector by lazy(LazyThreadSafetyMode.NONE) { 170 | GestureDetector(context, gestureListener).also { 171 | it.setIsLongpressEnabled(false) //自己识别长按 172 | } 173 | } 174 | private fun isEventIn(event: MotionEvent, @OnChangeListener.TouchMode mode: Int): Boolean { 175 | return when (mode) { 176 | MODE_START -> event.x in startTimeX - cursorWidth..startTimeX 177 | MODE_END -> event.x in endTimeX..endTimeX + cursorWidth 178 | MODE_WHOLE -> event.x in startTimeX - cursorWidth..endTimeX + cursorWidth 179 | else -> false 180 | } 181 | } 182 | 183 | /** 184 | * 点击监听 185 | */ 186 | private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { 187 | 188 | override fun onDown(event: MotionEvent): Boolean { 189 | eventHandle.touchStartCursor = isEventIn(event, MODE_START) 190 | eventHandle.touchEndCursor = 191 | (!eventHandle.touchStartCursor) && isEventIn(event, MODE_END) 192 | eventHandle.removeLongPressEvent() 193 | 194 | val consume = if (eventHandle.startEndBothMoveEnable) { 195 | (event.x > startTimeX && event.x < endTimeX).also { 196 | if (it) { 197 | eventHandle.sendLongPressAtTime(event.downTime) 198 | } 199 | } 200 | } else { 201 | false 202 | } || eventHandle.touchStartCursor || eventHandle.touchEndCursor 203 | 204 | onChangeListener?.apply { 205 | if (eventHandle.touchStartCursor) { 206 | mode = MODE_START 207 | } else if (eventHandle.touchEndCursor) { 208 | mode = MODE_END 209 | } 210 | } 211 | 212 | if (consume) { 213 | eventHandle.onChangeListener?.onTouchDown() 214 | } 215 | return consume 216 | } 217 | 218 | override fun onScroll( 219 | e1: MotionEvent, 220 | e2: MotionEvent, 221 | distanceX: Float, 222 | distanceY: Float 223 | ): Boolean { 224 | 225 | // 事件向上递传 226 | val eventDispatchUp = if (eventHandle.consumeEvent) { 227 | //较正结束点和对应点的真正偏移 228 | onChangeListener?.mode?.let { mode -> 229 | if (!isEventIn(e2, mode) // 手指如果没在光标范围内 ,(e1是 down,没必要判断) 230 | && isCloseToCursor(e2.x, distanceX, mode)// 则远离小耳朵一定要生效,逼近的可以吃掉 231 | ) { 232 | eventHandle.removeLongPressEvent() 233 | eventHandle.autoHorizontalScrollAnimator?.cancel() 234 | return true 235 | } 236 | } 237 | 238 | !eventHandle.onScroll(e1, e2, distanceX, distanceY) 239 | } else { 240 | true 241 | } 242 | 243 | //滚动事件传递给上层 244 | if (eventDispatchUp) { 245 | eventHandle.removeLongPressEvent() 246 | // 需求事件不传递 247 | if (!eventHandle.consumeEvent) { 248 | (parent as? ZoomFrameLayout)?.gestureListener?.onScroll( 249 | e1, 250 | e2, 251 | distanceX, 252 | distanceY 253 | ) 254 | } 255 | } 256 | 257 | return true 258 | } 259 | 260 | override fun onFling( 261 | e1: MotionEvent?, 262 | e2: MotionEvent?, 263 | velocityX: Float, 264 | velocityY: Float 265 | ): Boolean { 266 | eventHandle.removeLongPressEvent() 267 | if (!eventHandle.consumeEvent) { 268 | (parent as? ZoomFrameLayout)?.gestureListener?.onFling(e1, e2, velocityX, velocityY) 269 | } 270 | return super.onFling(e1, e2, velocityX, velocityY) 271 | 272 | } 273 | } 274 | /** 275 | * 光标是否更接近了 276 | */ 277 | private fun isCloseToCursor( 278 | x: Float, 279 | distanceX: Float, // lastX - x 280 | @OnChangeListener.TouchMode mode: Int 281 | ): Boolean { 282 | val cursorX = when (mode) { 283 | MODE_START -> startTimeX 284 | MODE_END -> endTimeX 285 | else -> (startTimeX + endTimeX) / 2 286 | } 287 | 288 | return abs(x - cursorX) < abs(x + distanceX - cursorX) 289 | } 290 | 291 | @SuppressLint("ClickableViewAccessibility") 292 | override fun onTouchEvent(event: MotionEvent): Boolean { 293 | val result = gestureDetector.onTouchEvent(event) 294 | eventHandle.onTouchEvent(event) 295 | return result 296 | 297 | } 298 | 299 | override fun onDetachedFromWindow() { 300 | super.onDetachedFromWindow() 301 | eventHandle.autoHorizontalScrollAnimator?.cancel() 302 | } 303 | 304 | override fun scaleChange() { 305 | eventHandle.timeJumpOffset = timeLineValue?.px2time(eventHandle.timeJumpOffsetPx) ?: 0 306 | invalidate() 307 | } 308 | override fun updateTime() { 309 | invalidate() 310 | } 311 | 312 | /** 313 | * 选择区域变化监听 314 | */ 315 | interface OnChangeListener { 316 | 317 | @TouchMode var mode: Int 318 | 319 | /** 320 | * 按下选择区域 321 | */ 322 | fun onTouchDown() 323 | 324 | /** 325 | * 开始时间变动的毫秒,结束时间变动的毫秒 326 | * @param startOffset 起始位置移动 327 | * @param endOffset 结束位置移动 328 | * @param fromUser true 用户滑动,false 动画自动滑动 329 | * 330 | * @return 返回这个事件是否生效 331 | */ 332 | fun onChange(startOffset: Long, endOffset: Long, fromUser: Boolean): Boolean 333 | 334 | 335 | /** 336 | * 抬起选择区域 337 | */ 338 | fun onTouchUp() 339 | 340 | /** 341 | * 整个区域移动 342 | */ 343 | fun onMove(offset: Long, fromUser: Boolean): Boolean { 344 | return false 345 | } 346 | 347 | companion object { 348 | const val MODE_NONE = -1 349 | const val MODE_START = 1 350 | const val MODE_END = 2 351 | const val MODE_WHOLE = 3 352 | } 353 | 354 | @IntDef(MODE_NONE, MODE_START, MODE_END, MODE_WHOLE) 355 | @kotlin.annotation.Retention(AnnotationRetention.SOURCE) 356 | annotation class TouchMode 357 | 358 | } 359 | 360 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/TagItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.graphics.* 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.sam.video.util.dp2px 7 | 8 | 9 | /** 10 | * 标签弹窗的item间隔 11 | * 12 | * 间隔的数据 为间隔前的item position 13 | * @author SamWang(33691286@qq.com) 14 | * @date 2019-07-22 15 | */ 16 | class TagItemDecoration(context: Context) : RecyclerView.ItemDecoration() { 17 | private val colorBg = Color.parseColor("#202020") 18 | 19 | private val triangleWidth = context.dp2px(7f) //小三角规格 20 | val triangleHeight = context.dp2px(9f) 21 | private val cornerRadius = context.dp2px(2f) 22 | 23 | var triangleXOffset = 0 24 | private var trianglePath = Path() 25 | private var coverPath = Path() 26 | 27 | val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 28 | color = Color.WHITE 29 | style = Paint.Style.FILL 30 | } 31 | 32 | private val rectF = RectF() 33 | override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { 34 | super.onDraw(c, parent, state) 35 | paint.color = colorBg 36 | rectF.set(0f, 0f, parent.width.toFloat(), parent.height - triangleHeight) 37 | val startX = parent.width / 2 - triangleWidth + triangleXOffset 38 | 39 | trianglePath.reset() 40 | trianglePath.moveTo(startX, parent.height - triangleHeight) 41 | trianglePath.rLineTo(triangleWidth, triangleHeight) 42 | trianglePath.rLineTo(triangleWidth, -triangleHeight) 43 | trianglePath.close() 44 | 45 | c.drawRoundRect(rectF, cornerRadius, cornerRadius, paint) 46 | c.drawPath(trianglePath, paint) 47 | } 48 | 49 | override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { 50 | super.onDrawOver(c, parent, state) 51 | coverPath.reset() 52 | coverPath.addRect(0f, parent.height - triangleHeight, parent.width.toFloat(), parent.height.toFloat(), Path.Direction.CCW) 53 | coverPath.op(trianglePath, Path.Op.DIFFERENCE) 54 | paint.color = Color.BLACK 55 | c.drawPath(coverPath, paint) 56 | } 57 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/TagPopWindow.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.graphics.Color 5 | import android.graphics.drawable.ColorDrawable 6 | import android.view.Gravity 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.widget.FrameLayout 10 | import android.widget.PopupWindow 11 | import com.google.android.flexbox.FlexDirection 12 | import com.google.android.flexbox.FlexWrap 13 | import com.google.android.flexbox.FlexboxLayoutManager 14 | import com.sam.video.timeline.adapter.TagAdapter 15 | import com.sam.video.timeline.bean.TagLineViewData 16 | import com.sam.video.timeline.listener.Click 17 | import com.sam.video.util.dp2px 18 | import com.sam.video.util.getScreenWidth 19 | import com.sam.video.util.removeObj 20 | 21 | /** 22 | * 重叠的标签组 选择弹窗 23 | * 更新的时候水平位置要有偏移,小三角的位置更新 24 | * 25 | * @author SamWang(33691286@qq.com) 26 | * @date 2019-08-08 27 | */ 28 | class TagPopWindow(val context: Context) : PopupWindow(context) { 29 | private val rv = MaxHeightRecyclerView(context) 30 | private val padding = context.dp2px(8f).toInt() 31 | private val data = mutableListOf() 32 | private val adapter = TagAdapter(context, data) 33 | private val tagItemDecoration = TagItemDecoration(context) 34 | private val screenWidth = context.getScreenWidth() 35 | 36 | init { 37 | setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) 38 | rv.maxHeight = context.dp2px(184f).toInt() 39 | rv.addItemDecoration(tagItemDecoration) 40 | 41 | //无法设置左右padding 会影响 FlexboxLayoutManager 排列,该库在layout时未考虑padding,真正绘制时又会考虑, 42 | //导致行数不统一异常留白、滑动等问题,UI要是有需求可以考虑在上一层 FrameLayout 上做样式 43 | rv.setPadding(0, padding, 0, 44 | (padding + tagItemDecoration.triangleHeight).toInt() 45 | ) 46 | rv.clipToPadding = false 47 | rv.layoutManager = FlexboxLayoutManager(context).apply { 48 | flexWrap = FlexWrap.WRAP 49 | flexDirection = FlexDirection.ROW 50 | } 51 | rv.adapter = adapter 52 | adapter.onClickListener = View.OnClickListener { 53 | val position = rv.getChildAdapterPosition(it) 54 | if (position in 0 until data.size) { 55 | onItemClickListener?.onItemClick(it, data[position], position) 56 | } 57 | 58 | } 59 | 60 | val fl = FrameLayout(context) //低版本兼容。。 61 | fl.addView(rv) 62 | fl.setOnClickListener { dismiss() } 63 | contentView = fl 64 | 65 | width = ViewGroup.LayoutParams.WRAP_CONTENT 66 | height = ViewGroup.LayoutParams.WRAP_CONTENT 67 | isOutsideTouchable = true 68 | 69 | 70 | } 71 | 72 | fun updateData(tagList: List, selectItem: TagLineViewData?) { 73 | data.clear() 74 | data.addAll(tagList) 75 | selectItem?.let { 76 | if (data.isNotEmpty() && it != data[0]) { 77 | //选中置顶 78 | data.removeObj(it) 79 | data.add(0, it) 80 | } 81 | } 82 | adapter.selectedItem = selectItem 83 | adapter.notifyDataSetChanged() 84 | } 85 | 86 | var onItemClickListener: Click.OnItemViewClickListener? = null 87 | 88 | /** 89 | * UI设定,比较复杂,指定三角形尖角的位置,整体popupwindow居中,或居中时部分超出屏幕,则靠近屏幕某边 90 | * @param x 三角形尖角的位置 91 | * @param y 底部Y的绝对坐标 92 | * 93 | * 关键:x 要换算成 popwindow位移 + popwindow内三角的偏移 94 | */ 95 | fun showAtTriangleX(parent: View, x: Int, y: Int) { 96 | showAtLocation(parent, Gravity.BOTTOM or Gravity.START, 0, y) 97 | rv.post { //为了准确获取w,h 98 | val halfWidth = rv.width / 2 99 | 100 | val newX = if (x >= halfWidth && x + halfWidth <= screenWidth) { 101 | tagItemDecoration.triangleXOffset = 0 102 | // 尖角居中的情况 103 | x - halfWidth 104 | } else if (x < halfWidth) { 105 | // 屏幕左边, 三角形往左偏 106 | tagItemDecoration.triangleXOffset = x - halfWidth 107 | 0 108 | } else { 109 | //屏幕右边 110 | tagItemDecoration.triangleXOffset = x + halfWidth - screenWidth 111 | screenWidth - rv.width 112 | } 113 | update(newX, y, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, true) 114 | } 115 | 116 | 117 | } 118 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/TimeChangeListener.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | interface TimeChangeListener { 4 | /** 因滑动 更新时间 */ 5 | fun updateTimeByScroll(time: Long) 6 | 7 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/TimeLineBaseValue.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import com.sam.video.App 4 | import com.sam.video.util.dp2px 5 | import com.sam.video.util.getScreenWidth 6 | 7 | /** 8 | * 时间轴共同数据 9 | */ 10 | class TimeLineBaseValue { 11 | /** 总时间长度 - 毫秒*/ 12 | public var duration: Long = 0L 13 | 14 | /** 时间 - 毫秒 */ 15 | var time: Long = 0L 16 | set(value) { 17 | field = when { 18 | value < 0 -> 0 19 | value > duration -> duration 20 | else -> value 21 | } 22 | // Log.d("TimeLineBaseValue", "update Time : $time") 23 | } 24 | 25 | /** 26 | * seek的时间 与时间轴时间不一样 27 | * 这个值有效时,用于视频预览seek到指定位置,而[time]用于时间轴定位,实现时间轴与视频预览差异seek 28 | * */ 29 | var seekTime = -1L 30 | 31 | /** 时间轴比例 缩放倍数 */ 32 | var scale: Float = 1.0F 33 | set(value) { 34 | field = when { 35 | value < minScale -> minScale 36 | value > maxScale -> maxScale 37 | else -> value 38 | } 39 | pxInSecond = standPxInSecond * field 40 | // Log.d("TimeLineBaseValue", "wds scaleChange() called $field") 41 | } 42 | 43 | /** 标准一秒几像素 */ 44 | var standPxInSecond: Float = 1.0f 45 | set(value) { 46 | field = value 47 | pxInSecond = field * scale 48 | } 49 | 50 | /** 每秒长度对应几像素,会用于分母要保证不会0 */ 51 | var pxInSecond: Float = 1.0f // pxInSecond = standPxInSecond * scale 52 | private set //始终通过内部运算得到 53 | 54 | /** 时间转成像素 */ 55 | fun time2px(timeMs: Long): Float { 56 | return timeMs * pxInSecond / 1000 57 | } 58 | 59 | /** 像素转成时间*/ 60 | fun px2time(px: Float): Long { 61 | return (px * 1000 / pxInSecond).toLong() 62 | } 63 | 64 | // 65 | /** 时间 -> X坐标 66 | * @param time 目标时间 67 | * @param cursorX 竖线(光标)的位置,一般在屏幕中央 68 | * @param currentTime 光标指向的时间 69 | * */ 70 | fun time2X(time: Long, cursorX: Int, currentTime: Long = this.time): Float { 71 | val offsetTime = time - currentTime 72 | return cursorX + time2px(offsetTime) 73 | } 74 | 75 | /** 76 | * 重置标准选区 77 | * @param holdPxInSecond 尽量保持pxInSecond不变 78 | */ 79 | fun resetStandPxInSecond(holdPxInSecond: Boolean = false) { 80 | 81 | //默认 65dp = 1s 82 | standPxInSecond = App.instance.dp2px(65f) 83 | //单段视频是一帧宽48dp 多段视频 84 | val frameTime = frameWidth * 1000 / standPxInSecond 85 | 86 | //fixme 产品或UI要给出更合理的缩放。。。 87 | maxScale = frameTime / minFrameTime //最大精度就是 0.25s 1帧 88 | minScale = frameWidth * 1000f / duration / standPxInSecond //最小精度为总长度到一帧 89 | 90 | scale = if (holdPxInSecond) { 91 | pxInSecond / standPxInSecond 92 | } else { 93 | 1.0f 94 | } 95 | } 96 | 97 | private val frameWidth = App.instance.dp2px(48f) 98 | private val screenWidth = App.instance.getScreenWidth() 99 | 100 | /** 101 | * 将scale整理为适合屏幕大小的值,解决片断增删 102 | * 调整区域后整个时间轴显示区域过小的问题 103 | * 104 | * - 尽量保持原有的 pxInSecond 比例不变 105 | * - 106 | */ 107 | fun fitScaleForScreen() { 108 | // val currentDurationPx = duration * standPxInSecond / 1000 109 | // if (currentDurationPx < App.instance.dp2px(48f) || currentDurationPx > screenWidth) { 110 | resetStandPxInSecond(true) 111 | // } 112 | } 113 | 114 | var maxScale = 1.0f 115 | var minScale = 0.5f//最小是默认的一半,占半屏 116 | 117 | 118 | //最小一帧对应的时间,用于直接分割帧,避免缩放的时候产生无数的图片 119 | var minFrameTime = 250L // frameTime / maxScale 120 | //最小时间切片的时间 121 | val minClipTime = MIN_CLIP_TIME 122 | val minStandPxInSecond = 1000 * App.instance.dp2px(8f) / minClipTime //最小切片占-帧边框的长度,标准是两倍,此时缩放到最小刚好满足 123 | 124 | 125 | /** 126 | * 含时间线数据 功能标识接口 127 | */ 128 | interface TimeLineBaseView { 129 | var timeLineValue: TimeLineBaseValue? 130 | fun scaleChange() 131 | fun updateTime() 132 | } 133 | 134 | 135 | companion object { 136 | /** 137 | * 视频片段最小时间 138 | */ 139 | const val MIN_CLIP_TIME = 100L 140 | } 141 | 142 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/TransImageView.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget; 2 | 3 | 4 | import android.annotation.SuppressLint; 5 | import android.content.Context; 6 | import android.graphics.drawable.Drawable; 7 | import android.util.AttributeSet; 8 | import android.widget.ImageView; 9 | 10 | import androidx.annotation.Nullable; 11 | 12 | @SuppressLint("AppCompatCustomView") 13 | public class TransImageView extends ImageView { 14 | public TransImageView(Context context) { 15 | this(context,null); 16 | } 17 | 18 | public TransImageView(Context context, @Nullable AttributeSet attrs) { 19 | this(context, attrs,0); 20 | } 21 | 22 | public TransImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 23 | super(context, attrs, defStyleAttr); 24 | } 25 | 26 | 27 | @Override 28 | public void setImageDrawable(@Nullable Drawable drawable) { 29 | if(drawable!=null){ 30 | super.setImageDrawable(drawable); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/VideoFrameItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.graphics.* 5 | import android.view.View 6 | import androidx.core.content.ContextCompat 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.sam.video.timeline.adapter.VideoFrameAdapter 9 | import com.sam.video.timeline.R 10 | import com.sam.video.util.dp2px 11 | 12 | 13 | /** 14 | * 多个视频 帧列表的视频间隔 15 | * 16 | * @author SamWang(33691286@qq.com) 17 | * @date 2019-07-22 18 | */ 19 | class VideoFrameItemDecoration(context: Context) : RecyclerView.ItemDecoration() { 20 | private val itemDecorationSpace = context.dp2px(2f).toInt() 21 | /**圆角半径 */ 22 | private val rect = RectF() 23 | private val strokeWidth = context.dp2px(2f) 24 | private val halfStrokeWidth = strokeWidth / 2 25 | private val radius = context.dp2px(4f) - strokeWidth 26 | 27 | private val colorStart = ContextCompat.getColor(context, R.color.colorSelectedBlur) 28 | private val colorMiddle = ContextCompat.getColor(context, R.color.colorSelectedBlurLight) 29 | private val colorEnd = ContextCompat.getColor(context, R.color.colorSelectedPink) 30 | 31 | private val shaderColors = intArrayOf(colorStart, colorMiddle, colorEnd) 32 | private val shaderPositions = floatArrayOf(0f, 0.39f, 1.0f) 33 | 34 | var hasBorder = true //是否有渐变描边 35 | 36 | private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 37 | color = Color.WHITE 38 | style = Paint.Style.STROKE 39 | strokeWidth = this@VideoFrameItemDecoration.strokeWidth 40 | } 41 | 42 | override fun getItemOffsets( 43 | outRect: Rect, 44 | view: View, 45 | parent: RecyclerView, 46 | state: RecyclerView.State 47 | ) { 48 | super.getItemOffsets(outRect, view, parent, state) 49 | val position = parent.getChildAdapterPosition(view) 50 | val adapter = parent.adapter 51 | var hasDecoration = false 52 | if (adapter is VideoFrameAdapter) { 53 | //不是全列表的最后一项 && 是这个视频的最后一项 54 | hasDecoration = position < adapter.data.size - 1 && adapter.data[position].isLastItem 55 | } 56 | if (hasDecoration) { 57 | outRect.set(0, 0, itemDecorationSpace, 0) 58 | } else { 59 | outRect.set(0, 0, 0, 0) 60 | } 61 | 62 | } 63 | 64 | override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { 65 | super.onDrawOver(canvas, parent, state) 66 | if (!hasBorder) { 67 | return 68 | } 69 | val adapter = parent.adapter 70 | if (adapter == null || adapter.itemCount == 0) { 71 | return 72 | } 73 | (parent as? VideoFrameRecyclerView)?.getCurrentCursorVideoRect(rect) 74 | // Log.d("SAM", "onDrawOver " +rect.toShortString()) 75 | if (rect.width() == 0f) { 76 | return 77 | } 78 | rect.left += halfStrokeWidth 79 | rect.right -= halfStrokeWidth 80 | 81 | rect.top += halfStrokeWidth 82 | rect.bottom -= halfStrokeWidth 83 | 84 | paint.shader = LinearGradient(rect.left, rect.top, rect.right, rect.bottom, 85 | shaderColors, shaderPositions, Shader.TileMode.CLAMP) 86 | canvas.drawRoundRect(rect, radius, radius, paint) 87 | 88 | } 89 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/VideoFrameRecyclerView.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.graphics.RectF 6 | import android.util.AttributeSet 7 | import android.util.Log 8 | import android.view.MotionEvent 9 | import android.view.ScaleGestureDetector 10 | import android.view.View 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import androidx.recyclerview.widget.RecyclerView 13 | import com.sam.video.timeline.adapter.VideoFrameAdapter 14 | import com.sam.video.timeline.bean.VideoClip 15 | import com.sam.video.timeline.bean.VideoFrameData 16 | import com.sam.video.timeline.listener.OnFrameClickListener 17 | import com.sam.video.util.dp2px 18 | import kotlin.math.min 19 | 20 | /** 21 | * 帧列表 22 | * 单视频:没有间隔,直接按视频长度换算 23 | * 多视频,首尾两个 + 一半间隔,中间的 + 完整间隔 24 | * 25 | * @author SamWang(33691286@qq.com) 26 | * @date 2019-07-29 27 | */ 28 | class VideoFrameRecyclerView @JvmOverloads constructor( 29 | context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 30 | ) : RecyclerView(context, attrs, defStyleAttr), TimeLineBaseValue.TimeLineBaseView { 31 | /** 视频数据 */ 32 | var videoData: List? = null 33 | /** 帧数据 */ 34 | val listData = mutableListOf() 35 | private val frameWidth by lazy(LazyThreadSafetyMode.NONE) { context.dp2px(48f).toInt() } 36 | private val decorationWidth by lazy(LazyThreadSafetyMode.NONE) { context.dp2px(2f).toInt() } 37 | val halfDurationSpace = decorationWidth / 2 38 | private val videoFrameItemDecoration: VideoFrameItemDecoration 39 | 40 | init { 41 | adapter = VideoFrameAdapter(listData, frameWidth) 42 | videoFrameItemDecoration = VideoFrameItemDecoration(context) 43 | addItemDecoration(videoFrameItemDecoration) 44 | 45 | addOnScrollListener(object : OnScrollListener() { 46 | override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { 47 | super.onScrolled(recyclerView, dx, dy) 48 | if (scrollState == SCROLL_STATE_IDLE) { 49 | //过滤updateTime 导致的滚动 50 | return 51 | } 52 | 53 | (parent as? ZoomFrameLayout)?.let { zoomLayout -> 54 | if (zoomLayout.isScrollIng()) { 55 | zoomLayout.flingAnimation.cancel() 56 | } 57 | 58 | getCurrentCursorTime()?.let { 59 | zoomLayout.updateTimeByScroll(it) 60 | } 61 | } 62 | 63 | 64 | } 65 | 66 | override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { 67 | super.onScrollStateChanged(recyclerView, newState) 68 | val timeLineValue = timeLineValue ?: return 69 | val timeChangeListener = (parent as? ZoomFrameLayout)?.timeChangeListener ?: return 70 | 71 | when (newState) { 72 | SCROLL_STATE_DRAGGING -> timeChangeListener.startTrackingTouch() 73 | SCROLL_STATE_IDLE -> { 74 | timeChangeListener.stopTrackingTouch(timeLineValue.time) 75 | if (needUpdateTimeWhenScrollEnd) { 76 | updateTime() 77 | } 78 | } 79 | } 80 | } 81 | }) 82 | } 83 | 84 | var hasBorder: Boolean 85 | set(value) { 86 | videoFrameItemDecoration.hasBorder = value 87 | invalidate() 88 | } 89 | get() = videoFrameItemDecoration.hasBorder 90 | 91 | override var timeLineValue: TimeLineBaseValue? = null 92 | 93 | override fun scaleChange() { 94 | Log.d("Sam", "wds : scaleChange") 95 | rebindFrameInfo() 96 | } 97 | 98 | /** 99 | * 重新绑定视频的帧信息 100 | * 间隔:前后间隔都当作自己一帧 101 | * */ 102 | fun rebindFrameInfo() { 103 | listData.clear() 104 | val timeLineValue = timeLineValue ?: return 105 | val videoData = videoData ?: return 106 | // videoHelper?.buildSuccessWidthCurveSpeed = false 107 | if (videoData.isEmpty()) { 108 | adapter?.notifyDataSetChanged() 109 | return 110 | } 111 | //一帧多少ms 112 | val frameTime = (frameWidth * 1000 / timeLineValue.pxInSecond).toLong() 113 | //间隔对应多少ms 114 | val decorationTime = (decorationWidth * 1000 / timeLineValue.pxInSecond).toLong() 115 | 116 | var fitOffsetTime = 0f //修正多个视频的精度偏移值 117 | 118 | for ((index, item) in videoData.withIndex()) { 119 | 120 | var time = 0L //该视频片断的,时间轴时间,从0开始; 121 | var clipTime = 0L 122 | var duration = item.durationMs //视频的持续时间 123 | val speed = 1 124 | val clipFrameTime = (frameTime * speed).toLong() //一帧对应的文件时间 125 | //曲线变速生效 126 | val curveSpeedEffect = false 127 | if (index < videoData.size - 1) { 128 | //分后一半间隔,把间隔当作这一帧的一部分 129 | duration -= decorationTime / 2 130 | } 131 | 132 | if (index > 0) { 133 | //分前一半间隔,把间隔自己当作一帧 134 | time += decorationTime / 2 135 | } 136 | 137 | var element: VideoFrameData? = null 138 | val durationPx = timeLineValue.time2px(duration).toInt() 139 | 140 | fitOffsetTime += timeLineValue.time2px(time) //将上一个视频的精度遗留到下一个视频中 141 | var widthCount = fitOffsetTime.toInt() //距离之和 142 | fitOffsetTime -= widthCount 143 | 144 | // Log.d("Sam", "----- item.startAtMs: ${item.startAtMs} $frameTime") 145 | while (widthCount < durationPx) { 146 | val isFirst = element == null 147 | var itemWidth = if (widthCount + frameWidth <= durationPx) { 148 | frameWidth 149 | } else { 150 | durationPx - widthCount //最后一个应该把误差都算进去: 视频的总长度,减去已经分割的长度 151 | } 152 | 153 | var left = 0 154 | if (isFirst) { //第一帧可能不是完整帧宽度 155 | left = timeLineValue.time2px(((item.startAtMs % clipFrameTime) / speed).toLong()).toInt() 156 | itemWidth = min( 157 | itemWidth, 158 | frameWidth - left 159 | ) 160 | // Log.d("wds", "wds ------------------ left:$left leftTime: ${timeLineValue.px2time(left.toFloat())}") 161 | //第一帧固定,防止剪辑时闪 162 | clipTime = item.startAtMs - (item.startAtMs % clipFrameTime) 163 | } else { 164 | if (clipTime + clipFrameTime <= item.endAtMs) { 165 | clipTime += clipFrameTime 166 | } else { 167 | clipTime = item.endAtMs 168 | } 169 | 170 | } 171 | 172 | element = VideoFrameData(item, time, clipTime, itemWidth, isFirst, false, left) 173 | listData.add(element) 174 | // Log.d("Sam", "-----: $time, $clipTime") 175 | 176 | time += if (isFirst) { 177 | frameTime - timeLineValue.px2time(left.toFloat()) 178 | } else { 179 | frameTime 180 | } 181 | widthCount += itemWidth 182 | } 183 | element?.isLastItem = true 184 | } 185 | adapter?.notifyDataSetChanged() 186 | updateTime() 187 | } 188 | 189 | private var needUpdateTimeWhenScrollEnd = false //标记一些更新时间时正在滚动,等滚动完成后重新校正 190 | override fun updateTime() { 191 | if (listData.isEmpty()) { 192 | return 193 | } 194 | 195 | if (scrollState == SCROLL_STATE_IDLE) { 196 | val timeLineValue = timeLineValue ?: return 197 | var position = listData.size - 1 //精度问题会导致遍历完了还没找到合适的Item,此时进度条一定是在最后一个item的最右边 198 | var offsetX = listData[listData.size - 1].frameWidth 199 | 200 | var offsetTime = 0L //偏移时间 201 | 202 | var lastVideo: VideoClip? = null //上一个视频 203 | var lastFrame: VideoFrameData? = null //上一帧 204 | 205 | for ((index, item) in listData.withIndex()) { 206 | if (lastVideo === item.videoData) { //这个视频还没超过 207 | continue 208 | } else { 209 | if (offsetTime + item.videoData.durationMs < timeLineValue.time) { 210 | //加上当前这个完整视频还不够,则可以跳过这个视频的所有帧 211 | 212 | offsetTime += item.videoData.durationMs 213 | lastVideo = item.videoData 214 | } else { 215 | //定位到该视频 216 | 217 | if (item.isLastItem || offsetTime + item.time >= timeLineValue.time) { 218 | //已经是这个视频的最后一帧的项了,或开始时间已经超过光标了 219 | 220 | //光标在这个视频的的相对时间 221 | offsetTime = timeLineValue.time - offsetTime 222 | offsetTime = if (lastFrame == null) { 223 | position = index 224 | offsetTime - item.time 225 | } else { 226 | offsetTime - lastFrame.time 227 | } 228 | 229 | offsetX = timeLineValue.time2px(offsetTime).toInt() 230 | 231 | break 232 | } else { 233 | position = index 234 | lastFrame = item 235 | continue 236 | } 237 | } 238 | } 239 | } 240 | //offset 负就向左, 241 | (layoutManager as LinearLayoutManager).scrollToPositionWithOffset(position, -offsetX) 242 | needUpdateTimeWhenScrollEnd = false 243 | } else { 244 | needUpdateTimeWhenScrollEnd = true 245 | } 246 | } 247 | 248 | var frameClickListener: OnFrameClickListener? = null 249 | 250 | override fun addOnItemTouchListener(listener: OnItemTouchListener) { 251 | super.addOnItemTouchListener(listener) 252 | (listener as? OnFrameClickListener)?.let { 253 | frameClickListener = it 254 | } 255 | } 256 | 257 | var scaleGestureDetector: ScaleGestureDetector? = null 258 | 259 | private fun disableLongPress() { 260 | frameClickListener?.gestureDetector?.setIsLongpressEnabled(false) 261 | } 262 | 263 | @SuppressLint("ClickableViewAccessibility") 264 | override fun onTouchEvent(e: MotionEvent): Boolean { 265 | // Log.d("Sam", " $e ") 266 | if (e.pointerCount > 1) { 267 | // todo 找更合适的地方禁用长按 268 | disableLongPress() 269 | } 270 | 271 | if (scrollState != SCROLL_STATE_IDLE) { 272 | disableLongPress() 273 | return super.onTouchEvent(e) 274 | } 275 | 276 | if (scaleGestureDetector == null) { 277 | (parent as? ZoomFrameLayout)?.scaleGestureListener?.let { 278 | scaleGestureDetector = 279 | ScaleGestureDetector(this@VideoFrameRecyclerView.context, it) 280 | } 281 | } 282 | 283 | scaleGestureDetector?.let { 284 | if (scrollState == SCROLL_STATE_IDLE && e.pointerCount > 1) { 285 | val scaleEvent = it.onTouchEvent(e) 286 | if (it.isInProgress) { 287 | return@onTouchEvent scaleEvent 288 | } 289 | } 290 | } 291 | 292 | if (e.pointerCount > 1) { 293 | return true 294 | } 295 | 296 | return super.onTouchEvent(e) 297 | 298 | } 299 | 300 | /** 通过X坐标找到对应的视频 */ 301 | fun findVideoByX(x: Float): VideoClip? { 302 | val child = findChildViewByX(x) ?: return null 303 | val position = getChildAdapterPosition(child) 304 | return listData.getOrNull(position)?.videoData 305 | } 306 | 307 | /** 308 | * 通过x坐标找到对应的view 309 | */ 310 | private fun findChildViewByX(x: Float): View? { 311 | val findChildViewUnder = findChildViewUnder(x, height / 2f) 312 | findChildViewUnder?.let { 313 | return it 314 | } 315 | 316 | for (i in 0 until childCount) { 317 | val child = getChildAt(i) 318 | val position = getChildAdapterPosition(child) 319 | if (position !in 0 until listData.size) { 320 | continue 321 | } 322 | val item = listData[position] 323 | val left = if (item.isFirstItem && position > 0) { 324 | child.left - halfDurationSpace 325 | } else { 326 | child.left 327 | } 328 | 329 | val right = if (item.isLastItem && position < listData.size - 1) { 330 | child.right + halfDurationSpace 331 | } else { 332 | child.right 333 | } 334 | 335 | if (left <= x && x <= right) { 336 | return child 337 | } 338 | } 339 | 340 | return null 341 | } 342 | 343 | /** 344 | * 当前游标指定的View 345 | * 用时间去找,比用坐标找更精确!可以精确到1ms,坐标只能精确到1px 346 | */ 347 | private fun getCurrentCursorView(): View? { 348 | return findChildViewByX(paddingLeft.toFloat()) 349 | } 350 | 351 | private val cursorX 352 | get() = paddingLeft 353 | 354 | private fun isAtEnd(): Boolean { 355 | return if (listData.isNotEmpty()) { 356 | val lastVH = findViewHolderForAdapterPosition(listData.size - 1) ?: return false 357 | lastVH.itemView.right <= cursorX 358 | } else { 359 | false 360 | } 361 | } 362 | 363 | /** 364 | * 当前游标指定的时间 365 | */ 366 | private fun getCurrentCursorTime(): Long? { 367 | val child = getCurrentCursorView() ?: return null 368 | val videoData = videoData ?: return null 369 | val timeLineValue = timeLineValue ?: return null 370 | layoutManager?.canScrollHorizontally() 371 | if (isAtEnd()) { 372 | return timeLineValue.duration 373 | } 374 | 375 | val position = getChildAdapterPosition(child) 376 | if (position in 0 until listData.size) { 377 | val item = listData[position] 378 | val indexVideo = videoData.indexOfFirst { it === item.videoData } 379 | 380 | var time = 0L 381 | for (i in 0 until indexVideo) { 382 | time += videoData[i].durationMs 383 | } 384 | 385 | time += item.time 386 | var itemWidth = item.frameWidth 387 | 388 | if (indexVideo > 0 && item.isFirstItem) { 389 | itemWidth += halfDurationSpace 390 | } 391 | if (indexVideo < videoData.size - 1 && item.isLastItem) { 392 | itemWidth -= halfDurationSpace 393 | } 394 | 395 | val offsetX = paddingLeft - child.left 396 | val offsetTime = timeLineValue.px2time(offsetX.toFloat()) 397 | time += offsetTime 398 | return time 399 | } 400 | return null 401 | 402 | } 403 | 404 | /** 405 | * 获取当前光标的指定的视频的范围 406 | * 当前item, 407 | * */ 408 | fun getCurrentCursorVideoRect(rect: RectF) { 409 | 410 | val child = getCurrentCursorView() ?: return 411 | val videoData = videoData ?: return 412 | val timeLineValue = timeLineValue ?: return 413 | 414 | val position = getChildAdapterPosition(child) 415 | if (position in 0 until listData.size) { 416 | val item = listData[position] 417 | val indexVideo = videoData.indexOfFirst { it === item.videoData } 418 | 419 | var offset = 0f //手动计算偏移值,防止 timeLineValue.time2px(item.time) 有误差 420 | for (i in position - 1 downTo 0) { 421 | val itemCountWidth = listData[i] 422 | if (itemCountWidth.videoData !== item.videoData) { 423 | break 424 | } 425 | offset += itemCountWidth.frameWidth 426 | } 427 | 428 | rect.top = child.top.toFloat() 429 | rect.bottom = child.bottom.toFloat() 430 | 431 | rect.left = child.left - offset //第一帧的左边 432 | 433 | rect.right = rect.left + timeLineValue.time2px(item.videoData.durationMs) 434 | 435 | if (indexVideo > 0) { 436 | rect.right -= halfDurationSpace 437 | } 438 | if (indexVideo < videoData.size - 1) { 439 | rect.right -= halfDurationSpace 440 | } 441 | 442 | } 443 | } 444 | 445 | 446 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/WideTextTagLineView.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.graphics.Color 6 | import android.graphics.Paint 7 | import android.graphics.RectF 8 | import android.text.TextPaint 9 | import android.text.TextUtils 10 | import android.util.AttributeSet 11 | import com.sam.video.timeline.bean.TagLineViewData 12 | import com.sam.video.timeline.bean.TagType 13 | import com.sam.video.util.dp 14 | import com.sam.video.util.dp2px 15 | 16 | 17 | /** 18 | * 文字限定一定的宽度;图片不变 标签-线 view 19 | * 20 | * 用于:内容都是文本,限制一定的宽度 21 | * 22 | * @author SamWang(33691286@qq.com) 23 | * @date 2019-07-31 24 | */ 25 | open class WideTextTagLineView @JvmOverloads constructor( 26 | context: Context, paramAttributeSet: AttributeSet? = null, 27 | paramInt: Int = 0 28 | ) : TagLineView(context, paramAttributeSet, paramInt), 29 | TimeLineBaseValue.TimeLineBaseView { 30 | 31 | protected val textPaint = TextPaint().apply { 32 | color = Color.WHITE 33 | textAlign = Paint.Align.LEFT 34 | textSize = 14f.dp 35 | } 36 | 37 | /** 38 | * 将传入的文本转换为要显示的文本 39 | */ 40 | private val ellipsizeStringMap = HashMap() 41 | /** 42 | * 后缀显示 43 | */ 44 | private val maxTextWidth by lazy { initMaxTextWidth() } //文本限制的最大宽度(3个汉字) 45 | protected val normalTextPadding = context.dp2px(6f) //文本左右Padding 46 | protected val activeTextPadding = context.dp2px(8f) 47 | 48 | protected open fun initMaxTextWidth(): Float { 49 | return context.dp2px(42f) 50 | } 51 | /** 52 | * 缩字 53 | */ 54 | protected fun ellipsizeText(origin: String): String { 55 | ellipsizeStringMap[origin]?.let { 56 | return it 57 | } 58 | 59 | return TextUtils.ellipsize(origin, textPaint, maxTextWidth, TextUtils.TruncateAt.END) 60 | .toString().also { 61 | ellipsizeStringMap[origin] = it 62 | } 63 | } 64 | 65 | override fun drawItem(item: TagLineViewData, canvas: Canvas, zIndex: Int, isActive: Boolean) { 66 | updatePathBeforeDraw(item, canvas, zIndex, isActive) 67 | super.drawItem(item, canvas, zIndex, isActive) 68 | } 69 | 70 | /** 71 | * 根据item 更新path 72 | */ 73 | open fun updatePathBeforeDraw(item: TagLineViewData, canvas: Canvas, zIndex: Int, isActive: Boolean) { 74 | if (item.itemType == TagType.ITEM_TYPE_TEXT) { 75 | val text = ellipsizeText(item.content) 76 | val width = textPaint.measureText(text) 77 | //UPDATE PATH 78 | if (isActive && zIndex == 0) { 79 | updateActivePath(width + activeTextPadding * 2) 80 | } else { 81 | updateNormalPath(width + normalTextPadding * 2) 82 | } 83 | } else { 84 | if (isActive && zIndex == 0) { 85 | updateActivePath() 86 | } else { 87 | updateNormalPath() 88 | } 89 | } 90 | } 91 | 92 | override fun drawContent( 93 | item: TagLineViewData, 94 | canvas: Canvas, 95 | zIndex: Int, 96 | isActive: Boolean 97 | ) { 98 | val rect = if (isActive && zIndex == 0) activeBitmapRectF else bitmapRectF 99 | if (item.itemType == TagType.ITEM_TYPE_IMG) { 100 | loadImg(item.content)?.let { 101 | drawBitmapKeepRatio(canvas, it, rect) 102 | } 103 | } else if (item.itemType == TagType.ITEM_TYPE_TEXT) { 104 | drawContentText(item.content,canvas, rect, isActive && zIndex == 0) 105 | } 106 | } 107 | 108 | 109 | /** 110 | * 绘制文本 111 | */ 112 | open fun drawContentText( 113 | content: String, 114 | canvas: Canvas, 115 | rect: RectF, 116 | isActive: Boolean 117 | ) { 118 | val text = ellipsizeText(content) 119 | val paddingLeft = if (isActive) activeTextPadding else normalTextPadding 120 | canvas.drawText(text, paddingLeft, rect.centerY() + textBaseY, textPaint) 121 | } 122 | 123 | override fun getItemWidth(item: TagLineViewData): Float { 124 | val width = textPaint.measureText(ellipsizeText(item.content)) 125 | 126 | return if (item.itemType == TagType.ITEM_TYPE_TEXT) { 127 | if (item == activeItem) { 128 | width + activeTextPadding * 2 129 | } else { 130 | width + normalTextPadding * 2 131 | } 132 | } else { 133 | super.getItemWidth(item) 134 | } 135 | 136 | } 137 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/timeline/widget/ZoomFrameLayout.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.timeline.widget 2 | 3 | import android.animation.ValueAnimator 4 | import android.content.Context 5 | import android.util.AttributeSet 6 | import android.view.GestureDetector 7 | import android.view.MotionEvent 8 | import android.view.ScaleGestureDetector 9 | import android.view.View 10 | import android.view.animation.AccelerateDecelerateInterpolator 11 | import android.widget.FrameLayout 12 | import androidx.dynamicanimation.animation.DynamicAnimation 13 | import androidx.dynamicanimation.animation.FlingAnimation 14 | import androidx.dynamicanimation.animation.FloatValueHolder 15 | import com.sam.video.timeline.R.id.rvFrame 16 | import com.sam.video.timeline.listener.VideoPlayerOperate 17 | 18 | /** 19 | * 拦截最外层,统一调度缩放事件、滑动事件 20 | * @author SamWang(33691286@qq.com) 21 | * @date 2019-07-19 22 | */ 23 | class ZoomFrameLayout : FrameLayout, 24 | DynamicAnimation.OnAnimationUpdateListener, 25 | ValueAnimator.AnimatorUpdateListener { 26 | 27 | 28 | constructor(context: Context) : super(context) 29 | constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) 30 | constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( 31 | context, 32 | attrs, 33 | defStyleAttr 34 | ) 35 | 36 | /** 滑动数据更新*/ 37 | override fun onAnimationUpdate( 38 | animation: DynamicAnimation>?, 39 | vlaue: Float, 40 | velocity: Float 41 | ) { 42 | timeLineValue.time = timeLineValue.px2time(vlaue) 43 | dispatchUpdateTime() 44 | timeChangeListener?.updateTimeByScroll(timeLineValue.time) 45 | } 46 | 47 | 48 | /** 双击动画更新 */ 49 | override fun onAnimationUpdate(animation: ValueAnimator) { 50 | scaleChange(animation.animatedValue as Float) 51 | } 52 | 53 | var timeLineValue = TimeLineBaseValue() 54 | /** 保存滑动事件*/ 55 | private var timeValueHolder = FloatValueHolder() 56 | val flingAnimation = FlingAnimation(timeValueHolder).apply { 57 | addUpdateListener(this@ZoomFrameLayout) 58 | addEndListener { _, _, _, _ -> timeChangeListener?.stopTrackingTouch(timeLineValue.time) } 59 | } 60 | 61 | /** 62 | * 双指缩放是否可用,默认true 63 | */ 64 | var doubleFingerEnable = true 65 | /** 66 | * 双击缩放是否可用,默认true 67 | */ 68 | var doubleTapEnable = true 69 | 70 | var scaleEnable = true 71 | 72 | /** 额外的事件监听 */ 73 | var timeChangeListener: VideoPlayerOperate? = null 74 | 75 | 76 | private fun scaleChange(scale: Float) { 77 | flingAnimation.cancel() 78 | timeLineValue.scale = scale 79 | dispatchScaleChange() 80 | } 81 | 82 | fun scroll(x: Float, y: Float) { 83 | val offsetTime = (x * 1000 / timeLineValue.pxInSecond).toLong() 84 | if (offsetTime != 0L) { 85 | flingAnimation.cancel() 86 | // Log.d("Sam", "scroll $x $offsetTime ${timeLineValue.time}") 87 | timeLineValue.time += offsetTime 88 | timeValueHolder.value = timeLineValue.time.toFloat() * timeLineValue.pxInSecond / 1000 89 | updateTimeByScroll(timeLineValue.time) 90 | } 91 | } 92 | 93 | /** 94 | * 动画改变缩放倍数 95 | */ 96 | private fun scaleChangeWithAnimation(target: Float) { 97 | ValueAnimator.ofFloat(timeLineValue.scale, target).apply { 98 | interpolator = AccelerateDecelerateInterpolator() 99 | duration = 500 100 | addUpdateListener(this@ZoomFrameLayout) 101 | start() 102 | } 103 | } 104 | var lastScaleEventTime = 0L 105 | 106 | /** 107 | * 双指缩放监听 - 识别手势并处理 108 | */ 109 | val scaleGestureListener = object : 110 | ScaleGestureDetector.SimpleOnScaleGestureListener() { 111 | 112 | override fun onScaleEnd(detector: ScaleGestureDetector) { 113 | super.onScaleEnd(detector) 114 | lastScaleEventTime = detector.eventTime 115 | // scaleListener?.onScaleEnd(detector) //手指未离开就end了 116 | } 117 | 118 | override fun onScale(detector: ScaleGestureDetector): Boolean { 119 | scaleChange(timeLineValue.scale * detector.scaleFactor * detector.scaleFactor) //加强灵敏度 120 | return true 121 | } 122 | 123 | override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean { 124 | scaleListener?.onScaleBegin(detector) 125 | return super.onScaleBegin(detector) 126 | } 127 | } 128 | 129 | 130 | /** 131 | * 额外缩放监听 132 | * - 用于通知外部view变化 133 | */ 134 | var scaleListener: ScaleListener? = null 135 | 136 | /** 137 | * 滑动、双击监听 138 | */ 139 | val gestureListener = object : GestureDetector.SimpleOnGestureListener() { 140 | override fun onDoubleTap(e: MotionEvent?): Boolean { 141 | if (!scaleEnable || e?.pointerCount ?: 1 > 1) { 142 | return false 143 | } 144 | // Log.d("ZoomFrameLayout", "onDoubleTap() called") 145 | val targetScale = when (timeLineValue.scale) { 146 | timeLineValue.maxScale -> { 147 | timeLineValue.minScale 148 | } 149 | 1f -> { 150 | timeLineValue.maxScale 151 | } 152 | else -> { 153 | 1f 154 | } 155 | } 156 | scaleChangeWithAnimation(targetScale) 157 | return true 158 | } 159 | 160 | override fun onSingleTapUp(e: MotionEvent?): Boolean { 161 | performClick() 162 | return super.onSingleTapUp(e) 163 | } 164 | 165 | override fun onScroll( 166 | e1: MotionEvent?, 167 | e2: MotionEvent?, 168 | distanceX: Float, 169 | distanceY: Float 170 | ): Boolean { 171 | if (hasEventWithScale(e1, e2)) { 172 | return true 173 | } 174 | scroll(distanceX, distanceY) 175 | return true 176 | } 177 | 178 | //按下后有过缩放事件就不处理滑动 179 | private fun hasEventWithScale( 180 | e1: MotionEvent?, 181 | e2: MotionEvent? 182 | ): Boolean { 183 | if (scaleGestureDetector.isInProgress) { 184 | return true 185 | } 186 | if (e1 != null && (lastScaleEventTime > e1.downTime || e1.pointerCount > 1)) { 187 | return true 188 | } 189 | 190 | if (e2 != null && (lastScaleEventTime > e2.downTime || e2.pointerCount > 1)) { 191 | return true 192 | } 193 | return false 194 | } 195 | 196 | override fun onFling( 197 | e1: MotionEvent?, 198 | e2: MotionEvent?, 199 | velocityX: Float, 200 | velocityY: Float 201 | ): Boolean { 202 | if (hasEventWithScale(e1, e2)) { 203 | return true 204 | } 205 | 206 | flingAnimation.apply { 207 | cancel() 208 | val max = timeLineValue.pxInSecond * timeLineValue.duration / 1000 209 | if (max > 0f && timeValueHolder.value in 0f..max) { 210 | setStartVelocity(-velocityX) 211 | setMinValue(0f) 212 | setMaxValue(max) 213 | start() 214 | timeChangeListener?.startTrackingTouch() 215 | } 216 | } 217 | 218 | return true 219 | } 220 | 221 | 222 | } 223 | 224 | fun isScrollIng(): Boolean { 225 | return flingAnimation.isRunning 226 | } 227 | 228 | private val gestureDetector: GestureDetector by lazy(LazyThreadSafetyMode.NONE) { 229 | GestureDetector(context, gestureListener) 230 | } 231 | 232 | 233 | private val scaleGestureDetector: ScaleGestureDetector by lazy(LazyThreadSafetyMode.NONE) { 234 | ScaleGestureDetector(context, scaleGestureListener) 235 | } 236 | 237 | fun updateTime(time: Long) { 238 | // Log.d("Sam" , "ZoomFrameLayout updateTime $time ") 239 | timeLineValue.time = time 240 | dispatchUpdateTime() 241 | } 242 | 243 | fun updateTimeByScroll(time: Long) { 244 | updateTime(time) 245 | timeChangeListener?.updateTimeByScroll(timeLineValue.time) 246 | } 247 | 248 | /** 249 | * 分发时间线数据 250 | */ 251 | fun dispatchTimeLineValue() = dispatchTimeLineEvent(false) { 252 | it.timeLineValue = timeLineValue 253 | } 254 | 255 | /** 256 | * 分发缩放数据 257 | */ 258 | fun dispatchScaleChange() = dispatchTimeLineEvent { 259 | it.scaleChange() 260 | } 261 | 262 | /** 传递时间轴事件 */ 263 | private fun dispatchTimeLineEvent( 264 | filterHidden: Boolean = true, //隐藏view不用分发事件 265 | event: (TimeLineBaseValue.TimeLineBaseView) -> Unit 266 | ) { 267 | for (i in 0..childCount) { 268 | val childAt = getChildAt(i) 269 | if (childAt is TimeLineBaseValue.TimeLineBaseView && (!filterHidden || childAt.visibility == View.VISIBLE)) { 270 | event(childAt) 271 | } 272 | } 273 | 274 | } 275 | 276 | /** 277 | * 分发时间更新 278 | */ 279 | fun dispatchUpdateTime() = dispatchTimeLineEvent { 280 | it.updateTime() 281 | } 282 | 283 | override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { 284 | return ev.pointerCount > 1 || super.onInterceptTouchEvent(ev) 285 | 286 | } 287 | override fun onTouchEvent(event: MotionEvent): Boolean { 288 | // Log.d("Sam", " $event ") 289 | if (!scaleEnable) { 290 | return super.onTouchEvent(event) 291 | } 292 | 293 | if (event.action == MotionEvent.ACTION_DOWN) { 294 | timeChangeListener?.startTrackingTouch() 295 | }else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { 296 | timeChangeListener?.stopTrackingTouch(timeLineValue.time) 297 | scaleListener?.touchEventEnd() 298 | } 299 | 300 | scaleGestureDetector.onTouchEvent(event) 301 | if (!scaleGestureDetector.isInProgress) { 302 | gestureDetector.onTouchEvent(event) 303 | } 304 | 305 | return true 306 | } 307 | 308 | abstract class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() { 309 | var isScaled = false 310 | 311 | override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean { 312 | isScaled = true 313 | return super.onScaleBegin(detector) 314 | } 315 | 316 | fun touchEventEnd() { 317 | if (isScaled) { 318 | onScaleTouchEnd() 319 | } 320 | } 321 | 322 | /** 323 | * 有过缩放后的事件结束 324 | */ 325 | abstract fun onScaleTouchEnd() 326 | 327 | } 328 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/util/AppExecutors.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.util; 2 | 3 | import android.annotation.SuppressLint; 4 | import android.os.Handler; 5 | import android.os.Looper; 6 | 7 | import androidx.annotation.NonNull; 8 | 9 | import java.util.concurrent.Executor; 10 | import java.util.concurrent.ExecutorService; 11 | import java.util.concurrent.Executors; 12 | import java.util.concurrent.ThreadFactory; 13 | import java.util.concurrent.atomic.AtomicInteger; 14 | 15 | /** 16 | * Created by wyh3 on 2018/11/20. 17 | * 全局执行池,可简化&集中线程操作 18 | */ 19 | @SuppressLint("ThreadNameRequired ") 20 | public class AppExecutors { 21 | 22 | private static class AppExecutorsHolder { 23 | private static final AppExecutors instance = new AppExecutors(); 24 | } 25 | 26 | public static AppExecutors get() { 27 | return AppExecutorsHolder.instance; 28 | } 29 | 30 | private final MainThreadExecutor mMainExecutor;//主线程 31 | private final ExecutorService mDiskExecutor;//文件读写 32 | private final ExecutorService mNetworkExecutor;//网络请求 33 | private final ExecutorService mDbExecutor;//数据库读写 34 | private final ExecutorService mWorkExecutor;//其他耗时操作 35 | 36 | private AppExecutors() { 37 | this.mMainExecutor = new MainThreadExecutor(); 38 | this.mDiskExecutor = Executors.newSingleThreadExecutor(new AppExecutorsThreadFactory("mtxx-disk-io")); 39 | this.mNetworkExecutor = Executors.newFixedThreadPool(5, new AppExecutorsThreadFactory("mtxx-network")); 40 | this.mDbExecutor = Executors.newFixedThreadPool(3, new AppExecutorsThreadFactory("mtxx-db")); 41 | this.mWorkExecutor = Executors.newCachedThreadPool(new AppExecutorsThreadFactory("mtxx-bg-work")); 42 | } 43 | 44 | /* ******** 直接执行 *********/ 45 | 46 | /** 47 | * 主线程操作 48 | */ 49 | public static void executeMain(Runnable runnable) { 50 | getMainExecutor().execute(runnable); 51 | } 52 | 53 | /** 54 | * 文件读写操作 55 | */ 56 | public static void executeDisk(Runnable runnable) { 57 | getDiskExecutor().execute(runnable); 58 | } 59 | 60 | /** 61 | * 网络操作 62 | */ 63 | public static void executeNetwork(Runnable runnable) { 64 | getNetworkExecutor().execute(runnable); 65 | } 66 | 67 | /** 68 | * 数据库操作 69 | */ 70 | public static void executeDb(Runnable runnable) { 71 | getDbExecutor().execute(runnable); 72 | } 73 | 74 | /** 75 | * 其他耗时操作 76 | */ 77 | public static void executeWork(Runnable runnable) { 78 | getWorkExecutor().execute(runnable); 79 | } 80 | 81 | 82 | /* ******** 获取线程池 *********/ 83 | 84 | /** 85 | * 文件读写线程池 86 | */ 87 | public static ExecutorService getDiskExecutor() { 88 | return get().mDiskExecutor; 89 | } 90 | 91 | /** 92 | * 网络操作线程池 93 | */ 94 | public static ExecutorService getNetworkExecutor() { 95 | return get().mNetworkExecutor; 96 | } 97 | 98 | /** 99 | * 数据库操作线程池 100 | */ 101 | public static ExecutorService getDbExecutor() { 102 | return get().mDbExecutor; 103 | } 104 | 105 | /** 106 | * 其他耗时操作线程池 107 | */ 108 | public static ExecutorService getWorkExecutor() { 109 | return get().mWorkExecutor; 110 | } 111 | 112 | /** 113 | * 主线程 114 | */ 115 | public static MainThreadExecutor getMainExecutor() { 116 | return get().mMainExecutor; 117 | } 118 | 119 | 120 | //主线程 121 | public static class MainThreadExecutor implements Executor { 122 | private Handler mainThreadHandler = new Handler(Looper.getMainLooper()); 123 | 124 | @Override 125 | public void execute(@NonNull Runnable command) { 126 | mainThreadHandler.post(command); 127 | } 128 | 129 | public Handler getMainThreadHandler() { 130 | return mainThreadHandler; 131 | } 132 | } 133 | 134 | //其他线程工厂 135 | private static class AppExecutorsThreadFactory implements ThreadFactory { 136 | 137 | private AtomicInteger count = new AtomicInteger(1); 138 | private String name; 139 | 140 | AppExecutorsThreadFactory(String name) { 141 | this.name = name; 142 | } 143 | 144 | @Override 145 | public Thread newThread(@NonNull Runnable r) { 146 | return new Thread(r, "AppExecutors-" + name + "-Thread-" + count.getAndIncrement()); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/util/Collections.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.util 2 | 3 | public inline fun MutableList.removeObj(item: T): Collection { 4 | val index = this.indexOfFirst { it === item } 5 | if (index in 0 until size) { 6 | this.removeAt(index) 7 | } 8 | return this 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/util/ContextExt.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("Utils") 2 | @file:JvmMultifileClass 3 | 4 | package com.sam.video.util 5 | 6 | import android.annotation.SuppressLint 7 | import android.content.Context 8 | import android.content.res.Resources 9 | import android.os.Vibrator 10 | import androidx.annotation.ColorRes 11 | import androidx.core.content.ContextCompat 12 | import android.util.DisplayMetrics 13 | import android.util.TypedValue 14 | import android.view.ViewConfiguration 15 | import android.view.WindowManager 16 | import com.sam.video.App 17 | 18 | /** 19 | * dp转px 20 | * 21 | * @param context 22 | * @param dp 23 | * @return 24 | */ 25 | fun Context.dp2px(dp: Float): Float { 26 | return TypedValue.applyDimension( 27 | TypedValue.COMPLEX_UNIT_DIP, 28 | dp, resources.displayMetrics 29 | ) 30 | } 31 | 32 | /** 33 | * sp转px 34 | * 35 | * @param context 36 | * @param sp 37 | * @return 38 | */ 39 | fun Context.sp2px(sp: Float): Float { 40 | return TypedValue.applyDimension( 41 | TypedValue.COMPLEX_UNIT_SP, 42 | sp, resources.displayMetrics 43 | ) 44 | } 45 | 46 | fun Context.getScreenWidth(): Int { 47 | val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager 48 | val outMetrics = DisplayMetrics() 49 | wm.defaultDisplay.getMetrics(outMetrics) 50 | return outMetrics.widthPixels 51 | } 52 | 53 | fun Context.color(@ColorRes res: Int): Int { 54 | return ContextCompat.getColor(this, res) 55 | } 56 | 57 | /** 58 | * 小震一下 59 | */ 60 | fun Context.vibratorOneShot() { 61 | vibrator(10) //为啥是10,我看其它地方也都是10,统一体验哈哈(- -.) 62 | } 63 | 64 | @SuppressLint("MissingPermission") 65 | fun Context.vibrator(milliseconds: Long){ 66 | // VibrationEffect.createOneShot(milliseconds, VibrationEffect.DEFAULT_AMPLITUDE) 67 | (getSystemService(Context.VIBRATOR_SERVICE) as Vibrator).vibrate(milliseconds) //deprecated但是推荐的接口api level 26 68 | } 69 | 70 | 71 | 72 | val Float.dp: Float 73 | get() = TypedValue.applyDimension( 74 | TypedValue.COMPLEX_UNIT_DIP, this, Resources.getSystem().displayMetrics 75 | ) 76 | val Int.dp: Int 77 | get() = TypedValue.applyDimension( 78 | TypedValue.COMPLEX_UNIT_DIP, 79 | this.toFloat(), 80 | Resources.getSystem().displayMetrics 81 | ).toInt() 82 | 83 | @Deprecated("注意:文字大小统一要求使用dp", ReplaceWith("dp")) 84 | val Float.sp: Float 85 | get() = TypedValue.applyDimension( 86 | TypedValue.COMPLEX_UNIT_SP, this, Resources.getSystem().displayMetrics 87 | ) 88 | 89 | 90 | @Deprecated("注意:文字大小统一要求使用dp", ReplaceWith("dp")) 91 | val Int.sp: Int 92 | get() = TypedValue.applyDimension( 93 | TypedValue.COMPLEX_UNIT_SP, 94 | this.toFloat(), 95 | Resources.getSystem().displayMetrics 96 | ).toInt() 97 | 98 | val scaledTouchSlop = ViewConfiguration.get(App.instance).scaledTouchSlop; 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/util/MediaStoreUtil.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.util; 2 | 3 | import android.content.Context; 4 | import android.database.Cursor; 5 | import android.net.Uri; 6 | import android.provider.MediaStore; 7 | 8 | public class MediaStoreUtil { 9 | /** 10 | * 从image uri获取绝对路径 11 | * 12 | * @return 绝对路径 13 | */ 14 | public static String imageUriToRealPath(Context context, Uri uri) { 15 | if (context == null || uri == null) { 16 | return null; 17 | } 18 | Cursor cursor = null; 19 | String[] projection = {MediaStore.Images.Media.DATA}; 20 | try { 21 | cursor = context.getContentResolver().query(uri, projection, null, null, null); 22 | if (cursor != null) { 23 | int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); 24 | cursor.moveToFirst(); 25 | return cursor.getString(columnIndex); 26 | } 27 | } finally { 28 | if (cursor != null) { 29 | cursor.close(); 30 | } 31 | } 32 | return null; 33 | } 34 | 35 | /** 36 | * video uri获取绝对路径 37 | * 38 | * @return 绝对路径 39 | */ 40 | public static String videoUriToRealPath(Context context, Uri uri) { 41 | if (context == null || uri == null) { 42 | return null; 43 | } 44 | Cursor cursor = null; 45 | String[] projection = {MediaStore.Video.Media.DATA}; 46 | try { 47 | cursor = context.getContentResolver().query(uri, projection, null, null, null); 48 | if (cursor != null) { 49 | int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATA); 50 | cursor.moveToFirst(); 51 | return cursor.getString(columnIndex); 52 | } 53 | } finally { 54 | if (cursor != null) { 55 | cursor.close(); 56 | } 57 | } 58 | return null; 59 | } 60 | 61 | /** 62 | * audio uri获取绝对路径 63 | * 64 | * @return 绝对路径 65 | */ 66 | public static String audioUriToRealPath(Context context, Uri uri) { 67 | if (context == null || uri == null) { 68 | return null; 69 | } 70 | Cursor cursor = null; 71 | String[] projection = {MediaStore.Audio.Media.DATA}; 72 | try { 73 | cursor = context.getContentResolver().query(uri, projection, null, null, null); 74 | if (cursor != null) { 75 | int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA); 76 | cursor.moveToFirst(); 77 | return cursor.getString(columnIndex); 78 | } 79 | } finally { 80 | if (cursor != null) { 81 | cursor.close(); 82 | } 83 | } 84 | return null; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/util/ScreenUtil.java: -------------------------------------------------------------------------------- 1 | package com.sam.video.util; 2 | 3 | 4 | import android.content.Context; 5 | import android.graphics.Point; 6 | import android.os.Build; 7 | import android.view.Display; 8 | import android.view.WindowManager; 9 | 10 | import com.sam.video.App; 11 | 12 | 13 | public class ScreenUtil { 14 | 15 | public static final String TAG = ScreenUtil.class.getSimpleName(); 16 | 17 | private int mRealSizeWidth; 18 | private int mRealSizeHeight; 19 | 20 | private ScreenUtil() { 21 | initRealSize(); 22 | } 23 | 24 | private static final class InstanceHolder { 25 | private static final ScreenUtil INSTANCE = new ScreenUtil(); 26 | } 27 | 28 | /** 29 | * 获取实例 30 | * 31 | * @return ScreenUtils 32 | */ 33 | public static ScreenUtil getInstance() { 34 | return InstanceHolder.INSTANCE; 35 | } 36 | 37 | private void initRealSize() { 38 | final WindowManager windowManager = 39 | (WindowManager) App.Companion.getInstance().getSystemService(Context.WINDOW_SERVICE); 40 | final Display display = windowManager.getDefaultDisplay(); 41 | Point outPoint = new Point(); 42 | if (Build.VERSION.SDK_INT >= 19) { 43 | // 包含虚拟按键 44 | display.getRealSize(outPoint); 45 | } else { 46 | // 不含虚拟按键 47 | display.getSize(outPoint); 48 | } 49 | if (outPoint.y > outPoint.x) { 50 | mRealSizeHeight = outPoint.y; 51 | mRealSizeWidth = outPoint.x; 52 | } else { 53 | mRealSizeHeight = outPoint.x; 54 | mRealSizeWidth = outPoint.y; 55 | } 56 | } 57 | 58 | public int getRealSizeWidth() { 59 | return mRealSizeWidth; 60 | } 61 | 62 | public int getRealSizeHeight() { 63 | return mRealSizeHeight; 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/util/SelectAreaEventHandle.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.util 2 | 3 | import android.animation.Animator 4 | import android.animation.ValueAnimator 5 | import android.content.Context 6 | import android.os.CountDownTimer 7 | import android.os.Handler 8 | import android.view.MotionEvent 9 | import android.view.ViewConfiguration 10 | import android.view.animation.LinearInterpolator 11 | import com.sam.video.timeline.widget.SelectAreaView 12 | import com.sam.video.timeline.widget.SelectAreaView.OnChangeListener.Companion.MODE_WHOLE 13 | import com.sam.video.timeline.widget.TimeLineBaseValue 14 | 15 | /** 16 | * 时间选择事件处理 17 | * [startEndBothMoveEnable]:控制整个区域移动 18 | * 复用: 19 | * 区域选择 [com.sam.video.timeline.widget.SelectAreaView]; 20 | * tag 移动 [com.sam.video.timeline.widget.TagLineView] 21 | * 22 | * @author SamWang(33691286@qq.com) 23 | * @date 2019-09-12 24 | */ 25 | class SelectAreaEventHandle(context: Context) { 26 | 27 | private val horizontalScrollSpeedMaxScaleRate = 9f // 横向自动滚动速度区间,因为是0.1-1.0,所以区间为9倍 28 | private val horizontalScrollSpeedMinScale = 1f 29 | private var horizontalScrollSpeedScale = horizontalScrollSpeedMinScale // 当前速度倍数 30 | private val horizontalScrollMinSpeed = 0.1f // 最小横向自动滚动速度,1毫秒0.1dp 31 | private val autoHorizontalScrollAreaWidth = context.getScreenWidth() / 8f 32 | 33 | var onChangeListener: SelectAreaView.OnChangeListener? = null 34 | private val LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout() 35 | private val LONG_PRESS_EVENT = 1 36 | private val width = context.getScreenWidth() 37 | /** 38 | * 整个区域同时移动(即起始和结束位置同时移动) 39 | */ 40 | var startEndBothMoveEnable = false 41 | 42 | /** 43 | * 整个一起移动的模式 44 | */ 45 | var wholeMoveMode = false 46 | set(value) { 47 | field = value 48 | wholeMoveModeChange?.onWholeMoveModeChange() 49 | if (value) { 50 | onChangeListener?.mode = MODE_WHOLE 51 | onChangeListener?.onTouchDown() 52 | } 53 | } 54 | 55 | /** 56 | * 吸附时的偏移距离 57 | * 产品没给,自己瞎设置4dp 58 | */ 59 | val timeJumpOffsetPx = context.dp2px(4f) 60 | /** 61 | * 吸附偏移时间范围 62 | */ 63 | var timeJumpOffset = 0L 64 | var timeLineValue: TimeLineBaseValue? = null 65 | set(value) { 66 | field = value 67 | timeJumpOffset = value?.px2time(timeJumpOffsetPx) ?: 0 68 | } 69 | 70 | private val longPressHandler = Handler { 71 | if (startEndBothMoveEnable) { 72 | wholeMoveMode = true 73 | context.vibratorOneShot() 74 | } 75 | true 76 | } 77 | 78 | fun removeLongPressEvent() { 79 | longPressHandler.removeMessages(LONG_PRESS_EVENT) 80 | } 81 | fun sendLongPressAtTime(now: Long) { 82 | longPressHandler.sendEmptyMessageAtTime( 83 | LONG_PRESS_EVENT, 84 | now + LONG_PRESS_TIMEOUT 85 | ) 86 | } 87 | 88 | /** 89 | * 手指滑动后所处的位置 90 | */ 91 | private fun changeStart(distanceX: Float, event: MotionEvent):Boolean { 92 | val timeLineValue = timeLineValue?: return false 93 | // < 0 右, >0 左 94 | val startOffset = -timeLineValue.px2time(distanceX) 95 | val consume = onChangeListener?.onChange(startOffset, 0, true) ?: false //是可移 96 | 97 | if (consume) { 98 | startCountDown(startOffset, event.x) 99 | } else { 100 | autoHorizontalScrollAnimator?.cancel() 101 | } 102 | 103 | return consume 104 | } 105 | 106 | private fun startCountDown(offsetTime: Long, eventX: Float) { 107 | horizontalScrollSpeedScale = when { 108 | eventX < autoHorizontalScrollAreaWidth -> { 109 | horizontalScrollSpeedMinScale + (autoHorizontalScrollAreaWidth - eventX) / autoHorizontalScrollAreaWidth * horizontalScrollSpeedMaxScaleRate 110 | } 111 | eventX > width - autoHorizontalScrollAreaWidth -> { 112 | horizontalScrollSpeedMinScale + (eventX - (width - autoHorizontalScrollAreaWidth)) / autoHorizontalScrollAreaWidth * horizontalScrollSpeedMaxScaleRate 113 | } 114 | else -> { 115 | horizontalScrollSpeedMinScale 116 | } 117 | } 118 | if (offsetTime <= 0 && eventX <= autoHorizontalScrollAreaWidth) { 119 | startCountDown(true) 120 | } else if (offsetTime >= 0 && eventX >= width - autoHorizontalScrollAreaWidth) { 121 | startCountDown(false) 122 | } else if (eventX > autoHorizontalScrollAreaWidth && eventX < width - autoHorizontalScrollAreaWidth) { 123 | autoHorizontalScrollAnimator?.cancel() 124 | } 125 | } 126 | 127 | private fun changeEnd(distanceX: Float, event: MotionEvent):Boolean { 128 | val timeLineValue = timeLineValue?: return false 129 | // < 0 右, >0 左 130 | val endOffset = -timeLineValue.px2time(distanceX) 131 | val consume = onChangeListener?.onChange(0, endOffset, true) ?: false 132 | if (consume) { 133 | startCountDown(endOffset, event.x) 134 | } else { 135 | autoHorizontalScrollAnimator?.cancel() 136 | } 137 | 138 | return consume 139 | 140 | } 141 | 142 | 143 | /** 操作的是开始的标记 */ 144 | var touchStartCursor = false 145 | /** 操作的是结束的标记*/ 146 | var touchEndCursor = false 147 | 148 | /** 149 | * 消耗事件 150 | */ 151 | val consumeEvent 152 | get() = touchStartCursor || touchEndCursor || wholeMoveMode 153 | 154 | var autoHorizontalScrollAnimator: ValueAnimator? = null 155 | private var moveLeft = false // true 左,false 右 156 | private fun startCountDown(moveLeft: Boolean) { 157 | this.moveLeft = moveLeft 158 | if (autoHorizontalScrollAnimator != null) { 159 | return 160 | } 161 | val valueAnimator = ValueAnimator.ofInt(1, 10000) 162 | valueAnimator.duration = 10000 163 | valueAnimator.interpolator = LinearInterpolator() 164 | valueAnimator.repeatMode = ValueAnimator.RESTART 165 | valueAnimator.repeatCount = ValueAnimator.INFINITE 166 | 167 | valueAnimator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener { 168 | 169 | private var lastAnimationValue = 0 170 | 171 | override fun onAnimationUpdate(animation: ValueAnimator?) { 172 | val timeLineValue = timeLineValue ?: return 173 | val value = animation?.animatedValue as Int 174 | val time = value - lastAnimationValue 175 | lastAnimationValue = if (value == 10000) 0 else value 176 | var offsetPx = (time * horizontalScrollMinSpeed * horizontalScrollSpeedScale).dp 177 | if (moveLeft) { 178 | offsetPx = -offsetPx 179 | } 180 | val offsetTime = timeLineValue.px2time(offsetPx) 181 | val result = when { 182 | wholeMoveMode -> onChangeListener?.onMove(offsetTime, false) 183 | touchStartCursor -> onChangeListener?.onChange(offsetTime, 0, false) 184 | touchEndCursor -> onChangeListener?.onChange(0, offsetTime, false) 185 | else -> false 186 | } 187 | if (result != true) { 188 | animation.cancel() 189 | } 190 | } 191 | }) 192 | valueAnimator.addListener(object : Animator.AnimatorListener { 193 | override fun onAnimationRepeat(animation: Animator?) { 194 | } 195 | 196 | override fun onAnimationEnd(animation: Animator?) { 197 | } 198 | 199 | override fun onAnimationCancel(animation: Animator?) { 200 | if (autoHorizontalScrollAnimator == valueAnimator) { 201 | autoHorizontalScrollAnimator = null 202 | } 203 | } 204 | 205 | override fun onAnimationStart(animation: Animator?) { 206 | } 207 | }) 208 | 209 | autoHorizontalScrollAnimator = valueAnimator 210 | autoHorizontalScrollAnimator?.start() 211 | } 212 | 213 | fun onScroll( 214 | e1: MotionEvent, 215 | e2: MotionEvent, 216 | distanceX: Float, 217 | distanceY: Float 218 | ): Boolean { 219 | return if (wholeMoveMode) { 220 | move(distanceX, e2) 221 | } else { 222 | (touchStartCursor && changeStart(distanceX, e2)) || (touchEndCursor && changeEnd( 223 | distanceX, 224 | e2 225 | )) //事件传递 226 | } 227 | } 228 | 229 | 230 | /** 231 | * 区域移动 232 | */ 233 | private fun move(distanceX: Float, event: MotionEvent):Boolean { 234 | val timeLineValue = timeLineValue?: return false 235 | // < 0 右, >0 左 236 | val offsetTime = -timeLineValue.px2time(distanceX) 237 | val consume = onChangeListener?.onMove(offsetTime, true) ?: false //是可移 238 | 239 | if (consume) { 240 | startCountDown(offsetTime, event.x) 241 | } else { 242 | autoHorizontalScrollAnimator?.cancel() 243 | } 244 | 245 | return consume 246 | } 247 | 248 | fun onTouchEvent(event: MotionEvent) { 249 | if ((event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) 250 | ) { 251 | longPressHandler.removeMessages(LONG_PRESS_EVENT) 252 | if (wholeMoveMode || touchStartCursor || touchEndCursor) { 253 | onChangeListener?.onTouchUp() 254 | autoHorizontalScrollAnimator?.cancel() 255 | wholeMoveMode = false 256 | } 257 | } 258 | } 259 | 260 | var wholeMoveModeChange: WholeMoveModeChange? = null 261 | interface WholeMoveModeChange { 262 | fun onWholeMoveModeChange() 263 | } 264 | 265 | 266 | } -------------------------------------------------------------------------------- /app/src/main/java/com/sam/video/util/VideoUtils.kt: -------------------------------------------------------------------------------- 1 | package com.sam.video.util 2 | 3 | import android.content.Context 4 | import android.media.MediaMetadataRetriever 5 | import android.net.Uri 6 | import java.io.File 7 | 8 | object VideoUtils { 9 | /** 10 | * 获取视频时长 11 | * 12 | * @param videoPath 视频地址 13 | * @return 视频时长 单位毫秒 14 | */ 15 | fun getVideoDuration(context: Context, videoPath: String?): Long { 16 | val retriever = MediaMetadataRetriever() 17 | retriever.setDataSource(context, Uri.fromFile(File(videoPath))) 18 | val duration = 19 | retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) 20 | .toLong() 21 | retriever.release() 22 | return duration 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/video_cover_duration_default.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/drawable-xxhdpi/video_cover_duration_default.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/video_cover_duration_disable.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/drawable-xxhdpi/video_cover_duration_disable.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/video_edit_frame_pic_icon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/drawable-xxhdpi/video_edit_frame_pic_icon.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/video_frame_cursor.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/drawable-xxhdpi/video_frame_cursor.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/video_select_left.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/drawable-xxhdpi/video_select_left.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-xxhdpi/video_select_right.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/drawable-xxhdpi/video_select_right.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shape_video_edit_filter_place_bg.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/tag_select_white_border_corners.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video_edit__tip_circle_bg.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video_edit__tip_circle_bg_16dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video_edit__tip_line_bg_gradient.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video_item_index_bg.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/video_item_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 18 | 19 | 29 | 30 | 38 | 39 | 45 | 46 | 52 | 53 | 54 | 61 | 62 | 63 | 64 | 77 | 78 | 92 | 93 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_start.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 |