Android 展示视频画面帧并进行视频剪切

1632821419972.gif

简单的视频剪切功能,支持每隔1s获取一张缩略图,移动seekbar视频会在区间里面重复播放
用到的第三方库
图片视频选择库

//图片选择库
    implementation 'com.github.HuanTanSheng:EasyPhotos:3.1.3'
    //图片加载库
    implementation("com.github.bumptech.glide:glide:4.11.0") {
        exclude group: "com.android.support"
    }
    annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
    implementation 'jp.wasabeef:glide-transformations:4.0.1'

FFmpeg库

//ffmpeg
    implementation 'com.github.microshow:RxFFmpeg:4.9.0'

EasyPhotos使用很简单

EasyPhotos.createAlbum(this, false, false, GlideEngine.getInstance())
                .setCount(1)//参数说明:最大可选数,默认1
                .onlyVideo()
                .setVideoMinSecond(11)
                .setFileProviderAuthority("com.example.demo.fileprovider")
                .start(REQUEST_VIDEO_BACK_CODE)

执行上面的代码,就能只选取手机中的视频了,而且选择的视频最小时长是11s。FileProvider需要自己配置,配置好了放在setFileProviderAuthority里面。GlideEngine代码如下:

public class GlideEngine implements ImageEngine {
    //单例
    private static GlideEngine instance = null;
    //单例模式,私有构造方法
    private GlideEngine() {
    }
    //获取单例
    public static GlideEngine getInstance() {
        if (null == instance) {
            synchronized (GlideEngine.class) {
                if (null == instance) {
                    instance = new GlideEngine();
                }
            }
        }
        return instance;
    }

    /**
     * 加载图片到ImageView
     *
     * @param context   上下文
     * @param uri 图片路径Uri
     * @param imageView 加载到的ImageView
     */
    //安卓10推荐uri,并且path的方式不再可用
    @Override
    public void loadPhoto(@NonNull Context context, @NonNull Uri uri, @NonNull ImageView imageView) {
        Glide.with(context).load(uri).transition(withCrossFade()).into(imageView);
    }

    /**
     * 加载gif动图图片到ImageView,gif动图不动
     *
     * @param context   上下文
     * @param gifUri   gif动图路径Uri
     * @param imageView 加载到的ImageView
     *                  <p>
     *                  备注:不支持动图显示的情况下可以不写
     */
    //安卓10推荐uri,并且path的方式不再可用
    @Override
    public void loadGifAsBitmap(@NonNull Context context, @NonNull Uri gifUri, @NonNull ImageView imageView) {
        Glide.with(context).asBitmap().load(gifUri).into(imageView);
    }

    /**
     * 加载gif动图到ImageView,gif动图动
     *
     * @param context   上下文
     * @param gifUri   gif动图路径Uri
     * @param imageView 加载动图的ImageView
     *                  <p>
     *                  备注:不支持动图显示的情况下可以不写
     */
    //安卓10推荐uri,并且path的方式不再可用
    @Override
    public void loadGif(@NonNull Context context, @NonNull Uri gifUri, @NonNull ImageView imageView) {
        Glide.with(context).asGif().load(gifUri).transition(withCrossFade()).into(imageView);
    }


    /**
     * 获取图片加载框架中的缓存Bitmap,不用拼图功能可以直接返回null
     *
     * @param context 上下文
     * @param uri    图片路径
     * @param width   图片宽度
     * @param height  图片高度
     * @return Bitmap
     * @throws Exception 异常直接抛出,EasyPhotos内部处理
     */
    //安卓10推荐uri,并且path的方式不再可用
    @Override
    public Bitmap getCacheBitmap(@NonNull Context context, @NonNull Uri uri, int width, int height) throws Exception {
        return Glide.with(context).asBitmap().load(uri).submit(width, height).get();
    }


}

然后在onActivityResult里面能收到我们选择的视频文件信息,我们拿到信息就能跳转到剪辑页面了。

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if(resultCode == RESULT_OK){
            if(requestCode == REQUEST_VIDEO_BACK_CODE){
                try {
                    val resultPhotos: ArrayList<Photo> = data!!.getParcelableArrayListExtra(EasyPhotos.RESULT_PHOTOS)!!
                    if(resultPhotos != null && resultPhotos.size >= 1){
                        var videoIntent = Intent(this, VideoCutActivity::class.java)
                        videoIntent.putExtra(VideoCutActivity.PATH, resultPhotos[0])
                        Log.d("yanjin", "path1 = ${resultPhotos[0]}")
                        startActivity(videoIntent)
                    }
                }catch (e:Exception){
                    e.printStackTrace()
                }
            }
        }
    }

先看activity布局代码

<LinearLayout 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:orientation="vertical"
    android:background="@color/black"
    tools:context=".video_cut.VideoCutActivity">
    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="4"
        android:gravity="center"
        android:layout_gravity="center">
        <android.widget.VideoView
            android:id="@+id/mVideoView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"/>
        <TextView
            android:id="@+id/mTvOk"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="截取"
            android:textSize="18sp"
            android:padding="@dimen/dp_10"
            android:layout_alignParentRight="true"
            android:layout_marginTop="15dp"
            android:layout_marginRight="15dp"
            android:textColor="@android:color/white"/>
    </RelativeLayout>

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:orientation="vertical">
        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true">
            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/mRecyclerView"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:paddingLeft="20dp"
                android:paddingRight="20dp"
                android:clipToPadding="false"
                android:layout_marginTop="10dp" />
            <com.example.demo.video_cut.view.RangeSeekBarView
                android:id="@+id/mRangeSeekBarView"
                android:layout_width="match_parent"
                android:layout_height="60dp"
                android:layout_marginLeft="20dp"
                android:layout_marginRight="20dp"/>
            <!--为两端的空间增加蒙层start-->
            <View
                android:layout_width="20dp"
                android:layout_height="60dp"
                android:background="@color/shadow_color"/>
            <View
                android:layout_width="20dp"
                android:layout_height="60dp"
                android:layout_alignParentRight="true"
                android:background="@color/shadow_color"/>
            <!--为两端的空间增加蒙层end-->
        </RelativeLayout>

    </RelativeLayout>
</LinearLayout>

然后是activity代码

class VideoCutActivity : AppCompatActivity() {
    private val mCacheRootPath by lazy {//设置保存每一帧图片保存的路径根目录
        Environment.getExternalStorageDirectory().path + File.separator + "videoCut" + File.separator
    }
    private var resouce: Photo? = null
    private var mp: MediaPlayer? = null
    private var mFrames = 0
    private val list = ArrayList<String>()
    private var mWidth = Utils.dp2px(35f)
    private var mHeight = Utils.dp2px(50f)
    private val mAdapter by lazy {
        FramesAdapter()
    }
    private var mMinTime:Long = 0*1000//默认从0s开始
    private var mMaxTime:Long = (MAX_TIME*1000).toLong()//默认从10s开始,单位是毫秒
    private var mFirstPosition = 0
    private var timer:Timer? = null
    private var timerTaskImp: TimerTaskImp? = null
    private var mCurrentSubscriber:MyRxFFmpegSubscriber? = null
    private var loadingDialog:AlertDialog? = null
    private val outDir by lazy {
        mCacheRootPath + Utils.getFileName(resouce?.name)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_video_cut)
        resouce = intent.getParcelableExtra<Photo>(PATH)
        mRangeSeekBarView?.post {
            mWidth = (mRangeSeekBarView?.width!!/MAX_TIME).toInt()
            mAdapter.setItemWidth(mWidth)//根据seekbar的长度除以我们最大帧数,就是我们每一帧需要的宽度
        }
        mTvOk?.setOnClickListener {
            trimVideo()
        }
        initFramesList()
        //播放视频,在视频播放准备完毕后再获取一共有多少帧
        initVideo()
    }

    private fun initFramesList() {
        mRecyclerView?.apply {
            layoutManager =
                LinearLayoutManager(this@VideoCutActivity, LinearLayoutManager.HORIZONTAL, false)
            adapter = mAdapter
        }
        mRecyclerView?.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                val layoutManager:LinearLayoutManager = recyclerView.layoutManager as LinearLayoutManager
                mFirstPosition = layoutManager.findFirstVisibleItemPosition()
                Log.d("yanjin", "$TAG mFirstPosition = $mFirstPosition")
                mMinTime = mRangeSeekBarView.selectedMinValue + (mFirstPosition * 1000)
                mMaxTime = mRangeSeekBarView.selectedMaxValue + (mFirstPosition * 1000)
                mRangeSeekBarView?.setStartEndTime(mMinTime,mMaxTime)
                mRangeSeekBarView?.invalidate()
                reStartVideo()
            }
        })
    }

    private fun initVideo() {
        mVideoView?.setVideoURI(resouce?.uri)
        mVideoView?.requestFocus()
        mVideoView?.start()
        startTimer()
        mVideoView?.setOnPreparedListener {
            mp = it//可以用来seekTo哦
            //设置seekbar
            initSeekBar()
            //解析视频画面帧
            analysisVideo()
        }
    }

    private fun startTimer() {
        if(timer == null){
            timer = Timer()
            timerTaskImp = TimerTaskImp(this)
            timer?.schedule(timerTaskImp,0,100)//数值越小,检查视频播放区间误差越小,但是危害就是性能越卡
        }
    }

    private fun initSeekBar() {
        mRangeSeekBarView?.selectedMinValue = mMinTime
        mRangeSeekBarView?.selectedMaxValue = mMaxTime
        mRangeSeekBarView?.setStartEndTime(mMinTime,mMaxTime)
        mRangeSeekBarView?.isNotifyWhileDragging = true
        mRangeSeekBarView?.setOnRangeSeekBarChangeListener(object :RangeSeekBarView.OnRangeSeekBarChangeListener{
            override fun onRangeSeekBarValuesChanged(
                bar: RangeSeekBarView?,
                minValue: Long,
                maxValue: Long,
                action: Int,
                isMin: Boolean,
                pressedThumb: RangeSeekBarView.Thumb?
            ) {
                Log.d("yanjin", "$TAG mMinTime = $minValue mMaxTime = $maxValue")
                mMinTime = minValue + (mFirstPosition * 1000)
                mMaxTime = maxValue + (mFirstPosition * 1000)
                mRangeSeekBarView?.setStartEndTime(mMinTime, mMaxTime)
                reStartVideo()
            }

        })
    }

    /**
     * 解析视频
     */
    private fun analysisVideo() {
        //先获取多少帧
        mFrames = mVideoView?.duration!! / 1000
        Log.d("yanjin", "$TAG mFrames = $mFrames")
        //设定这个大小的List,String代表图片路径,目前先定义一个这么大的集合,图片还没解析就先不放。
        if (!File(outDir).exists()) {
            File(outDir).mkdirs()
        }
        //平凑解析的命令
        gotoGetFrameAtTime(0)
    }

    /**
     * 获取画面帧
     */
    private fun gotoGetFrameAtTime(time: Int) {
        if (time >= mFrames) {
            return//如果超过了就返回,不要截取了
        }
        var outfile = outDir + File.separator + "${time}.jpg"
        val cmd =
            "ffmpeg -ss " + time + " -i " + resouce?.path + " -preset " + "ultrafast" + " -frames:v 1 -f image2 -s " + mWidth + "x" + mHeight + " -y " + outfile
        val commands = cmd.split(" ").toTypedArray()
        var nextTime = time + 1
        var subscribe: Flowable<RxFFmpegProgress> = RxFFmpegInvoke.getInstance()
            .runCommandRxJava(commands)
        mCurrentSubscriber = object : MyRxFFmpegSubscriber() {
            override fun onFinish() {
                Log.d("yanjin", "$TAG 完成 time = ${time}")
                if (time == 0) {
                    //第一次,那么全部图片用第一帧画面
                    for (x in 0 until mFrames) {
                        list.add(outfile)
                    }
                    mAdapter.updateList(list)
                } else {
                    //找到对应得条目修改
                    list.set(time, outfile)
                    mAdapter.updateItem(time, outfile)
                }
                gotoGetFrameAtTime(nextTime)
            }
        }
        subscribe.subscribe(mCurrentSubscriber)

    }

    /**
     * 重新把视频重头到位播一遍
     */
    private fun reStartVideo() {
        try {
            if(mp != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O){
                //VideoView.seekTo是有可能不在我们想要的那个时间播放的,因为我们那个时间可能不是关键帧,所以为了解决
                //我们用MediaPlayer.SEEK_CLOSEST,但是这个方法只能在6.0以上
                mp?.seekTo(mMinTime,MediaPlayer.SEEK_CLOSEST)
            }else{
                mVideoView.seekTo(mMinTime.toInt())
            }
        }catch (e:Exception){
            e.printStackTrace()
        }
    }

    /**
     * 每隔1s获取一下视频当前播放位置
     */
    fun getVideoProgress() {
        try {
            val currentPosition = mVideoView?.currentPosition
            Log.d("yanjin","currentPosition = $currentPosition mMaxTime = $mMaxTime")
            if(currentPosition!! >= mMaxTime){
                //如果当前时间已经超过我们选取的最大播放位置,那么我们从头播放。
                reStartVideo()
            }
        }catch (e:Exception){
            e.printStackTrace()
        }
    }

    private fun trimVideo() {
        loadingDialog = DialogUtiles.showLoading(this)
        if(mCurrentSubscriber != null && !mCurrentSubscriber?.isDisposed!!){
            mCurrentSubscriber?.dispose()
        }
        var outDir = mCacheRootPath
        if (!File(outDir).exists()) {
            File(outDir).mkdirs()
        }
        var outfile = mCacheRootPath +  "${Utils.getFileName(resouce?.name)}_trim.mp4"
        var start:Float = mMinTime/1000f
        var end:Float = mMaxTime/1000f
        var cmd = "ffmpeg -ss " + start + " -to " + end + " -accurate_seek" + " -i " + resouce?.path + " -to " + (end - start) + " -preset " + "superfast" + " -crf 23 -c:a copy -avoid_negative_ts 0 -y " + outfile;
        val commands = cmd.split(" ").toTypedArray()
        try {
            RxFFmpegInvoke.getInstance()
                .runCommandRxJava(commands)
                .subscribe(object : MyRxFFmpegSubscriber(){
                    override fun onFinish() {
                        if(loadingDialog != null && loadingDialog?.isShowing!!){
                            loadingDialog?.dismiss()
                        }
                        finish()
                        Log.d("yanjin", "$TAG 完成截取 outfile = ${outfile}")
                    }

                    override fun onProgress(progress: Int, progressTime: Long) {
                        Log.d("yanjin", "$TAG 截取进度 progress = ${progress}")
                    }
                })
        }catch (e:Exception){
            e.printStackTrace()
        }
    }

    override fun onDestroy() {

        super.onDestroy()
        RxFFmpegInvoke.getInstance().exit()
        if(mCurrentSubscriber != null && !mCurrentSubscriber?.isDisposed!!){
            mCurrentSubscriber?.dispose()
        }
        if(timer != null){
            timer?.cancel()
            timer = null
        }
        if(timerTaskImp != null){
            timerTaskImp?.cancel()
            timerTaskImp = null
        }
        ThreadPoolManager.getInstance().executeTask {
            //删除解析出来的图片
            val files: Array<File> = File(outDir).listFiles()
            for (i in files.indices) {
                if(files[i].exists()){
                    files[i].delete()
                }
            }
        }

    }

    companion object {
        public const val PATH = "path"
        public var TAG = VideoCutActivity::class.java.name
        public const val MAX_TIME = 10;//最大截取10s,最多展示10帧
    }
}

这里的主要想法就是,先初始化videoview播放视频,当视频播放准备完毕,再解析视频获取每一帧,因为视频准备完毕我们才能拿到视频一共有多长时间,时长除以1000就是多少帧,然后就能调用ffmpeg命令获取这一秒的画面帧了。
为什么这么做呢?是因为我刚开始是直接上来就用ffmpeg代码每隔1s截取一张画面帧,但是会发现,比如一个视频30s,截取出来的图片有32张,命令如下:

ffmpeg -y -i /storage/emulated/0/1/input.mp4 -f image2 -r 1 -q:v 10 -preset superfast /storage/emulated/0/1/%3d.jpg

为什么会多两张搞不懂,有知道的小朋友私信告诉我一下。

RangeSeekBarView的代码

public class RangeSeekBarView extends View {
    private static final String TAG = RangeSeekBarView.class.getSimpleName();
    public static final int INVALID_POINTER_ID = 255;
    public static final int ACTION_POINTER_INDEX_MASK = 0x0000ff00, ACTION_POINTER_INDEX_SHIFT = 8;
    private static final int TextPositionY = Utils.dp2px(7);
    private static final int paddingTop = Utils.dp2px(10);
    private int mActivePointerId = INVALID_POINTER_ID;

    private long mMinShootTime = 3*1000;//最小剪辑3s,默认
    private double absoluteMinValuePrim, absoluteMaxValuePrim;
    private double normalizedMinValue = 0d;//点坐标占总长度的比例值,范围从0-1
    private double normalizedMaxValue = 1d;//点坐标占总长度的比例值,范围从0-1
    private double normalizedMinValueTime = 0d;
    private double normalizedMaxValueTime = 1d;// normalized:规格化的--点坐标占总长度的比例值,范围从0-1
    private int mScaledTouchSlop;
    private Bitmap thumbImageLeft;
    private Bitmap thumbImageRight;
    private Bitmap thumbPressedImage;
    private Paint paint;
    private Paint rectPaint;
    private final Paint mVideoTrimTimePaintL = new Paint();
    private final Paint mVideoTrimTimePaintR = new Paint();
    private final Paint mShadow = new Paint();
    private int thumbWidth;
    private float thumbHalfWidth;
    private final float padding = 0;
    private long mStartPosition = 0;
    private long mEndPosition = 0;
    private float thumbPaddingTop = 0;
    private boolean isTouchDown;
    private float mDownMotionX;
    private boolean mIsDragging;
    private Thumb pressedThumb;
    private boolean isMin;
    private double min_width = 1;//最小裁剪距离
    private boolean notifyWhileDragging = false;
    private OnRangeSeekBarChangeListener mRangeSeekBarChangeListener;
    private int whiteColorRes = getContext().getResources().getColor(R.color.white);

    public enum Thumb {
        MIN, MAX
    }

    public RangeSeekBarView(Context context) {
        this(context,null);
    }

    public RangeSeekBarView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs,0);
    }

    public RangeSeekBarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.absoluteMinValuePrim = 0*1000;
        this.absoluteMaxValuePrim = VideoCutActivity.MAX_TIME *1000;
        setFocusable(true);
        setFocusableInTouchMode(true);
        init();
    }

    private void init() {
//        mScaledTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
        thumbImageLeft = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_video_thumb_handle);

        int width = thumbImageLeft.getWidth();
        int height = thumbImageLeft.getHeight();
        int newWidth = Utils.dp2px(12.5f);
        int newHeight = Utils.dp2px(50f);
        float scaleWidth = newWidth * 1.0f / width;
        float scaleHeight = newHeight * 1.0f / height;
        Matrix matrix = new Matrix();
        matrix.postScale(scaleWidth, scaleHeight);
        thumbImageLeft = Bitmap.createBitmap(thumbImageLeft, 0, 0, width, height, matrix, true);
        thumbImageRight = thumbImageLeft;
        thumbPressedImage = thumbImageLeft;
        thumbWidth = newWidth;
        thumbHalfWidth = thumbWidth / 2f;
        int shadowColor = getContext().getResources().getColor(R.color.shadow_color);
        mShadow.setAntiAlias(true);
        mShadow.setColor(shadowColor);

        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        rectPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        rectPaint.setStyle(Paint.Style.FILL);
        rectPaint.setColor(whiteColorRes);

        mVideoTrimTimePaintL.setStrokeWidth(3);
        mVideoTrimTimePaintL.setARGB(255, 51, 51, 51);
        mVideoTrimTimePaintL.setTextSize(28);
        mVideoTrimTimePaintL.setAntiAlias(true);
        mVideoTrimTimePaintL.setColor(whiteColorRes);
        mVideoTrimTimePaintL.setTextAlign(Paint.Align.LEFT);

        mVideoTrimTimePaintR.setStrokeWidth(3);
        mVideoTrimTimePaintR.setARGB(255, 51, 51, 51);
        mVideoTrimTimePaintR.setTextSize(28);
        mVideoTrimTimePaintR.setAntiAlias(true);
        mVideoTrimTimePaintR.setColor(whiteColorRes);
        mVideoTrimTimePaintR.setTextAlign(Paint.Align.RIGHT);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int width = 300;
        if (MeasureSpec.UNSPECIFIED != MeasureSpec.getMode(widthMeasureSpec)) {
            width = MeasureSpec.getSize(widthMeasureSpec);
        }
        int height = 120;
        if (MeasureSpec.UNSPECIFIED != MeasureSpec.getMode(heightMeasureSpec)) {
            height = MeasureSpec.getSize(heightMeasureSpec);
        }
        setMeasuredDimension(width, height);
    }

    @SuppressLint("DrawAllocation")
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        float bg_middle_left = 0;
        float bg_middle_right = getWidth() - getPaddingRight();
        float rangeL = normalizedToScreen(normalizedMinValue);
        float rangeR = normalizedToScreen(normalizedMaxValue);
        Rect leftRect = new Rect((int) bg_middle_left, getHeight(), (int) rangeL, 0);
        Rect rightRect = new Rect((int) rangeR, getHeight(), (int) bg_middle_right, 0);
        canvas.drawRect(leftRect, mShadow);
        canvas.drawRect(rightRect, mShadow);

        //上边框
        canvas.drawRect(rangeL + thumbHalfWidth, thumbPaddingTop + paddingTop, rangeR - thumbHalfWidth, thumbPaddingTop + Utils.dp2px(2) + paddingTop, rectPaint);

        //下边框
        canvas.drawRect(rangeL + thumbHalfWidth, getHeight() - Utils.dp2px(2), rangeR - thumbHalfWidth, getHeight(), rectPaint);

        //画左边thumb
        drawThumb(normalizedToScreen(normalizedMinValue), false, canvas, true);

        //画右thumb
        drawThumb(normalizedToScreen(normalizedMaxValue), false, canvas, false);

        //绘制文字
        drawVideoTrimTimeText(canvas);
    }

    private void drawThumb(float screenCoord, boolean pressed, Canvas canvas, boolean isLeft) {
        canvas.drawBitmap(pressed ? thumbPressedImage : (isLeft ? thumbImageLeft : thumbImageRight), screenCoord - (isLeft ? 0 : thumbWidth), paddingTop, paint);
    }

    private void drawVideoTrimTimeText(Canvas canvas) {
        String leftThumbsTime = Utils.convertSecondsToTime(mStartPosition);
        String rightThumbsTime = Utils.convertSecondsToTime(mEndPosition);
        canvas.drawText(leftThumbsTime, normalizedToScreen(normalizedMinValue), TextPositionY, mVideoTrimTimePaintL);
        canvas.drawText(rightThumbsTime, normalizedToScreen(normalizedMaxValue), TextPositionY, mVideoTrimTimePaintR);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (isTouchDown) {
            return super.onTouchEvent(event);
        }
        if (event.getPointerCount() > 1) {
            return super.onTouchEvent(event);
        }

        if (!isEnabled()) return false;
        if (absoluteMaxValuePrim <= mMinShootTime) {
            return super.onTouchEvent(event);
        }
        int pointerIndex;// 记录点击点的index
        final int action = event.getAction();
        switch (action & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN:
                //记住最后一个手指点击屏幕的点的坐标x,mDownMotionX
                mActivePointerId = event.getPointerId(event.getPointerCount() - 1);
                pointerIndex = event.findPointerIndex(mActivePointerId);
                mDownMotionX = event.getX(pointerIndex);
                // 判断touch到的是最大值thumb还是最小值thumb
                pressedThumb = evalPressedThumb(mDownMotionX);
                if (pressedThumb == null) return super.onTouchEvent(event);
                setPressed(true);// 设置该控件被按下了
                onStartTrackingTouch();// 置mIsDragging为true,开始追踪touch事件
                trackTouchEvent(event);
                attemptClaimDrag();
                if (mRangeSeekBarChangeListener != null) {
                    mRangeSeekBarChangeListener.onRangeSeekBarValuesChanged(this, getSelectedMinValue(), getSelectedMaxValue(), MotionEvent.ACTION_DOWN, isMin, pressedThumb);
                }
                break;
            case MotionEvent.ACTION_MOVE:
                if (pressedThumb != null) {
                    if (mIsDragging) {
                        trackTouchEvent(event);
                    } else {
                        // Scroll to follow the motion event
                        pointerIndex = event.findPointerIndex(mActivePointerId);
                        final float x = event.getX(pointerIndex);// 手指在控件上点的X坐标
                        // 手指没有点在最大最小值上,并且在控件上有滑动事件
                        if (Math.abs(x - mDownMotionX) > mScaledTouchSlop) {
                            setPressed(true);
                            Log.e(TAG, "没有拖住最大最小值");// 一直不会执行?
                            invalidate();
                            onStartTrackingTouch();
                            trackTouchEvent(event);
                            attemptClaimDrag();
                        }
                    }
                    if (notifyWhileDragging && mRangeSeekBarChangeListener != null) {
                        mRangeSeekBarChangeListener.onRangeSeekBarValuesChanged(this, getSelectedMinValue(), getSelectedMaxValue(), MotionEvent.ACTION_MOVE, isMin, pressedThumb);
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                if (mIsDragging) {
                    trackTouchEvent(event);
                    onStopTrackingTouch();
                    setPressed(false);
                } else {
                    onStartTrackingTouch();
                    trackTouchEvent(event);
                    onStopTrackingTouch();
                }

                invalidate();
                if (mRangeSeekBarChangeListener != null) {
                    mRangeSeekBarChangeListener.onRangeSeekBarValuesChanged(this, getSelectedMinValue(), getSelectedMaxValue(), MotionEvent.ACTION_UP, isMin,
                            pressedThumb);
                }
                pressedThumb = null;// 手指抬起,则置被touch到的thumb为空
                break;
            case MotionEvent.ACTION_POINTER_DOWN:
                final int index = event.getPointerCount() - 1;
                // final int index = ev.getActionIndex();
                mDownMotionX = event.getX(index);
                mActivePointerId = event.getPointerId(index);
                invalidate();
                break;
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(event);
                invalidate();
                break;
            case MotionEvent.ACTION_CANCEL:
                if (mIsDragging) {
                    onStopTrackingTouch();
                    setPressed(false);
                }
                invalidate(); // see above explanation
                break;
            default:
                break;
        }
        return true;
    }

    private void onSecondaryPointerUp(MotionEvent ev) {
        final int pointerIndex = (ev.getAction() & ACTION_POINTER_INDEX_MASK) >> ACTION_POINTER_INDEX_SHIFT;
        final int pointerId = ev.getPointerId(pointerIndex);
        if (pointerId == mActivePointerId) {
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            mDownMotionX = ev.getX(newPointerIndex);
            mActivePointerId = ev.getPointerId(newPointerIndex);
        }
    }

    private void trackTouchEvent(MotionEvent event) {
        if (event.getPointerCount() > 1) return;
        Log.e(TAG, "trackTouchEvent: " + event.getAction() + " x: " + event.getX());
        final int pointerIndex = event.findPointerIndex(mActivePointerId);// 得到按下点的index
        float x = 0;
        try {
            x = event.getX(pointerIndex);
        } catch (Exception e) {
            return;
        }
        if (Thumb.MIN.equals(pressedThumb)) {
            // screenToNormalized(x)-->得到规格化的0-1的值
            setNormalizedMinValue(screenToNormalized(x, 0));
        } else if (Thumb.MAX.equals(pressedThumb)) {
            setNormalizedMaxValue(screenToNormalized(x, 1));
        }
    }

    private double screenToNormalized(float screenCoord, int position) {
        int width = getWidth();
        if (width <= 2 * padding) {
            // prevent division by zero, simply return 0.
            return 0d;
        } else {
            isMin = false;
            double current_width = screenCoord;
            float rangeL = normalizedToScreen(normalizedMinValue);
            float rangeR = normalizedToScreen(normalizedMaxValue);
            double min = mMinShootTime / (absoluteMaxValuePrim - absoluteMinValuePrim) * (width - thumbWidth * 2);

            if (absoluteMaxValuePrim > 5 * 60 * 1000) {//大于5分钟的精确小数四位
                DecimalFormat df = new DecimalFormat("0.0000");
                min_width = Double.parseDouble(df.format(min));
            } else {
                min_width = Math.round(min + 0.5d);
            }
            if (position == 0) {
                if (isInThumbRangeLeft(screenCoord, normalizedMinValue, 0.5)) {
                    return normalizedMinValue;
                }

                float rightPosition = (getWidth() - rangeR) >= 0 ? (getWidth() - rangeR) : 0;
                double left_length = getValueLength() - (rightPosition + min_width);

                if (current_width > rangeL) {
                    current_width = rangeL + (current_width - rangeL);
                } else if (current_width <= rangeL) {
                    current_width = rangeL - (rangeL - current_width);
                }

                if (current_width > left_length) {
                    isMin = true;
                    current_width = left_length;
                }

                if (current_width < thumbWidth * 2 / 3) {
                    current_width = 0;
                }

                double resultTime = (current_width - padding) / (width - 2 * thumbWidth);
                normalizedMinValueTime = Math.min(1d, Math.max(0d, resultTime));
                double result = (current_width - padding) / (width - 2 * padding);
                return Math.min(1d, Math.max(0d, result));// 保证该该值为0-1之间,但是什么时候这个判断有用呢?
            } else {
                if (isInThumbRange(screenCoord, normalizedMaxValue, 0.5)) {
                    return normalizedMaxValue;
                }

                double right_length = getValueLength() - (rangeL + min_width);
                if (current_width > rangeR) {
                    current_width = rangeR + (current_width - rangeR);
                } else if (current_width <= rangeR) {
                    current_width = rangeR - (rangeR - current_width);
                }

                double paddingRight = getWidth() - current_width;

                if (paddingRight > right_length) {
                    isMin = true;
                    current_width = getWidth() - right_length;
                    paddingRight = right_length;
                }

                if (paddingRight < thumbWidth * 2 / 3) {
                    current_width = getWidth();
                    paddingRight = 0;
                }

                double resultTime = (paddingRight - padding) / (width - 2 * thumbWidth);
                resultTime = 1 - resultTime;
                normalizedMaxValueTime = Math.min(1d, Math.max(0d, resultTime));
                double result = (current_width - padding) / (width - 2 * padding);
                return Math.min(1d, Math.max(0d, result));// 保证该该值为0-1之间,但是什么时候这个判断有用呢?
            }
        }
    }

    private int getValueLength() {
        return (getWidth() - 2 * thumbWidth);
    }

    /**
     * 计算位于哪个Thumb内
     *
     * @param touchX touchX
     * @return 被touch的是空还是最大值或最小值
     */
    private Thumb evalPressedThumb(float touchX) {
        Thumb result = null;
        boolean minThumbPressed = isInThumbRange(touchX, normalizedMinValue, 2);// 触摸点是否在最小值图片范围内
        boolean maxThumbPressed = isInThumbRange(touchX, normalizedMaxValue, 2);
        if (minThumbPressed && maxThumbPressed) {
            // 如果两个thumbs重叠在一起,无法判断拖动哪个,做以下处理
            // 触摸点在屏幕右侧,则判断为touch到了最小值thumb,反之判断为touch到了最大值thumb
            result = (touchX / getWidth() > 0.5f) ? Thumb.MIN : Thumb.MAX;
        } else if (minThumbPressed) {
            result = Thumb.MIN;
        } else if (maxThumbPressed) {
            result = Thumb.MAX;
        }
        return result;
    }

    private boolean isInThumbRange(float touchX, double normalizedThumbValue, double scale) {
        // 当前触摸点X坐标-最小值图片中心点在屏幕的X坐标之差<=最小点图片的宽度的一般
        // 即判断触摸点是否在以最小值图片中心为原点,宽度一半为半径的圆内。
        return Math.abs(touchX - normalizedToScreen(normalizedThumbValue)) <= thumbHalfWidth * scale;
    }

    private boolean isInThumbRangeLeft(float touchX, double normalizedThumbValue, double scale) {
        // 当前触摸点X坐标-最小值图片中心点在屏幕的X坐标之差<=最小点图片的宽度的一般
        // 即判断触摸点是否在以最小值图片中心为原点,宽度一半为半径的圆内。
        return Math.abs(touchX - normalizedToScreen(normalizedThumbValue) - thumbWidth) <= thumbHalfWidth * scale;
    }

    /**
     * 试图告诉父view不要拦截子控件的drag
     */
    private void attemptClaimDrag() {
        if (getParent() != null) {
            getParent().requestDisallowInterceptTouchEvent(true);
        }
    }

    void onStartTrackingTouch() {
        mIsDragging = true;
    }

    void onStopTrackingTouch() {
        mIsDragging = false;
    }

    public void setMinShootTime(long min_cut_time) {
        this.mMinShootTime = min_cut_time;
    }

    private float normalizedToScreen(double normalizedCoord) {
        return (float) (getPaddingLeft() + normalizedCoord * (getWidth() - getPaddingLeft() - getPaddingRight()));
    }

    private double valueToNormalized(long value) {
        if (0 == absoluteMaxValuePrim - absoluteMinValuePrim) {
            return 0d;
        }
        return (value - absoluteMinValuePrim) / (absoluteMaxValuePrim - absoluteMinValuePrim);
    }

    public void setStartEndTime(long start, long end) {
        this.mStartPosition = start / 1000;
        this.mEndPosition = end / 1000;
    }

    public void setSelectedMinValue(long value) {
        if (0 == (absoluteMaxValuePrim - absoluteMinValuePrim)) {
            setNormalizedMinValue(0d);
        } else {
            setNormalizedMinValue(valueToNormalized(value));
        }
    }

    public void setSelectedMaxValue(long value) {
        if (0 == (absoluteMaxValuePrim - absoluteMinValuePrim)) {
            setNormalizedMaxValue(1d);
        } else {
            setNormalizedMaxValue(valueToNormalized(value));
        }
    }

    public void setNormalizedMinValue(double value) {
        normalizedMinValue = Math.max(0d, Math.min(1d, Math.min(value, normalizedMaxValue)));
        invalidate();// 重新绘制此view
    }

    public void setNormalizedMaxValue(double value) {
        normalizedMaxValue = Math.max(0d, Math.min(1d, Math.max(value, normalizedMinValue)));
        invalidate();// 重新绘制此view
    }

    public long getSelectedMinValue() {
        return normalizedToValue(normalizedMinValueTime);
    }

    public long getSelectedMaxValue() {
        return normalizedToValue(normalizedMaxValueTime);
    }

    private long normalizedToValue(double normalized) {
        return (long) (absoluteMinValuePrim + normalized * (absoluteMaxValuePrim - absoluteMinValuePrim));
    }

    /**
     * 供外部activity调用,控制是都在拖动的时候打印log信息,默认是false不打印
     */
    public boolean isNotifyWhileDragging() {
        return notifyWhileDragging;
    }

    public void setNotifyWhileDragging(boolean flag) {
        this.notifyWhileDragging = flag;
    }

    public void setTouchDown(boolean touchDown) {
        isTouchDown = touchDown;
    }

    @Override
    protected Parcelable onSaveInstanceState() {
        final Bundle bundle = new Bundle();
        bundle.putParcelable("SUPER", super.onSaveInstanceState());
        bundle.putDouble("MIN", normalizedMinValue);
        bundle.putDouble("MAX", normalizedMaxValue);
        bundle.putDouble("MIN_TIME", normalizedMinValueTime);
        bundle.putDouble("MAX_TIME", normalizedMaxValueTime);
        return bundle;
    }

    @Override
    protected void onRestoreInstanceState(Parcelable parcel) {
        final Bundle bundle = (Bundle) parcel;
        super.onRestoreInstanceState(bundle.getParcelable("SUPER"));
        normalizedMinValue = bundle.getDouble("MIN");
        normalizedMaxValue = bundle.getDouble("MAX");
        normalizedMinValueTime = bundle.getDouble("MIN_TIME");
        normalizedMaxValueTime = bundle.getDouble("MAX_TIME");
    }

    public interface OnRangeSeekBarChangeListener {
        void onRangeSeekBarValuesChanged(RangeSeekBarView bar, long minValue, long maxValue, int action, boolean isMin, Thumb pressedThumb);
    }

    public void setOnRangeSeekBarChangeListener(OnRangeSeekBarChangeListener listener) {
        this.mRangeSeekBarChangeListener = listener;
    }
}

用到的图片


ic_video_thumb_handle.png

用到的颜色

<color name="shadow_color">#7F000000</color>

DialogUtiles代码如下:

public class DialogUtiles {
    public static AlertDialog showLoading(Activity context){
        try {
            if (context == null || context.isFinishing()) {
                return null;
            }
            final AlertDialog dlg = new AlertDialog.Builder(context, R.style.dialog_no_sullscreen_no_title).show();
            dlg.setCanceledOnTouchOutside(false);
            Window window = dlg.getWindow();
            window.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM);
            window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
            window.setContentView(R.layout.loading_dialog);
            WindowManager.LayoutParams lp = dlg.getWindow().getAttributes();
            //这里设置居中
            lp.gravity = Gravity.CENTER;
            window.setAttributes(lp);
            return dlg;
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }
}

TimerTaskImp

public class TimerTaskImp extends TimerTask {
    private WeakReference<VideoCutActivity> weakReference;
    public TimerTaskImp(VideoCutActivity activity){
        weakReference = new WeakReference<>(activity);
    }
    @Override
    public void run() {
        if(weakReference != null && weakReference.get() != null){
            weakReference.get().getVideoProgress();
        }
    }
}

Utils代码

public class Utils {
    public static int dp2px(float dpValue){
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,dpValue, App.getInstance().getResources().getDisplayMetrics());
    }
    /**
     * second to HH:MM:ss
     * @param seconds
     * @return
     */
    public static String convertSecondsToTime(long seconds) {
        String timeStr = null;
        int hour = 0;
        int minute = 0;
        int second = 0;
        if (seconds <= 0)
            return "00:00";
        else {
            minute = (int)seconds / 60;
            if (minute < 60) {
                second = (int)seconds % 60;
                timeStr = unitFormat(minute) + ":" + unitFormat(second);
            } else {
                hour = minute / 60;
                if (hour > 99)
                    return "99:59:59";
                minute = minute % 60;
                second = (int)(seconds - hour * 3600 - minute * 60);
                timeStr = unitFormat(hour) + ":" + unitFormat(minute) + ":" + unitFormat(second);
            }
        }
        return timeStr;
    }
    private static String unitFormat(int i) {
        String retStr = null;
        if (i >= 0 && i < 10)
            retStr = "0" + Integer.toString(i);
        else
            retStr = "" + i;
        return retStr;
    }

    public static String getFileName(String sting){
        String[] split = sting.split("\\.");
        if(split.length > 0){
            return split[0];
        }
        return sting;
    }
}

ThreadPoolManager代码

public class ThreadPoolManager {
    private ExecutorService service;
    private final SerialExecutor serialExecutor;
    private static final ThreadPoolManager manager = new ThreadPoolManager();

    private ThreadPoolManager() {
        int num = Runtime.getRuntime().availableProcessors();
        service = Executors.newFixedThreadPool(num);
        serialExecutor = new SerialExecutor(service);
    }

    public static ThreadPoolManager getInstance() {
        return manager;
    }

    /**
     * 顺序执行一个任务
     *
     * @param runnable              任务
     * @param isSequentialExecution 是否顺序执行
     */
    public void executeTask(Runnable runnable, boolean isSequentialExecution) {
        if (isSequentialExecution) {
            serialExecutor.execute(runnable);
        } else {
            service.execute(runnable);
        }
    }

    /**
     * 执行一个任务
     *
     * @param runnable
     */
    public void executeTask(Runnable runnable) {
        executeTask(runnable, false);
    }

    public void executeTasks(ArrayList<Runnable> list, boolean isSequentialExecution) {
        for (Runnable runnable : list) {
            executeTask(runnable, isSequentialExecution);
        }
    }

    /**
     * 执行AsyncTask
     *
     * @param task
     */
    @SuppressLint("NewApi")
    @SuppressWarnings("unchecked")
    public void execAsync(AsyncTask<?, ?, ?> task) {
        if (Build.VERSION.SDK_INT >= 11) {
            //task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
            task.executeOnExecutor(Executors.newCachedThreadPool());
        } else {
            task.execute();
        }

    }

}

SerialExecutor代码

public class SerialExecutor implements Executor {
    final Queue<Runnable> tasks = new ArrayDeque<Runnable>();
    final Executor executor;
    Runnable active;

    SerialExecutor(Executor executor) {
        this.executor = executor;
    }

    @Override
    public synchronized void execute(final Runnable r) {
        tasks.offer(new Runnable() {
            @Override
            public void run() {
                try {
                    r.run();
                } finally {
                    scheduleNext();
                }
            }
        });
        if (active == null) {
            scheduleNext();
        }
    }

    protected synchronized void scheduleNext() {
        if ((active = tasks.poll()) != null) {
            executor.execute(active);
        }
    }
}

补充一下,看来有同学和我一样有写这个功能的需求,把漏掉的类加上:
首先就是FramesAdapter,他就是将解析出来的帧水平放好,只是我们这里的图片宽度要做动态修改,初始状态下,我们的裁剪框装10张图片。所以拿裁剪框的宽度除以10,就是每一张图片的宽度

public class FramesAdapter extends RecyclerView.Adapter<FramesAdapter.ViewHolder> {
    private List<String> list = new ArrayList<>();
    private int mWidth = Utils.dp2px(35f);

    public FramesAdapter(){

    }
    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.frames_item_layout,parent,false));
    }

    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Glide.with(holder.mIv.getContext()).load(list.get(position)).into(holder.mIv);
        ViewGroup.LayoutParams layoutParams = holder.mIv.getLayoutParams();
        layoutParams.width = mWidth;
        holder.mIv.setLayoutParams(layoutParams);
    }

    @Override
    public int getItemCount() {
        return list.size();
    }

    public void updateList(@NotNull List<String> list) {
        this.list.clear();
        this.list.addAll(list);
        notifyDataSetChanged();
    }

    public void updateItem(int position, @NotNull String outfile) {
        this.list.set(position,outfile);
        notifyItemChanged(position);
    }

    public void setItemWidth(int mWidth) {
        this.mWidth = mWidth;
    }

    public class ViewHolder extends RecyclerView.ViewHolder{

        private final ImageView mIv;

        public ViewHolder(@NonNull View itemView) {
            super(itemView);
            mIv = itemView.findViewById(R.id.mIv);
        }
    }
}

item的布局frames_item_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">
    <ImageView
        android:id="@+id/mIv"
        android:layout_width="35dp"
        android:layout_height="50dp"
        android:layout_centerInParent="true"/>
</RelativeLayout>

还有个MyRxFFmpegSubscriber其实就是嫌弃这个RxFfmpeg的回调方法太多,继承了他,这样就不用全部方法都要写一遍了。

public class MyRxFFmpegSubscriber extends RxFFmpegSubscriber {
    @Override
    public void onFinish() {

    }

    @Override
    public void onProgress(int progress, long progressTime) {

    }

    @Override
    public void onCancel() {

    }

    @Override
    public void onError(String message) {

    }
}

最后说一句,这个案例用的ffmpeg解析特别的慢,包体也大,用于项目肯定不行,如果要用ffmpeg的话找找其他的开源,这里吐槽一句ffmpeg解析完后退出界面,一定要记得删除图片哦,当然还可以用Android自带的MediaCode,他解析不需要存图片在文件夹里面,拿着就能用。主页有相应文章介绍。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容