Quellcode durchsuchen

feat: 支持查看图片消息&预览

DoggyZhang vor 3 Monaten
Ursprung
Commit
ad82dee9f7
39 geänderte Dateien mit 1630 neuen und 38 gelöschten Zeilen
  1. 1 0
      app/build.gradle
  2. 0 6
      app/src/main/java/com/adealink/weparty/module/image/Router.kt
  3. 2 2
      app/src/main/java/com/adealink/weparty/util/RouterUtil.kt
  4. 11 1
      app/src/main/res/values/no_translate_strings.xml
  5. 0 10
      app/src/main/res/values/strings.xml
  6. 1 0
      app/src/main/resources/META-INF/services/com.adealink.frame.router.IRouterInit
  7. 4 1
      module/im/src/main/java/com/adealink/weparty/im/session/adapter/viewbinder/BaseMessageViewBinder.kt
  8. 34 7
      module/im/src/main/java/com/adealink/weparty/im/session/adapter/viewbinder/ImageMessageViewBinder.kt
  9. 11 11
      module/im/src/main/java/com/adealink/weparty/im/session/adapter/viewbinder/SoundMessageViewBinder.kt
  10. 1 0
      module/image/.gitignore
  11. 41 0
      module/image/build.gradle
  12. 22 0
      module/image/src/androidTest/java/com/adealink/weparty/image/ExampleInstrumentedTest.kt
  13. 32 0
      module/image/src/main/AndroidManifest.xml
  14. 8 0
      module/image/src/main/java/com/adealink/weparty/image/data/Tags.kt
  15. 99 0
      module/image/src/main/java/com/adealink/weparty/image/preview/ImagePreviewActivity.kt
  16. 142 0
      module/image/src/main/java/com/adealink/weparty/image/preview/ImagePreviewFragment.kt
  17. 96 0
      module/image/src/main/java/com/adealink/weparty/image/preview/VideoPreviewActivity.kt
  18. 212 0
      module/image/src/main/java/com/adealink/weparty/image/preview/VideoPreviewFragment.kt
  19. 125 0
      module/image/src/main/java/com/adealink/weparty/image/view/VideoTimeBar.kt
  20. 274 0
      module/image/src/main/java/com/adealink/weparty/image/viewmodel/ImagePreviewExtensionViewModel.kt
  21. 92 0
      module/image/src/main/java/com/adealink/weparty/image/viewmodel/VideoViewModel.kt
  22. 20 0
      module/image/src/main/res/drawable/image_moment_like.xml
  23. 16 0
      module/image/src/main/res/drawable/image_moment_like_bg.xml
  24. 11 0
      module/image/src/main/res/drawable/image_preview_report_ic.xml
  25. 6 0
      module/image/src/main/res/drawable/image_video_bottom_bar_bg.xml
  26. 30 0
      module/image/src/main/res/drawable/image_video_time_bar.xml
  27. 11 0
      module/image/src/main/res/drawable/image_video_time_bar_thumb.xml
  28. 68 0
      module/image/src/main/res/layout/activity_image_preview.xml
  29. 70 0
      module/image/src/main/res/layout/activity_video_preview.xml
  30. 11 0
      module/image/src/main/res/layout/fragment_image_preview.xml
  31. 32 0
      module/image/src/main/res/layout/fragment_video_preview.xml
  32. 80 0
      module/image/src/main/res/layout/layout_video_preview_controller.xml
  33. 25 0
      module/image/src/main/res/layout/layout_video_time_bar.xml
  34. 7 0
      module/image/src/main/res/values-ar/strings.xml
  35. 7 0
      module/image/src/main/res/values-zh/strings.xml
  36. 4 0
      module/image/src/main/res/values/ids.xml
  37. 7 0
      module/image/src/main/res/values/strings.xml
  38. 16 0
      module/image/src/test/java/com/adealink/weparty/image/ExampleUnitTest.kt
  39. 1 0
      settings.gradle

+ 1 - 0
app/build.gradle

@@ -219,6 +219,7 @@ android {
             ':module:order',
             ':module:wallet',
             ':module:share',
+            ':module:image',
     ]
     buildFeatures {
         viewBinding true

+ 0 - 6
app/src/main/java/com/adealink/weparty/module/image/Router.kt

@@ -8,12 +8,6 @@ interface Image {
             const val KEY_IMAGE_URI_LIST = "key_image_uri_list"
             const val KEY_CURRENT_INDEX = "key_current_index"
             const val KEY_OWNER_UID = "key_owner_uid" //图片拥有者的uid
-
-            const val KEY_MOMENT_SCENE = "key_moment_scene" // moment 场景
-            const val KEY_MOMENT_TOPICID = "key_moment_topicid" // moment topicId
-            const val KEY_MOMENT_LIKED = "key_moment_liked" // moment 是否已点赞
-            const val KEY_MOMENT_ADAPTER_POSITION = "key_moment_adapter_index" // 方便页面回来定位 item 的位置, 更新数据
-            const val KEY_SAY_HI_STATE = "key_say_hi_state"
         }
     }
 

+ 2 - 2
app/src/main/java/com/adealink/weparty/util/RouterUtil.kt

@@ -13,7 +13,7 @@ fun goImagePreviewActivity(
     activity: Activity,
     imageUriList: ArrayList<String>,
     currentIndex: Int = 0,
-    ownerUid: Long = 0L,
+    ownerUid: String = "",
 ) {
     Router.build(activity, Image.Preview.PATH)
         .putStringArrayListExtra(KEY_IMAGE_URI_LIST, imageUriList)
@@ -26,7 +26,7 @@ fun goVideoPreviewActivity(
     activity: Activity,
     videoList: ArrayList<VideoItem>,
     currentIndex: Int = 0,
-    ownerUid: Long = 0L,
+    ownerUid: String = "",
 ) {
     Router.build(activity, Video.Preview.PATH)
         .putParcelableArrayListExtra(Video.Preview.KEY_VIDEO_LIST, videoList)

+ 11 - 1
app/src/main/res/values/no_translate_strings.xml

@@ -2,6 +2,17 @@
 <resources>
     <string name="channel_event" translatable="false">event</string>
 
+    <string name="app_name" translatable="false">Gami</string>
+    <string name="module_account" translatable="false">Account</string>
+    <string name="module_profile" translatable="false">profile</string>
+    <string name="module_im" translatable="false">IM</string>
+    <string name="module_playmate" translatable="false">playmate</string>
+    <string name="module_activity" translatable="false">activity</string>
+    <string name="module_setting" translatable="false">setting</string>
+    <string name="module_order" translatable="false">order</string>
+    <string name="module_wallet" translatable="false">wallet</string>
+    <string name="module_share" translatable="false">share</string>
+    <string name="module_image" translatable="false">image</string>
 <!--    <string name="module_account" translatable="false">account</string>-->
 <!--    <string name="module_anchor" translatable="false">anchor</string>-->
 <!--    <string name="module_attribution" translatable="false">attribution</string>-->
@@ -19,7 +30,6 @@
 <!--    <string name="module_gift" translatable="false">gift</string>-->
 <!--    <string name="module_guard_treasure" translatable="false">guardtreasure</string>-->
 <!--    <string name="module_headline" translatable="false">headline</string>-->
-<!--    <string name="module_image" translatable="false">image</string>-->
 <!--    <string name="module_level" translatable="false">level</string>-->
 <!--    <string name="module_ludo" translatable="false">ludo</string>-->
 <!--    <string name="module_medal" translatable="false">medal</string>-->

+ 0 - 10
app/src/main/res/values/strings.xml

@@ -1,15 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="app_name" translatable="false">Gami</string>
-    <string name="module_account" translatable="false">Account</string>
-    <string name="module_profile" translatable="false">profile</string>
-    <string name="module_im" translatable="false">IM</string>
-    <string name="module_playmate" translatable="false">playmate</string>
-    <string name="module_activity" translatable="false">activity</string>
-    <string name="module_setting" translatable="false">setting</string>
-    <string name="module_order" translatable="false">order</string>
-    <string name="module_wallet" translatable="false">wallet</string>
-    <string name="module_share" translatable="false">share</string>
     <string name="common_create">Create</string>
     <string name="common_send">Send</string>
     <string name="common_sending">Sending</string>

+ 1 - 0
app/src/main/resources/META-INF/services/com.adealink.frame.router.IRouterInit

@@ -8,3 +8,4 @@ com.adealink.frame.router.RouterInit_module_setting
 com.adealink.frame.router.RouterInit_module_order
 com.adealink.frame.router.RouterInit_module_wallet
 com.adealink.frame.router.RouterInit_module_share
+com.adealink.frame.router.RouterInit_module_image

+ 4 - 1
module/im/src/main/java/com/adealink/weparty/im/session/adapter/viewbinder/BaseMessageViewBinder.kt

@@ -20,7 +20,7 @@ import com.tencent.qcloud.tuikit.timcommon.interfaces.OnItemClickListener
 /**
  * 对照: ImageMessageHolder
  */
-abstract class BaseMessageViewBinder<T : TUIMessageBean, V: ViewBinding, MH : MessageViewHolder<T, V>>(
+abstract class BaseMessageViewBinder<T : TUIMessageBean, V : ViewBinding, MH : MessageViewHolder<T, V>>(
     protected var onItemClickListener: OnItemClickListener?
 ) : ItemViewBinder<T, MH>() {
 
@@ -56,6 +56,7 @@ abstract class BaseMessageViewBinder<T : TUIMessageBean, V: ViewBinding, MH : Me
                 true
             }
         }
+        holder.initView(holder.messageBinding, msg)
     }
 
     private fun applySelfStyle(holder: MH, msg: T) {
@@ -124,6 +125,8 @@ abstract class MessageViewHolder<T : TUIMessageBean, V : ViewBinding>(
     val messageBinding: V
 ) : BindingViewHolder<LayoutSessionMessageBaseBinding>(rootBinding) {
 
+    open fun initView(binding: V, msg: T) {}
+
     abstract fun onBindSelfMessage(binding: V, msg: T)
 
     abstract fun onBindOtherMessage(binding: V, msg: T)

+ 34 - 7
module/im/src/main/java/com/adealink/weparty/im/session/adapter/viewbinder/ImageMessageViewBinder.kt

@@ -1,14 +1,19 @@
 package com.adealink.weparty.im.session.adapter.viewbinder
 
+import android.app.Activity
 import android.view.LayoutInflater
 import android.view.ViewGroup
+import androidx.core.net.toUri
+import com.adealink.frame.util.onClick
+import com.adealink.weparty.commonui.ext.getActivity
 import com.adealink.weparty.im.databinding.LayoutSessionMessageBaseBinding
 import com.adealink.weparty.im.databinding.LayoutSessionMessageImageBinding
 import com.adealink.weparty.util.formatTimeTo
-import com.tencent.imsdk.v2.V2TIMImageElem
+import com.adealink.weparty.util.goImagePreviewActivity
 import com.tencent.qcloud.tuikit.timcommon.interfaces.OnItemClickListener
 import com.tencent.qcloud.tuikit.tuichat.bean.message.ImageMessageBean
 import com.tencent.qcloud.tuikit.tuichat.presenter.ChatFileDownloadPresenter
+import java.io.File
 
 /**
  * 对照: ImageMessageHolder
@@ -22,20 +27,29 @@ class ImageMessageViewHolder(
     binging
 ) {
 
-    override fun onBindSelfMessage(
+    override fun initView(
         binding: LayoutSessionMessageImageBinding,
         msg: ImageMessageBean
     ) {
+        binding.ivImg.onClick {
+            it.context.getActivity()?.let { act ->
+                clickImage(act, msg)
+            }
+        }
         setImage(binding, msg)
         setMessageTime(binding, msg)
     }
 
+    override fun onBindSelfMessage(
+        binding: LayoutSessionMessageImageBinding,
+        msg: ImageMessageBean
+    ) {
+    }
+
     override fun onBindOtherMessage(
         binding: LayoutSessionMessageImageBinding,
         msg: ImageMessageBean
     ) {
-        setImage(binding, msg)
-        setMessageTime(binding, msg)
     }
 
     private fun setImage(
@@ -43,8 +57,8 @@ class ImageMessageViewHolder(
         item: ImageMessageBean
     ) {
         val imagePath =
-            ChatFileDownloadPresenter.getImagePath(item, V2TIMImageElem.V2TIM_IMAGE_TYPE_ORIGIN)
-        binding.ivImg.setImageURI(imagePath)
+            ChatFileDownloadPresenter.getImagePath(item, ImageMessageBean.IMAGE_TYPE_THUMB)
+        binding.ivImg.setImageURI(File(imagePath).toUri())
     }
 
     private fun setMessageTime(
@@ -53,11 +67,24 @@ class ImageMessageViewHolder(
     ) {
         binding.vText.tvText.text = formatTimeTo((item.messageTime * 1000), "HH:mm")
     }
+
+    private fun clickImage(activity: Activity, msg: ImageMessageBean) {
+        val imageUrl = msg.getImageBean(ImageMessageBean.IMAGE_TYPE_ORIGIN)?.url
+        if (imageUrl.isNullOrEmpty()) {
+            return
+        }
+        goImagePreviewActivity(
+            activity,
+            arrayListOf(imageUrl)
+        )
+    }
 }
 
 class ImageMessageViewBinder(
     onItemClickListener: OnItemClickListener
-) : BaseMessageViewBinder<ImageMessageBean, LayoutSessionMessageImageBinding, ImageMessageViewHolder>(onItemClickListener) {
+) : BaseMessageViewBinder<ImageMessageBean, LayoutSessionMessageImageBinding, ImageMessageViewHolder>(
+    onItemClickListener
+) {
 
 
     override fun onCreateMessageHolder(

+ 11 - 11
module/im/src/main/java/com/adealink/weparty/im/session/adapter/viewbinder/SoundMessageViewBinder.kt

@@ -49,6 +49,17 @@ class SoundMessageViewHolder(
 
     private var downloadSoundCallback: TUIValueCallback<*>? = null
 
+    override fun initView(
+        binding: LayoutSessionMessageSoundBinding,
+        msg: SoundMessageBean
+    ) {
+        val durationStr = formatSecondsTo00(max(msg.getDuration(), 1)) //音频时间最小展示1s
+        resetTimerStatus(binding, durationStr)
+        binding.root.onClick {
+            onSoundClick(binding, msg, msg.getDuration(), durationStr)
+        }
+    }
+
     override fun onBindSelfMessage(
         binding: LayoutSessionMessageSoundBinding,
         msg: SoundMessageBean
@@ -101,17 +112,6 @@ class SoundMessageViewHolder(
         )
     }
 
-    private fun initView(
-        binding: LayoutSessionMessageSoundBinding,
-        msg: SoundMessageBean
-    ) {
-        val durationStr = formatSecondsTo00(max(msg.getDuration(), 1)) //音频时间最小展示1s
-        resetTimerStatus(binding, durationStr)
-        binding.root.onClick {
-            onSoundClick(binding, msg, msg.getDuration(), durationStr)
-        }
-    }
-
     private fun resetTimerStatus(
         binding: LayoutSessionMessageSoundBinding,
         timeString: String?

+ 1 - 0
module/image/.gitignore

@@ -0,0 +1 @@
+/build

+ 41 - 0
module/image/build.gradle

@@ -0,0 +1,41 @@
+plugins {
+    id 'com.android.dynamic-feature'
+    id 'org.jetbrains.kotlin.android'
+    id 'org.jetbrains.kotlin.kapt'
+}
+android {
+    namespace 'com.adealink.weparty.image'
+    compileSdk libs.versions.compileSdk.get().toInteger()
+
+    defaultConfig {
+        minSdk libs.versions.minSdk.get().toInteger()
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+
+
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_17
+        targetCompatibility JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.majorVersion
+    }
+    viewBinding {
+        enabled = true
+    }
+    namespace 'com.adealink.weparty.image'
+}
+
+dependencies {
+    implementation project(":app")
+    //frame
+    kapt libs.frame.router.compiler
+
+    //test
+    testImplementation libs.junit
+    androidTestImplementation libs.androidx.junit
+    androidTestImplementation libs.androidx.espresso.core
+}

+ 22 - 0
module/image/src/androidTest/java/com/adealink/weparty/image/ExampleInstrumentedTest.kt

@@ -0,0 +1,22 @@
+package com.adealink.weparty.image
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+    @Test
+    fun useAppContext() {
+        // Context of the app under test.
+        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+        assertEquals("com.adealink.weparty.image", appContext.packageName)
+    }
+}

+ 32 - 0
module/image/src/main/AndroidManifest.xml

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:dist="http://schemas.android.com/apk/distribution"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="LockedOrientationActivity">
+
+    <dist:module
+        dist:instant="false"
+        dist:title="@string/module_image">
+        <dist:fusing dist:include="true" />
+        <dist:delivery>
+            <dist:install-time>
+                <dist:removable dist:value="true"/>
+            </dist:install-time>
+        </dist:delivery>
+    </dist:module>
+
+    <application android:requestLegacyExternalStorage="true">
+
+        <activity
+            android:name=".preview.ImagePreviewActivity"
+            android:screenOrientation="portrait"
+            android:theme="@style/AppTheme" />
+
+        <activity
+            android:name=".preview.VideoPreviewActivity"
+            android:screenOrientation="portrait"
+            android:theme="@style/AppTheme" />
+
+    </application>
+
+</manifest>

+ 8 - 0
module/image/src/main/java/com/adealink/weparty/image/data/Tags.kt

@@ -0,0 +1,8 @@
+package com.adealink.weparty.image.data
+
+/**
+ * Created by sunxiaodong on 2022/7/19.
+ */
+const val TAG_VIDEO = "tag_video"
+const val TAG_VIDEO_DOWNLOAD = "${TAG_VIDEO}_download"
+const val TAG_IMAGE_PREVIEW = "tag_image_preview"

+ 99 - 0
module/image/src/main/java/com/adealink/weparty/image/preview/ImagePreviewActivity.kt

@@ -0,0 +1,99 @@
+package com.adealink.weparty.image.preview
+
+import android.annotation.SuppressLint
+import android.view.View
+import androidx.activity.OnBackPressedCallback
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentPagerAdapter
+import androidx.viewpager.widget.ViewPager.OnPageChangeListener
+import com.adealink.frame.mvvm.view.viewBinding
+import com.adealink.frame.router.Router
+import com.adealink.frame.router.annotation.BindExtra
+import com.adealink.frame.router.annotation.RouterUri
+import com.adealink.weparty.commonui.BaseActivity
+import com.adealink.weparty.image.databinding.ActivityImagePreviewBinding
+import com.adealink.weparty.module.image.Image
+import com.adealink.weparty.module.profile.ProfileModule
+import com.qmuiteam.qmui.widget.util.QMUIStatusBarHelper
+import com.adealink.weparty.R as APP_R
+
+@RouterUri(path = [Image.Preview.PATH], desc = "图片预览页")
+class ImagePreviewActivity : BaseActivity() {
+    companion object {
+        private const val TAG = "ImagePreviewActivity"
+    }
+
+    @BindExtra(name = Image.Preview.KEY_IMAGE_URI_LIST, desc = "图片url列表")
+    var imageUriList: ArrayList<String>? = null
+
+    @BindExtra(name = Image.Preview.KEY_CURRENT_INDEX, desc = "展示的图片索引")
+    var currentIndex = 0
+
+    @BindExtra(name = Image.Preview.KEY_OWNER_UID, desc = "图片所有者uid")
+    var uid: String = ""
+
+    private val binding by viewBinding(ActivityImagePreviewBinding::inflate)
+    private val images: List<String>
+        get() = imageUriList ?: arrayListOf()
+    private lateinit var adapter: ImagePreviewFragmentAdapter
+
+    override fun onBeforeCreate() {
+        super.onBeforeCreate()
+        Router.bind(this)
+    }
+
+    @SuppressLint("SetTextI18n")
+    override fun initViews() {
+        QMUIStatusBarHelper.setStatusBarDarkMode(this)
+        setNavigationBarColor(APP_R.color.black)
+        setContentView(binding.root)
+        binding.back.setOnClickListener {
+            finish()
+        }
+        adapter = ImagePreviewFragmentAdapter(supportFragmentManager)
+        binding.vpImages.adapter = adapter
+        binding.vpImages.addOnPageChangeListener(object : OnPageChangeListener {
+            override fun onPageScrolled(
+                position: Int,
+                positionOffset: Float,
+                positionOffsetPixels: Int,
+            ) {
+            }
+
+            @SuppressLint("SetTextI18n")
+            override fun onPageSelected(position: Int) {
+                binding.title.text = "${position + 1}/${images.size}"
+            }
+
+            override fun onPageScrollStateChanged(state: Int) {}
+        })
+        if (currentIndex == 0) {
+            binding.title.text = "1/${images.size}"
+        }
+        binding.vpImages.currentItem = currentIndex
+        binding.ivReport.visibility =
+            if (uid.isEmpty() || uid == ProfileModule.getMyUid()) View.GONE else View.VISIBLE
+
+//        binding.ivReport.setOnClickListener {
+//            UserProtectModule.showReportUserDialog(uid, ReportFrom.IMAGE_PREVIEW)
+//        }
+
+        onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
+            override fun handleOnBackPressed() {
+                finish()
+            }
+        })
+    }
+
+    inner class ImagePreviewFragmentAdapter(fm: FragmentManager) :
+        FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
+        override fun getItem(position: Int): Fragment {
+            return ImagePreviewFragment.newInstance(images[position])
+        }
+
+        override fun getCount(): Int {
+            return images.size
+        }
+    }
+}

+ 142 - 0
module/image/src/main/java/com/adealink/weparty/image/preview/ImagePreviewFragment.kt

@@ -0,0 +1,142 @@
+package com.adealink.weparty.image.preview
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import androidx.fragment.app.viewModels
+import com.adealink.frame.aab.util.getCompatString
+import com.adealink.frame.image.view.zoomable.IExtraListener
+import com.adealink.frame.mvvm.view.viewBinding
+import com.adealink.weparty.commonui.BaseFragment
+import com.adealink.weparty.commonui.dialogfragment.CommonTextActionDialog
+import com.adealink.weparty.commonui.toast.util.showToast
+import com.adealink.weparty.commonui.widget.CommonDialog
+import com.adealink.weparty.image.R
+import com.adealink.weparty.image.data.TAG_IMAGE_PREVIEW
+import com.adealink.weparty.image.databinding.FragmentImagePreviewBinding
+import com.adealink.weparty.image.viewmodel.ImagePreviewExtensionViewModel
+import com.adealink.weparty.image.viewmodel.Resource
+import com.adealink.weparty.permission.PermissionUtils
+import com.adealink.weparty.R as APP_R
+
+
+class ImagePreviewFragment : BaseFragment(R.layout.fragment_image_preview) {
+    companion object {
+        private const val KEY_URL = "url"
+        private const val STORAGE_PERMISSION_DIALOG = "storage_permission_dialog"
+        fun newInstance(uri: String): ImagePreviewFragment {
+            val fragment = ImagePreviewFragment()
+            val bundle = Bundle()
+            bundle.putString(KEY_URL, uri)
+            fragment.arguments = bundle
+            return fragment
+        }
+    }
+
+    private val binding by viewBinding(FragmentImagePreviewBinding::bind)
+    private val imagePreviewExtensionViewModel by viewModels<ImagePreviewExtensionViewModel>()
+    private var imageUri = ""
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        imageUri = arguments?.getString(KEY_URL) ?: ""
+    }
+
+    override fun observeViewModel() {
+        super.observeViewModel()
+        imagePreviewExtensionViewModel.saveResult.observe(viewLifecycleOwner) {
+            when (it) {
+                is Resource.Success -> {
+                    showToast(R.string.image_save_success)
+
+                }
+
+                is Resource.Error -> {
+                    Log.e(TAG_IMAGE_PREVIEW, "saveResult error, e:${it.error?.message}")
+                    showToast(R.string.image_save_failed)
+                }
+
+                is Resource.Loading -> {
+                    //Ntd.
+                }
+            }
+        }
+    }
+
+    private fun showLongClickActionDialog() {
+        if (!isAdded) {
+            return
+        }
+        CommonTextActionDialog.Builder()
+            .addItem(R.id.image_preview_save, APP_R.string.common_save)
+            .actionListener(object : CommonTextActionDialog.CommonTextActionListener {
+                override fun onActionClick(viewId: Int) {
+                    when (viewId) {
+                        R.id.image_preview_save -> {
+                            checkStoragePermissionAndSaveImg(imageUri)
+                        }
+                    }
+                }
+            }).build().show(childFragmentManager)
+    }
+
+    override fun initViews() {
+        super.initViews()
+        binding.ivPhoto.extraListener = object : IExtraListener {
+            override fun onLongClick() {
+                //长按菜单,仅在图片加载成功后可弹出
+                showLongClickActionDialog()
+            }
+
+            override fun onSwipeDown() {
+                //下滑退出页面
+                activity?.finish()
+            }
+        }
+        binding.ivPhoto.setImagePath(imageUri)
+    }
+
+    //保存图片需要检查存储权限
+    @SuppressLint("CheckResult")
+    private fun checkStoragePermissionAndSaveImg(imageUri: String) {
+        val act = activity ?: return
+        val permission = getMediaImagePermission()
+        if (PermissionUtils.hasPermissions(act, *permission)) {
+            imagePreviewExtensionViewModel.saveImageToAlbum(imageUri)
+        } else {
+            PermissionUtils.getRxPermissions(act)
+                .request(*permission).subscribe { granted ->
+                    if (granted) {
+                        imagePreviewExtensionViewModel.saveImageToAlbum(imageUri)
+                    } else {
+                        showPermissionNoGrantedDialog()
+                    }
+                }
+        }
+    }
+
+    private fun getMediaImagePermission(): Array<String> {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            arrayOf(Manifest.permission.READ_MEDIA_IMAGES)
+        } else {
+            arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+        }
+    }
+
+    private fun showPermissionNoGrantedDialog() {
+        childFragmentManager.findFragmentByTag(STORAGE_PERMISSION_DIALOG)?.let {
+            childFragmentManager.beginTransaction().remove(it).commitNowAllowingStateLoss()
+        }
+        val permissionDialog = CommonDialog.Builder()
+            .message(getCompatString(R.string.image_save_storage_permission_no_granted))
+            .onPositive {
+                PermissionUtils.startPermissionSettingActivity(this)
+            }
+            .dismissAfterClick(true)
+            .setShowDefaultCancel(true)
+            .build()
+        permissionDialog.show(childFragmentManager, STORAGE_PERMISSION_DIALOG)
+    }
+}

+ 96 - 0
module/image/src/main/java/com/adealink/weparty/image/preview/VideoPreviewActivity.kt

@@ -0,0 +1,96 @@
+package com.adealink.weparty.image.preview
+
+import android.annotation.SuppressLint
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentPagerAdapter
+import androidx.viewpager.widget.ViewPager
+import com.adealink.frame.mvvm.view.viewBinding
+import com.adealink.frame.router.Router
+import com.adealink.frame.router.annotation.BindExtra
+import com.adealink.frame.router.annotation.RouterUri
+import com.adealink.weparty.commonui.BaseActivity
+import com.adealink.weparty.image.databinding.ActivityVideoPreviewBinding
+import com.adealink.weparty.module.image.Video
+import com.adealink.weparty.module.image.data.VideoItem
+import com.qmuiteam.qmui.widget.util.QMUIStatusBarHelper
+import com.adealink.weparty.R as APP_R
+
+/**
+ * Created by sunxiaodong on 2022/7/15.
+ */
+@RouterUri(path = [Video.Preview.PATH], desc = "视频预览页")
+class VideoPreviewActivity : BaseActivity() {
+
+    companion object {
+        private const val TAG = "VideoPreviewActivity"
+    }
+
+    @BindExtra(name = Video.Preview.KEY_VIDEO_LIST, desc = "url列表")
+    var videoList: ArrayList<VideoItem>? = null
+
+    @BindExtra(name = Video.Preview.KEY_CURRENT_INDEX, desc = "当前索引")
+    var currentIndex = 0
+
+    @BindExtra(name = Video.Preview.KEY_OWNER_UID, desc = "所有者uid")
+    var uid = 0L
+
+    private val binding by viewBinding(ActivityVideoPreviewBinding::inflate)
+    private val videos: List<VideoItem>
+        get() = videoList ?: arrayListOf()
+
+    private lateinit var adapter: PreviewFragmentAdapter
+
+    override fun onBeforeCreate() {
+        super.onBeforeCreate()
+        Router.bind(this)
+    }
+
+    @SuppressLint("SetTextI18n")
+    override fun initViews() {
+        QMUIStatusBarHelper.setStatusBarDarkMode(this)
+        setNavigationBarColor(APP_R.color.black)
+        setContentView(binding.root)
+        binding.back.setOnClickListener {
+            finish()
+        }
+        adapter = PreviewFragmentAdapter(supportFragmentManager)
+        binding.vpVideos.adapter = adapter
+        binding.vpVideos.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
+            override fun onPageScrolled(
+                position: Int,
+                positionOffset: Float,
+                positionOffsetPixels: Int,
+            ) {
+            }
+
+            @SuppressLint("SetTextI18n")
+            override fun onPageSelected(position: Int) {
+                binding.title.text = "${position + 1}/${videos.size}"
+            }
+
+            override fun onPageScrollStateChanged(state: Int) {}
+        })
+        if (currentIndex == 0) {
+            binding.title.text = "1/${videos.size}"
+        }
+        binding.vpVideos.currentItem = currentIndex
+//        binding.reportTv.visibility =
+//            if (uid == 0L || uid == ProfileModule.getMyUid()) View.GONE else View.VISIBLE
+//        binding.reportTv.setOnClickListener {
+//            UserProtectModule.showReportUserDialog(uid, ReportFrom.IMAGE_PREVIEW)
+//        }
+    }
+
+    inner class PreviewFragmentAdapter(fm: FragmentManager) :
+        FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
+        override fun getItem(position: Int): Fragment {
+            return VideoPreviewFragment.newInstance(videos[position])
+        }
+
+        override fun getCount(): Int {
+            return videos.size
+        }
+    }
+
+}

+ 212 - 0
module/image/src/main/java/com/adealink/weparty/image/preview/VideoPreviewFragment.kt

@@ -0,0 +1,212 @@
+package com.adealink.weparty.image.preview
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.webkit.URLUtil
+import androidx.fragment.app.viewModels
+import com.adealink.frame.aab.util.getCompatString
+import com.adealink.frame.base.Rlt
+import com.adealink.frame.base.fastLazy
+import com.adealink.frame.effect.video.controller.VideoScalingMode
+import com.adealink.frame.mvvm.view.viewBinding
+import com.adealink.weparty.commonui.BaseFragment
+import com.adealink.weparty.commonui.dialogfragment.CommonTextActionDialog
+import com.adealink.weparty.commonui.ext.gone
+import com.adealink.weparty.commonui.ext.show
+import com.adealink.weparty.commonui.toast.util.showToast
+import com.adealink.weparty.commonui.widget.CommonDialog
+import com.adealink.weparty.image.R
+import com.adealink.weparty.image.data.TAG_IMAGE_PREVIEW
+import com.adealink.weparty.image.databinding.FragmentVideoPreviewBinding
+import com.adealink.weparty.image.viewmodel.ImagePreviewExtensionViewModel
+import com.adealink.weparty.image.viewmodel.Resource
+import com.adealink.weparty.image.viewmodel.VideoViewModel
+import com.adealink.weparty.module.image.data.VideoItem
+import com.adealink.weparty.permission.PermissionUtils
+import com.adealink.weparty.R as APP_R
+
+/**
+ * Created by sunxiaodong on 2022/7/15.
+ */
+class VideoPreviewFragment : BaseFragment(R.layout.fragment_video_preview) {
+
+    companion object {
+        private const val KEY_VIDEO = "video"
+        private const val STORAGE_PERMISSION_DIALOG = "storage_permission_dialog"
+
+        fun newInstance(video: VideoItem): VideoPreviewFragment {
+            val fragment = VideoPreviewFragment()
+            val bundle = Bundle()
+            bundle.putParcelable(KEY_VIDEO, video)
+            fragment.arguments = bundle
+            return fragment
+        }
+    }
+
+    private val videoViewModel by viewModels<VideoViewModel>()
+    private val imagePreviewExtensionViewModel by viewModels<ImagePreviewExtensionViewModel>()
+    private val binding by viewBinding(FragmentVideoPreviewBinding::bind)
+    private var videoItem: VideoItem? = null
+    private var isVideoReady: Boolean = false
+    private var videoLocalPath: String? = null
+    //长按和下滑检测
+    private val gestureDetector by fastLazy {
+        GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
+            private val SWIPE_THRESHOLD = 100
+            private val SWIPE_VELOCITY_THRESHOLD = 100
+
+            override fun onFling(
+                e1: MotionEvent?,
+                e2: MotionEvent,
+                velocityX: Float,
+                velocityY: Float
+            ): Boolean {
+                if (e1 == null) return false
+                val deltaY = e2.y - e1.y
+                if (deltaY > SWIPE_THRESHOLD && Math.abs(velocityY) > SWIPE_VELOCITY_THRESHOLD) {
+                    activity?.finish()
+                    return true
+                }
+                return false
+            }
+
+            override fun onLongPress(e: MotionEvent) {
+                super.onLongPress(e)
+                if (!isVideoReady) {
+                    return
+                }
+                showLongClickActionDialog()
+            }
+        })
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        videoItem = arguments?.getParcelable(KEY_VIDEO)
+    }
+
+    @SuppressLint("ClickableViewAccessibility")
+    override fun initViews() {
+        super.initViews()
+        binding.videoView.setOnTouchListener { _, event ->
+            gestureDetector.onTouchEvent(event)
+            true
+        }
+    }
+
+    override fun observeViewModel() {
+        super.observeViewModel()
+        imagePreviewExtensionViewModel.saveResult.observe(viewLifecycleOwner) {
+            when(it) {
+                is Resource.Success -> {
+                    showToast(R.string.image_save_success)
+                }
+                is Resource.Error -> {
+                    Log.e(TAG_IMAGE_PREVIEW, "saveResult error, e:${it.error?.message}")
+                    showToast(R.string.image_save_failed)
+                }
+                is Resource.Loading -> {
+                    //Ntd.
+                }
+            }
+        }
+    }
+
+    override fun loadData() {
+        super.loadData()
+        val videoUrl = videoItem?.url?: return
+        if (URLUtil.isNetworkUrl(videoUrl)) {
+            binding.vVideoLoading.show()
+            videoViewModel.downloadVideo(videoUrl).observe(viewLifecycleOwner) {
+                binding.vVideoLoading.gone()
+                when(it) {
+                    is Rlt.Success -> {
+                        videoLocalPath = it.data.use()
+                    }
+                    is Rlt.Failed -> {
+
+                    }
+                }
+                videoLocalPath?.let { path ->
+                    playVideo(path)
+                }
+            }
+            return
+        }
+        videoLocalPath = videoUrl
+        playVideo(videoUrl)
+    }
+
+    private fun playVideo(url: String) {
+        binding.videoView.setVideoScalingMode(VideoScalingMode.ScaleToFit)
+        binding.videoView.setAutoPlay(true)
+        binding.videoView.setLoopCount(1)
+        binding.videoView.setShowControllerBar(true)
+        binding.videoView.setUri(url)
+    }
+
+    private fun showLongClickActionDialog() {
+        if (!isAdded) {
+            return
+        }
+        CommonTextActionDialog.Builder()
+            .addItem(R.id.image_preview_save, APP_R.string.common_save)
+            .actionListener(object : CommonTextActionDialog.CommonTextActionListener {
+                override fun onActionClick(viewId: Int) {
+                    when (viewId) {
+                        R.id.image_preview_save -> {
+                            checkStoragePermissionAndSaveVideo()
+                        }
+                    }
+                }
+            }).build().show(childFragmentManager)
+    }
+
+    //保存视频需要检查存储权限
+    @SuppressLint("CheckResult")
+    private fun checkStoragePermissionAndSaveVideo() {
+        val act = activity ?: return
+        val permission = getVideoPermission()
+        if (PermissionUtils.hasPermissions(act, *permission)) {
+            imagePreviewExtensionViewModel.saveLocalVideoToAlbum(videoLocalPath)
+        } else {
+            PermissionUtils.getRxPermissions(act)
+                .request(*permission).subscribe { granted ->
+                    if (granted) {
+                        imagePreviewExtensionViewModel.saveLocalVideoToAlbum(videoLocalPath)
+                    } else {
+                        showPermissionNoGrantedDialog()
+                    }
+                }
+        }
+    }
+
+    private fun getVideoPermission(): Array<String> {
+        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            arrayOf(Manifest.permission.READ_MEDIA_VIDEO)
+        } else {
+            arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+        }
+    }
+
+    private fun showPermissionNoGrantedDialog() {
+        childFragmentManager.findFragmentByTag(STORAGE_PERMISSION_DIALOG)?.let {
+            childFragmentManager.beginTransaction().remove(it).commitNowAllowingStateLoss()
+        }
+        val permissionDialog = CommonDialog.Builder()
+            .message(getCompatString(R.string.image_video_save_storage_permission_no_granted))
+            .onPositive {
+                PermissionUtils.startPermissionSettingActivity(this)
+            }
+            .dismissAfterClick(true)
+            .setShowDefaultCancel(true)
+            .build()
+        permissionDialog.show(childFragmentManager, STORAGE_PERMISSION_DIALOG)
+    }
+
+}

+ 125 - 0
module/image/src/main/java/com/adealink/weparty/image/view/VideoTimeBar.kt

@@ -0,0 +1,125 @@
+package com.adealink.weparty.image.view
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.ViewParent
+import android.widget.SeekBar
+import android.widget.SeekBar.OnSeekBarChangeListener
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.ui.TimeBar
+import com.adealink.frame.log.Log
+import com.adealink.frame.util.DisplayUtil
+import com.adealink.weparty.image.databinding.LayoutVideoTimeBarBinding
+import java.util.concurrent.CopyOnWriteArraySet
+
+@UnstableApi
+class VideoTimeBar @JvmOverloads constructor(
+    context: Context, attrs: AttributeSet? = null
+) : ConstraintLayout(context, attrs), TimeBar {
+
+    private val binding = LayoutVideoTimeBarBinding.inflate(LayoutInflater.from(context), this)
+
+    private val listeners: CopyOnWriteArraySet<TimeBar.OnScrubListener> = CopyOnWriteArraySet()
+    private var position: Long = 0
+    private var bufferedPosition: Long = 0
+    private var duration: Long = 0
+
+    init {
+        binding.seekBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
+            override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
+                if (fromUser) {
+
+                }
+            }
+
+            override fun onStartTrackingTouch(seekBar: SeekBar?) {
+//                seekBar?.progress?.let {
+//                    startScrubbing(it.toLong())
+//                }
+            }
+
+            override fun onStopTrackingTouch(seekBar: SeekBar?) {
+                seekBar?.progress?.let {
+                    stopScrubbing(it.toLong())
+                }
+            }
+        })
+    }
+
+    private fun stopScrubbing(position: Long) {
+        val parent: ViewParent? = parent
+        parent?.requestDisallowInterceptTouchEvent(false)
+        invalidate()
+        for (listener: TimeBar.OnScrubListener in listeners) {
+            listener.onScrubStop(this, position, false)
+        }
+    }
+
+    override fun addListener(listener: TimeBar.OnScrubListener) {
+        listeners.add(listener)
+    }
+
+    override fun removeListener(listener: TimeBar.OnScrubListener) {
+        listeners.remove(listener)
+    }
+
+    override fun setKeyTimeIncrement(time: Long) {
+        //Ntd.
+    }
+
+    override fun setKeyCountIncrement(count: Int) {
+        //Ntd.
+    }
+
+    override fun setPosition(position: Long) {
+        Log.d(TAG, "setPosition:$position")
+        if (this.position == position) {
+            return
+        }
+        this.position = position
+        update()
+    }
+
+    override fun setBufferedPosition(bufferedPosition: Long) {
+        Log.d(TAG, "setBufferedPosition:$bufferedPosition")
+        if (this.bufferedPosition == bufferedPosition) {
+            return
+        }
+        this.bufferedPosition = bufferedPosition
+        update()
+    }
+
+    override fun setDuration(duration: Long) {
+        Log.d(TAG, "setDuration:$duration")
+        if (this.duration == duration) {
+            return
+        }
+        this.duration = duration
+        update()
+    }
+
+    override fun getPreferredUpdateDelay(): Long {
+        val timeBarWidthDp: Int = DisplayUtil.px2dp(binding.seekBar.width.toFloat())
+        return if ((timeBarWidthDp == 0) || (duration == 0L) || (duration == androidx.media3.common.C.TIME_UNSET)) {
+            Long.Companion.MAX_VALUE
+        } else {
+            duration / timeBarWidthDp
+        }
+    }
+
+    override fun setAdGroupTimesMs(adGroupTimesMs: LongArray?, playedAdGroups: BooleanArray?, adGroupCount: Int) {
+        //Ntd.
+    }
+
+    private fun update() {
+        binding.seekBar.max = duration.toInt()
+        binding.seekBar.progress = position.toInt()
+        binding.seekBar.secondaryProgress = bufferedPosition.toInt()
+    }
+
+    companion object {
+        const val TAG = "VideoTimeBar"
+    }
+}

+ 274 - 0
module/image/src/main/java/com/adealink/weparty/image/viewmodel/ImagePreviewExtensionViewModel.kt

@@ -0,0 +1,274 @@
+package com.adealink.weparty.image.viewmodel
+
+import android.annotation.SuppressLint
+import android.content.ContentValues
+import android.media.MediaScannerConnection
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.webkit.URLUtil
+import androidx.lifecycle.MutableLiveData
+import com.adealink.frame.coroutine.dispatcher.Dispatcher
+import com.adealink.frame.mvvm.viewmodel.BaseViewModel
+import com.adealink.frame.util.AppUtil
+import com.adealink.weparty.imageselect.util.PictureMimeType
+import com.facebook.common.executors.CallerThreadExecutor
+import com.facebook.common.memory.PooledByteBuffer
+import com.facebook.common.memory.PooledByteBufferInputStream
+import com.facebook.common.references.CloseableReference
+import com.facebook.datasource.BaseDataSubscriber
+import com.facebook.datasource.DataSource
+import com.facebook.drawee.backends.pipeline.Fresco
+import com.facebook.imagepipeline.common.RotationOptions
+import com.facebook.imagepipeline.request.ImageRequestBuilder
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+import kotlin.coroutines.resumeWithException
+
+//TODO wtr 这部分较粗糙 待整理
+class ImagePreviewExtensionViewModel : BaseViewModel() {
+    val saveResult = MutableLiveData<Resource<Uri>>()
+
+    fun saveImageToAlbum(imageUri: String) {
+        viewModelScope.launch {
+            saveResult.postValue(Resource.loading())
+            try {
+                val savedUri = withContext(Dispatcher.WENEXT_THREAD_POOL) {
+                    when {
+                        URLUtil.isNetworkUrl(imageUri) -> handleNetworkImage(imageUri)
+                        imageUri.startsWith("content://") -> handleContentUri(
+                            imageUri,
+                            "image",
+                            "image/*"
+                        )
+                        imageUri.startsWith("file://") -> handleFileUri(imageUri, "image/*")
+                        else -> throw IllegalArgumentException("Unsupported uri type")
+                    }
+                }
+                saveResult.postValue(Resource.success(savedUri))
+            } catch (e: Exception) {
+                saveResult.postValue(Resource.error(e))
+            }
+        }
+    }
+
+    fun saveLocalVideoToAlbum(videoLocalUrl: String?) {
+        viewModelScope.launch {
+            if (videoLocalUrl == null) {
+                saveResult.postValue(Resource.error(IOException("Video local url is null")))
+                return@launch
+            }
+            saveResult.postValue(Resource.loading())
+            try {
+                val savedUri = withContext(Dispatcher.WENEXT_THREAD_POOL) {
+                    when {
+                        videoLocalUrl.startsWith("content://") -> handleContentUri(
+                            videoLocalUrl,
+                            "video",
+                            "video/*"
+                        )
+                        else -> handleFileUri(videoLocalUrl, "video/*")
+                    }
+                }
+                saveResult.postValue(Resource.success(savedUri))
+            } catch (e: Exception) {
+                saveResult.postValue(Resource.error(e))
+            }
+        }
+    }
+
+    // 处理网络图片
+    private suspend fun handleNetworkImage(url: String): Uri {
+        return downloadAndSaveImage(url)
+    }
+
+    private suspend fun downloadAndSaveImage(url: String): Uri =
+        suspendCancellableCoroutine { cancellableContinuation ->
+            val uri = Uri.parse(url)
+            val imageRequest = ImageRequestBuilder.newBuilderWithSource(uri)
+                .setRotationOptions(RotationOptions.autoRotate())
+                .build()
+
+            val dataSource = Fresco.getImagePipeline().fetchEncodedImage(imageRequest, null)
+
+            dataSource.subscribe(
+                object : BaseDataSubscriber<CloseableReference<PooledByteBuffer>>() {
+                    override fun onNewResultImpl(dataSource: DataSource<CloseableReference<PooledByteBuffer>>) {
+                        if (!dataSource.isFinished) return
+
+                        try {
+                            val ref = dataSource.result ?: throw IOException("Empty image data")
+                            val imageStream: InputStream = PooledByteBufferInputStream(ref.get())
+                            val fileName = URLUtil.guessFileName(uri.toString(), null, null)
+                            val savedUri = saveToGallery(imageStream, fileName, "image/*")
+                            cancellableContinuation.resume(savedUri, null)
+                        } catch (e: Exception) {
+                            cancellableContinuation.resumeWithException(e)
+                        } finally {
+                            CloseableReference.closeSafely(dataSource.result)
+                            dataSource.close()
+                        }
+                    }
+
+                    override fun onFailureImpl(dataSource: DataSource<CloseableReference<PooledByteBuffer>>) {
+                        cancellableContinuation.resumeWithException(
+                            dataSource.failureCause ?: IOException("Unknown error")
+                        )
+                        dataSource.close()
+                    }
+
+                    override fun onProgressUpdate(progress: DataSource<CloseableReference<PooledByteBuffer>>) {
+                        // 可选:处理进度更新
+                    }
+                },
+                CallerThreadExecutor.getInstance() // 指定回调执行线程
+            )
+
+            // 协程取消时关闭 DataSource
+            cancellableContinuation.invokeOnCancellation {
+                dataSource.close()
+            }
+        }
+
+    // 处理 Content Uri
+    private fun handleContentUri(uriString: String, namePrefix: String, mineType: String): Uri {
+        val uri = Uri.parse(uriString)
+        return AppUtil.appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
+            saveToGallery(
+                inputStream = inputStream,
+                displayName = getFileNameFromContentUri(uri, namePrefix),
+                mineType
+            )
+        } ?: throw FileNotFoundException("Cannot open content uri")
+    }
+
+    // 处理文件路径
+    private fun handleFileUri(uriString: String, mineType: String): Uri {
+        val file = File(uriString.replace("file://", ""))
+        if (!file.exists()) throw FileNotFoundException("File not exists")
+
+        return saveToGallery(
+            inputStream = FileInputStream(file),
+            displayName = file.name,
+            mineType
+        )
+    }
+
+    // 核心保存方法(适配 Android 10+)
+    @SuppressLint("Range")
+    private fun saveToGallery(
+        inputStream: InputStream,
+        displayName: String,
+        mineType: String
+    ): Uri {
+        val values =
+            if (PictureMimeType.eqImage(mineType)) {
+                ContentValues().apply {
+                    put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
+                    put(MediaStore.Images.Media.MIME_TYPE, mineType)
+                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
+                        put(MediaStore.Images.Media.IS_PENDING, 1)
+                    }
+                }
+            } else {
+                ContentValues().apply {
+                    put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
+                    put(MediaStore.Video.Media.MIME_TYPE, "video/*")
+                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                        put(MediaStore.Video.Media.RELATIVE_PATH, Environment.DIRECTORY_MOVIES)
+                        put(MediaStore.Video.Media.IS_PENDING, 1)
+                    }
+                }
+            }
+
+        val resolver = AppUtil.appContext.contentResolver
+        var uri: Uri? = null
+
+        try {
+            // 插入 MediaStore
+            uri = resolver.insert(
+                if (PictureMimeType.eqImage(mineType)) {
+                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+                } else {
+                    MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+                },
+                values
+            ) ?: throw IOException("Failed to create media entry")
+
+            // 写入文件内容
+            resolver.openOutputStream(uri)?.use { output ->
+                inputStream.copyTo(output)
+            } ?: throw IOException("Failed to get output stream")
+
+            // 适配 Android Q+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                values.clear()
+                if (PictureMimeType.eqImage(mineType)) {
+                    values.put(MediaStore.Images.Media.IS_PENDING, 0)
+                } else {
+                    values.put(MediaStore.Video.Media.IS_PENDING, 0)
+                }
+                resolver.update(uri, values, null, null)
+            }
+
+            // 通知媒体扫描(兼容旧版本)
+            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+                val path = uri.path ?: throw IOException("Invalid uri path")
+                MediaScannerConnection.scanFile(
+                    AppUtil.appContext,
+                    arrayOf(path),
+                    arrayOf(mineType),
+                    null
+                )
+            }
+
+            return uri
+        } catch (e: Exception) {
+            uri?.let { resolver.delete(it, null, null) }
+            throw e
+        }
+    }
+
+    // 从 Content Uri 获取文件名
+    @SuppressLint("Range")
+    private fun getFileNameFromContentUri(uri: Uri, namePrefix: String): String {
+        return AppUtil.appContext.contentResolver.query(
+            uri,
+            arrayOf(MediaStore.Images.ImageColumns.DISPLAY_NAME),
+            null,
+            null,
+            null
+        )?.use { cursor ->
+            if (cursor.moveToFirst()) {
+                cursor.getString(cursor.getColumnIndex(MediaStore.Images.ImageColumns.DISPLAY_NAME))
+            } else {
+                "${namePrefix}_${System.currentTimeMillis()}.jpg"
+            }
+        } ?: "${namePrefix}_${System.currentTimeMillis()}.jpg"
+    }
+}
+
+sealed class Resource<T>(
+    val data: T? = null,
+    val error: Throwable? = null
+) {
+    class Loading<T> : Resource<T>()
+
+    class Success<T>(data: T) : Resource<T>(data = data)
+
+    class Error<T>(error: Throwable) : Resource<T>(error = error)
+
+    companion object {
+        fun <T> loading(): Resource<T> = Loading()
+        fun <T> success(data: T): Resource<T> = Success(data)
+        fun <T> error(error: Throwable): Resource<T> = Error(error)
+    }
+}

+ 92 - 0
module/image/src/main/java/com/adealink/weparty/image/viewmodel/VideoViewModel.kt

@@ -0,0 +1,92 @@
+package com.adealink.weparty.image.viewmodel
+
+import androidx.lifecycle.LiveData
+import com.adealink.frame.base.IError
+import com.adealink.frame.base.Rlt
+import com.adealink.frame.download.listener.TaskListener
+import com.adealink.frame.download.task.Task
+import com.adealink.frame.log.Log
+import com.adealink.frame.mvvm.livedata.OnceMutableLiveData
+import com.adealink.frame.mvvm.viewmodel.BaseViewModel
+import com.adealink.frame.storage.file.WeFile
+import com.adealink.frame.storage.file.getCacheFilePathByUrl
+import com.adealink.frame.storageService
+import com.adealink.frame.util.md5
+import com.adealink.weparty.App
+import com.adealink.weparty.image.data.TAG_VIDEO_DOWNLOAD
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
+
+/**
+ * Created by sunxiaodong on 2022/7/19.
+ */
+class VideoViewModel : BaseViewModel() {
+
+    fun downloadVideo(url: String): LiveData<Rlt<WeFile>> {
+        val liveData = OnceMutableLiveData<Rlt<WeFile>>()
+        viewModelScope.launch {
+            liveData.send(download(url))
+        }
+        return liveData
+    }
+
+    private suspend fun download(url: String): Rlt<WeFile> {
+        return suspendCancellableCoroutine { continuation ->
+            addDownloadTask(url,
+                {
+                    if (continuation.isActive) {
+                        continuation.resume(Rlt.Success(it))
+                    }
+                },
+                {
+                    if (continuation.isActive) {
+                        continuation.resume(Rlt.Failed(it))
+                    }
+                })
+        }
+    }
+
+    private fun addDownloadTask(
+        url: String,
+        success: ((path: WeFile) -> Unit)? = null,
+        failed: ((error: IError) -> Unit)? = null,
+    ) {
+        val savePath = getCacheFilePathByUrl(url)
+        val file = storageService.file.createWeFile(savePath)
+        if (file.exists()) {
+            success?.invoke(file)
+            return
+        }
+
+        val taskId = url.md5()
+        val task = Task(taskId, url, savePath)
+        Log.d(
+            TAG_VIDEO_DOWNLOAD,
+            "addDownloadTask, start download, url:$url, task:$task"
+        )
+        task.listeners.add(object : TaskListener {
+
+            override fun onFinished(task: Task) {
+                super.onFinished(task)
+                success?.invoke(file)
+                Log.d(
+                    TAG_VIDEO_DOWNLOAD,
+                    "addDownloadTask, download success, task:$task"
+                )
+            }
+
+            override fun onError(task: Task, error: IError) {
+                super.onError(task, error)
+                failed?.invoke(error)
+                Log.e(
+                    TAG_VIDEO_DOWNLOAD,
+                    "addDownloadTask, task:$task, error:$error"
+                )
+            }
+
+        })
+        App.instance.downloadService.addTask(task)
+    }
+
+}

+ 20 - 0
module/image/src/main/res/drawable/image_moment_like.xml

@@ -0,0 +1,20 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="80dp"
+    android:height="80dp"
+    android:viewportWidth="80"
+    android:viewportHeight="80">
+  <path
+      android:pathData="M40,40m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"
+      android:fillColor="#ffffff"
+      android:fillAlpha="0.1"/>
+  <path
+      android:strokeWidth="1"
+      android:pathData="M40,40m-39.5,0a39.5,39.5 0,1 1,79 0a39.5,39.5 0,1 1,-79 0"
+      android:strokeAlpha="0.4"
+      android:fillColor="#00000000"
+      android:strokeColor="#ffffff"/>
+  <path
+      android:pathData="M37.119,28.445L37.14,28.371L37.156,28.295C37.441,26.925 38.664,25.961 40.055,26.001C41.775,26.051 43.071,27.595 42.817,29.308L42.299,32.801L41.917,35.375L44.502,35.082L50.135,34.443C51.877,34.245 53.305,35.826 52.922,37.551L50.678,47.664C50.332,49.221 48.976,50.341 47.39,50.387H34.093V38.871L37.119,28.445ZM34.093,52.387H47.416C49.932,52.328 52.084,50.558 52.631,48.097L54.874,37.984C55.554,34.923 53.021,32.103 49.91,32.456L46.334,32.861L44.277,33.094L44.581,31.047L44.795,29.602C45.225,26.707 43.033,24.086 40.112,24.002C37.751,23.934 35.68,25.571 35.198,27.888L32.605,36.82H31.925H29.172C27.642,36.82 26.358,37.971 26.19,39.491L25.012,50.168C24.882,51.352 25.809,52.387 27,52.387H31.925L32.093,52.387H34.093ZM29.172,38.82H31.925V50.387H27L28.178,39.71C28.234,39.203 28.662,38.82 29.172,38.82Z"
+      android:fillColor="#ffffff"
+      android:fillType="evenOdd"/>
+</vector>

+ 16 - 0
module/image/src/main/res/drawable/image_moment_like_bg.xml

@@ -0,0 +1,16 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="80dp"
+    android:height="80dp"
+    android:viewportWidth="80"
+    android:viewportHeight="80">
+  <path
+      android:pathData="M40,40m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"
+      android:fillColor="#ffffff"
+      android:fillAlpha="0.1"/>
+  <path
+      android:strokeWidth="1"
+      android:pathData="M40,40m-39.5,0a39.5,39.5 0,1 1,79 0a39.5,39.5 0,1 1,-79 0"
+      android:strokeAlpha="0.4"
+      android:fillColor="#00000000"
+      android:strokeColor="#ffffff"/>
+</vector>

+ 11 - 0
module/image/src/main/res/drawable/image_preview_report_ic.xml

@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="48dp"
+    android:height="48dp"
+    android:viewportWidth="48"
+    android:viewportHeight="48">
+
+    <path
+        android:fillColor="#ffffff"
+        android:fillType="evenOdd"
+        android:pathData="M27.967,11.076C26.009,7.943 21.445,7.943 19.487,11.076L7.663,29.994C5.582,33.324 7.976,37.644 11.903,37.644H35.55C39.478,37.644 41.872,33.324 39.791,29.994L27.967,11.076ZM21.183,12.136C22.358,10.256 25.096,10.256 26.271,12.136L38.095,31.054C39.343,33.052 37.907,35.644 35.55,35.644H11.903C9.547,35.644 8.111,33.052 9.359,31.054L21.183,12.136ZM22.585,18.746C22.585,18.115 23.096,17.604 23.727,17.604C24.358,17.604 24.869,18.115 24.869,18.746V27.124C24.869,27.755 24.358,28.266 23.727,28.266C23.096,28.266 22.585,27.755 22.585,27.124V18.746ZM22.585,30.932C22.585,30.301 23.096,29.789 23.727,29.789C24.358,29.789 24.869,30.301 24.869,30.932C24.869,31.563 24.358,32.074 23.727,32.074C23.096,32.074 22.585,31.563 22.585,30.932Z" />
+</vector>

+ 6 - 0
module/image/src/main/res/drawable/image_video_bottom_bar_bg.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners android:radius="16dp" />
+    <solid android:color="@color/color_26000000" />
+</shape>

+ 30 - 0
module/image/src/main/res/drawable/image_video_time_bar.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <!--定义滑动条的底色-->
+    <item android:id="@android:id/background">
+        <shape>
+            <corners android:radius="2dp" />
+            <solid android:color="@color/color_80FFFFFF" />
+        </shape>
+    </item>
+
+    <!-- 定义缓存颜色 -->
+    <item android:id="@android:id/secondaryProgress">
+        <clip>
+            <shape>
+                <corners android:radius="2dp" />
+                <solid android:color="@color/color_CCFFFFFF" />
+            </shape>
+        </clip>
+    </item>
+
+    <!--定义seekbar滑动条进度颜色-->
+    <item android:id="@android:id/progress">
+        <clip>
+            <shape>
+                <corners android:radius="2dp" />
+                <solid android:color="@color/color_FFA576FF" />
+            </shape>
+        </clip>
+    </item>
+</layer-list>

+ 11 - 0
module/image/src/main/res/drawable/image_video_time_bar_thumb.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="oval">
+    <size
+        android:width="9dp"
+        android:height="9dp" />
+    <solid android:color="@color/white" />
+    <stroke
+        android:width="1dp"
+        android:color="@color/white" />
+</shape>

+ 68 - 0
module/image/src/main/res/layout/activity_image_preview.xml

@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/black"
+    android:fitsSystemWindows="true">
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/top_cl"
+        android:layout_width="match_parent"
+        android:layout_height="44dp"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <ImageView
+            android:id="@+id/back"
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:layout_marginStart="16dp"
+            android:rotationY="@integer/locale_mirror_flip"
+            android:src="@drawable/commonui_back_white_48_ic"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toStartOf="@+id/title"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            tools:ignore="ContentDescription" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/title"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="41dp"
+            android:layout_marginEnd="41dp"
+            android:ellipsize="end"
+            android:gravity="center"
+            android:layoutDirection="locale"
+            android:maxLines="1"
+            android:textColor="@color/white"
+            android:textSize="18sp"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            tools:text="1/1" />
+
+        <androidx.appcompat.widget.AppCompatImageView
+            android:id="@+id/iv_report"
+            android:layout_width="44dp"
+            android:layout_height="44dp"
+            android:layout_marginEnd="6dp"
+            android:padding="10dp"
+            android:src="@drawable/image_preview_report_ic"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+    <androidx.viewpager.widget.ViewPager
+        android:id="@+id/vp_images"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/top_cl" />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 70 - 0
module/image/src/main/res/layout/activity_video_preview.xml

@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/black"
+    android:fitsSystemWindows="true">
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:id="@+id/top_cl"
+        android:layout_width="match_parent"
+        android:layout_height="44dp"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <ImageView
+            android:id="@+id/back"
+            android:layout_width="24dp"
+            android:layout_height="24dp"
+            android:rotationY="@integer/locale_mirror_flip"
+            android:layout_marginStart="16dp"
+            android:src="@drawable/commonui_back_white_48_ic"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toStartOf="@+id/title"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            tools:ignore="ContentDescription" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/title"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="41dp"
+            android:layout_marginEnd="41dp"
+            android:ellipsize="end"
+            android:gravity="center"
+            android:maxLines="1"
+            android:layoutDirection="locale"
+            android:textColor="@color/white"
+            android:textSize="18sp"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            tools:text="1/1" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/report_tv"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:paddingVertical="5dp"
+            android:paddingStart="5dp"
+            android:paddingEnd="16dp"
+            android:text="@string/common_report"
+            android:textColor="@color/color_AAAAAA"
+            android:textSize="16sp"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintTop_toTopOf="parent" />
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+    <androidx.viewpager.widget.ViewPager
+        android:id="@+id/vp_videos"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/top_cl" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 11 - 0
module/image/src/main/res/layout/fragment_image_preview.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <com.adealink.weparty.commonui.imageview.FusionPreviewImageView
+        android:id="@+id/iv_photo"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 32 - 0
module/image/src/main/res/layout/fragment_video_preview.xml

@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <com.adealink.frame.effect.video.WeVideoView
+        android:id="@+id/video_view"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:controller_layout_id="@layout/layout_video_preview_controller"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:surface_type="surface_view" />
+
+    <com.qmuiteam.qmui.widget.QMUILoadingView
+        android:id="@+id/v_video_loading"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:layout_marginTop="12dp"
+        android:color="@color/color_CCFFFFFF"
+        android:visibility="gone"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:qmui_loading_view_size="36dp" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 80 - 0
module/image/src/main/res/layout/layout_video_preview_controller.xml

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:parentTag="android.widget.FrameLayout"
+    tools:viewBindingIgnore="true">
+
+    <!-- ExoPlayer自定义操作控件, StyledPlayerControlView, exo_styled_player_control_view.xml -->
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_marginBottom="60dp">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:id="@id/exo_bottom_bar"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginHorizontal="8dp"
+            android:layout_marginBottom="6dp"
+            android:background="@drawable/image_video_bottom_bar_bg"
+            android:paddingHorizontal="8dp"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent">
+
+            <androidx.appcompat.widget.AppCompatImageView
+                android:id="@id/exo_play_pause"
+                android:layout_width="24dp"
+                android:layout_height="24dp"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent" />
+
+            <androidx.appcompat.widget.AppCompatTextView
+                android:id="@id/exo_position"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="4dp"
+                android:includeFontPadding="false"
+                android:textColor="@color/color_FFFFFF"
+                android:textSize="12sp"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintStart_toEndOf="@id/exo_play_pause"
+                app:layout_constraintTop_toTopOf="parent"
+                tools:minWidth="50dp" />
+
+            <com.adealink.weparty.image.view.VideoTimeBar
+                android:id="@id/exo_progress"
+                android:layout_width="0dp"
+                android:layout_height="0dp"
+                android:layout_marginHorizontal="4dp"
+                app:bar_height="8dp"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintEnd_toStartOf="@id/exo_duration"
+                app:layout_constraintStart_toEndOf="@id/exo_position"
+                app:layout_constraintTop_toTopOf="parent"
+                app:played_color="@color/color_FFA576FF"
+                app:scrubber_drawable="@drawable/image_video_time_bar_thumb"
+                app:unplayed_color="@color/color_80FFFFFF" />
+
+            <androidx.appcompat.widget.AppCompatTextView
+                android:id="@id/exo_duration"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:includeFontPadding="false"
+                android:textColor="@color/color_FFFFFF"
+                android:textSize="12sp"
+                app:layout_constraintBottom_toBottomOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                tools:minWidth="50dp" />
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+</merge>

+ 25 - 0
module/image/src/main/res/layout/layout_video_time_bar.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
+
+    <androidx.appcompat.widget.AppCompatSeekBar
+        android:id="@+id/seek_bar"
+        style="@style/Widget.AppCompat.ProgressBar.Horizontal"
+        android:layout_width="match_parent"
+        android:layout_height="10dp"
+        android:clipChildren="false"
+        android:max="100"
+        android:paddingStart="4.5dp"
+        android:paddingEnd="4.5dp"
+        android:maxHeight="4dp"
+        tools:progress="20"
+        android:progressDrawable="@drawable/image_video_time_bar"
+        android:thumb="@drawable/image_video_time_bar_thumb"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+</merge>

+ 7 - 0
module/image/src/main/res/values-ar/strings.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+   <string name="image_save_success">تم الحفظ بنجاح</string>
+   <string name="image_video_save_storage_permission_no_granted">لم يتم منح إذن التخزين، ولا يمكن حفظ الفيديو. يرجى التوجه إلى [إعدادات النظام] لتمكينه.</string>
+   <string name="image_save_failed">فشل الحفظ، يرجى المحاولة مرة أخرى</string>
+   <string name="image_save_storage_permission_no_granted">لم يتم الحصول على إذن التخزين، ولا يمكن حفظ الصورة. افتحه في [إعدادات النظام].</string>
+</resources>

+ 7 - 0
module/image/src/main/res/values-zh/strings.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+   <string name="image_save_success">Save successful</string>
+   <string name="image_video_save_storage_permission_no_granted">Storage permission not granted, unable to save video. Please enable it in [System Settings]</string>
+   <string name="image_save_failed">Save failed, please try again</string>
+   <string name="image_save_storage_permission_no_granted">存储权限未获取,无法保存图片。到 [系统设置] 里去打开吧</string>
+</resources>

+ 4 - 0
module/image/src/main/res/values/ids.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <item name="image_preview_save" type="id"/>
+</resources>

+ 7 - 0
module/image/src/main/res/values/strings.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+   <string name="image_save_success">保存成功</string>
+   <string name="image_save_storage_permission_no_granted">存储权限未获取,无法保存图片。到[系统设置]里去打开吧</string>
+   <string name="image_video_save_storage_permission_no_granted">存储权限未获取,无法保存视频。到[系统设置]里去打开吧</string>
+   <string name="image_save_failed">保存失败,请重试</string>
+</resources>

+ 16 - 0
module/image/src/test/java/com/adealink/weparty/image/ExampleUnitTest.kt

@@ -0,0 +1,16 @@
+package com.adealink.weparty.image
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+    @Test
+    fun addition_isCorrect() {
+        assertEquals(4, 2 + 2)
+    }
+}

+ 1 - 0
settings.gradle

@@ -101,6 +101,7 @@ include ':module:setting'
 include ':module:order'
 include ':module:wallet'
 include ':module:share'
+include ':module:image'
 
 //调试frame框架
 //include ':frame:network'