├── .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 | 
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 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_tag_img.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
15 |
16 |
22 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_tag_text.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
22 |
23 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/item_video_frame.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
12 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values/attrs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #008577
4 | #00574B
5 | #D81B60
6 | #26000000
7 | #66000000
8 | #b3000000
9 | #000000
10 | #f7f7f7
11 | #000000
12 | #e3e3e3
13 |
14 | #FFFFFF
15 | #ccffffff
16 | #b3ffffff
17 | #FFBED4
18 |
19 | #19ffffff
20 |
21 | #5b5b5b
22 |
23 | #f0f0f0
24 |
25 | #dddddd
26 | #A3D1E9
27 |
28 | #FF3F5B
29 |
30 | #FC4865
31 | #B3FFFFFF
32 |
33 | #2C2E30
34 | #1a1825
35 | #c067acd5
36 | #67acd5
37 | #fcea9e
38 |
39 | #578fff
40 | #00000000
41 | #fd4965
42 | #4dfd4965
43 | #31FFFFFF
44 | #26FFFFFF
45 |
46 | #e6000000
47 | #f2000000
48 | #cc000000
49 | #80000000
50 | #2A000000
51 | #4c000000
52 | #d9000000
53 | #7D9EC0
54 | #F9F9F9
55 | #2c2e30
56 | #c067acd5
57 | #992c2e30
58 | #9a000000
59 | #BBBBBB
60 | #e7e7e7
61 | #cacbcc
62 | #1e1f21
63 | #a2a7ae
64 |
65 |
66 | #802c2e30
67 | #E8E8E8
68 |
69 | #bfbfbf
70 |
71 | #E6E6E6
72 |
73 |
74 | #ff602a
75 | #ff1383
76 |
77 | #2e2e2e
78 | #c1c1c1
79 | #D75036
80 | #F7C69A
81 | #F8E7B0
82 | #FFEFBF
83 | #FB6548
84 |
85 | #f3f3f3
86 | #e0e0e0
87 |
88 | #bbbbbb
89 | #808080
90 |
91 | #E7E7E7
92 | #E3E3E3
93 | #FD4965
94 |
95 | #CED0D4
96 |
97 | #45D9FC
98 |
99 | #5093ff
100 | #34dcff
101 | #f9b0d8
102 |
103 | #000000
104 | #303030
105 |
106 | #34000000
107 | #00000000
108 |
109 |
110 | #000000
111 | #ffffff
112 | #4cffffff
113 | #f6f6f8
114 | #FFFFFF
115 | #e6ffffff
116 | #F6EDE5
117 |
118 | #4DFFFFFF
119 |
120 |
121 |
122 | #FF3960
123 |
124 | #4085FA
125 |
126 | #2C2E30
127 |
128 |
129 | #BBBBBB
130 |
131 | #f7f7f7
132 | #dddddd
133 |
134 | #4c2c2e30
135 |
136 | #fe7f93
137 |
138 | @color/color_2c2e30
139 | #7f868e
140 | #a0a3a6
141 |
142 | #ff1383
143 | #8047DD
144 |
145 | #ff5f3e
146 |
147 |
148 | #2c2e30
149 | #181818
150 | #cc2c2e30
151 | #332c2e30
152 |
153 | #666666
154 |
155 | #B3B3B3
156 |
157 | #a0a3a6
158 | #ababad
159 | @color/color_2c2e30
160 | @color/color_666666
161 | @color/color_b3b3b3
162 | #C8CFD8
163 |
164 | #fd4965
165 | #4DFF3960
166 | #fafd4965
167 | #33fd4965
168 | #E8E8E8
169 |
170 | #FF3960
171 |
172 | @color/watermelon_98
173 | #ffffffff
174 |
175 | #80000000
176 | #33000000
177 |
178 |
179 | #45d9fc
180 | #8045D9FC
181 |
182 | #ff46494d
183 | #ff2c2e30
184 | @color/white
185 | @color/cool_grey
186 |
187 |
188 | #7fffffff
189 | #b3ffffff
190 | #ccffffff
191 | #d9ffffff
192 | #99ffffff
193 | #e6ffffff
194 | #f2ffffff
195 | #80ffffff
196 | #1Affffff
197 | #33000000
198 | #1A000000
199 | #0f000000
200 | #80000000
201 | #00000000
202 | #2c2e30
203 | #A0A3A6
204 | #818083
205 | #cc353437
206 | #2d2c2f
207 | #E5181718
208 | #cc1c1b1d
209 | #2f2f2e
210 | #EEEEEE
211 | #D6D6D6
212 | #E5E5E5
213 |
214 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | VideoTimeLine
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/values/video_edit_tag_color_res.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | - #6b93d1
6 | - #4da9e8
7 | - #7eb7de
8 | - #777de5
9 | - #5d65b2
10 | - #8c79d3
11 | - #ae85c3
12 | - #6c8baf
13 | - #6a7482
14 | - #6ba9b6
15 |
16 |
17 |
18 | - #00c1a4
19 | - #32d886
20 | - #55d542
21 | - #b3d65c
22 | - #e3d156
23 | - #edc112
24 | - #ff9a57
25 | - #ff819e
26 | - #ff2b6e
27 | - #e9241b
28 |
29 |
--------------------------------------------------------------------------------
/app/src/test/java/com/sam/video/timeline/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.sam.video.timeline
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | ext.kotlin_version = '1.4.0'
5 | repositories {
6 | jcenter()
7 | google()
8 | }
9 | dependencies {
10 | classpath 'com.android.tools.build:gradle:4.1.0'
11 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
12 | }
13 | }
14 |
15 | allprojects {
16 | repositories {
17 | jcenter()
18 | maven { url "https://jitpack.io" }
19 | mavenCentral()
20 | google()
21 | }
22 | }
23 |
24 | task clean(type: Delete) {
25 | delete rootProject.buildDir
26 | }
27 | ext {
28 | quickAdapterVersion = "2.9.49-androidx"
29 | glideVersion = "4.11.0"
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx1536m
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
22 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app'
2 | rootProject.name='VideoTimeLine'
3 |
--------------------------------------------------------------------------------
/时间轴.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/meitu/VideoTimeLine/757b4cb51656f7c87836e0b323209f0fa6916bd4/时间轴.png
--------------------------------------------------------------------------------