Android 基于LocalSocket实现本地多媒体流式播放

前言

目前针对一些有价值的片源,大家基本或多或少都会用到点加密技术,最简单的比如修改MP4文件头。到了要播放的时候,就需要找到一种比较方便的解密–播放方法,简单来说,就是在Android应用上实现对本地多媒体文件一边解密一边播放的功能。

思路

考虑到需求是一边加密一边解密,那么像是在本地先读文件流解密后再输出清流播放这类方法肯定是不行了,因为会产生无加密的中间文件,理论上应该是一边读取文件流,一边解密,然后将解密的文件流直接播放。
MediaPlayer能够播放的源说白了就2种:网络流媒体、本地文件。本地文件播放不能满足上述需求,网络流媒体播放显然是可以的。那么我们的目标就在于,如何将本地文件转换为流媒体让MediaPlayer进行播放?

流媒体播放简单原理

先让我们搞明白MediaPlayer是如何播放网络流媒体的。
尝试用模拟器播放一个网络流,再配合tcpdump+wireshark,可以看到MediaPlayer发送了一个Http Get请求给服务器端,而服务器端则返回了相应的Http Reponse及文件数据,这些数据就是被播放出来的音视频。
因此,这意味着我们可以在本地创建一个最简单的服务器实现,接收MediaPlayer的请求,读取本地多媒体文件,转为文件流进行解密,然后将数据流返回给MediaPlayer进行播放,即可达到边解密边播放的效果。

实现步骤

1.创建Local Socket服务端
我选择使用NIO来实现,首先监听端口,创建Selector。在新线程内等待客户端的通讯并做出响应。

    public MediaProxyServer(int port) {
        try {
            isStop = new AtomicBoolean(false);
            isNeedReponse = new AtomicBoolean(false);

            selector = Selector.open();
            ServerSocketChannel serverChannel = ServerSocketChannel.open();
            serverChannel.configureBlocking(false);
            serverChannel.socket().bind(new InetSocketAddress(port), 1024);
            serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

2.读取客户端请求
在这个简单示例里,我们只关心MediaPlayerGET请求。

    if (key.isReadable()) {
        String content = readFromClient(key);
        if (isHttpGetRequest(content)) {
            range = parseRange(content);
            isNeedReponse.set(true);
            key.channel().register(selector, SelectionKey.OP_WRITE);
        }
    } // read end

3.读取多媒体文件
之所以使用RandomAccessFile来打开文件,是为了便于读取文件流时指定开始/结束位置,实现多媒体播放时拖动进度条的效果。

    private void openFileChannel() throws FileNotFoundException {
        if (null == fileChannel) {
            File test = new File(Environment.getExternalStorageDirectory()
                    .getAbsolutePath()
                    + File.separator
                    + "Movies"
                    + File.separator + "test.mp4");
            fileChannel = new RandomAccessFile(test, "r").getChannel();
        }
    }

4.设置Reponse
Reponse只需要收到GET请求之后发送一次。每次发送时根据 GET请求中的Range头来确定要返回的数据范围。

    private void writeHttpResponse(SelectionKey key, long size, long range) throws IOException {
        if (!isNeedReponse.get()) {
            return;
        }
        isNeedReponse.set(false);
        StringBuffer sb = new StringBuffer();
        sb.append("HTTP/1.1 206 Partial Content\r\n");
        sb.append("Content-Type: video/mp4\r\n");
        sb.append("Connection: Keep-Alive\r\n");
        sb.append("Accept-Ranges: bytes\r\n");
        sb.append("Content-Length: " + size + "\r\n");
        sb.append("Content-Range: bytes " + range + "-" + (size - 1) + "/" + size + "\r\n");
        sb.append("\r\n");

        SocketChannel channel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
        buffer.clear();
        buffer.put(sb.toString().getBytes());
        buffer.flip();
        while (buffer.hasRemaining()) {
            channel.write(buffer);
        }
        buffer.clear();
    }

5.返回数据

    private void writeFileContent(SelectionKey key, FileChannel fileChannel, long range) throws IOException {
        SocketChannel sc = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
        buffer.clear();

        fileChannel.position(range);
        while (true) {
            if (buffer.hasRemaining()) {
                int bytes = fileChannel.read(buffer);
                if (-1 == bytes) {
                    buffer.flip();
                    while (buffer.hasRemaining()) {             
                        sc.write(buffer);
                    }
                    buffer.clear();

                    // close connect with client
                    fileChannel.close();
                    key.cancel();
                    sc.close();
                    break;
                }
            } else {
                buffer.flip();
                while (buffer.hasRemaining()) {
                    sc.write(buffer);
                }
                buffer.clear();
            }
        }
    }

各种坑

  1. 数据不完整
    遇到最大的问题是发送数据时,服务端发送量和客户端接受量不相同。最终发现是由于服务端发送数据时,ByteBuffer有时没有存满就开始对客户端进行发送,发送时ByteBuffer还有剩余数据时又开始接收文件流中新数据。
    解决方法就如上面的代码,使用了两个循环,一是确保读文件读到ByteBuffer满再发送,二是确保发送时彻底清空ByteBuffer再去读文件。

  2. 响应头设置
    MediaPlayer在播放中会有多次请求,每一次都请求一部分数据。刚开始每次播放都会在第二次请求时停止,最终发现是服务端响应头设置的问题。
    首先,Accept-Ranges: bytes是必须的。
    其次,Content-Range: bytes 1-2/3也是必须的,此处1-2是返回数据的范围,起始值就是从客户端请求头中解析到的Range,终止值为了简单可以设置为本地媒体文件的size-13就是本地文件的size了。

  3. 新请求瞬间播放卡顿
    播放器进行新的请求时,服务端要先返回应头,再返回数据,导致在虚拟机上出现瞬间的卡顿。这个问题目前没详细DEBUG,也不太清楚在真机上是否表现一样。
    我认为肯定是可以避免的。目前猜测将服务端按照HLS协议实现可能可以解决。服务端构造M3U8描述文件,定义每个分片的时间&数据,然后用RandomAccessFile读取返回。

参考资料

关于android视频边解密边播放
Android MediaPlayer与Http Proxy结合之基础篇
Android ServerSocket programming with jCIFS streaming files
Java NIO系列教程

源码地址

我的Github工程:LocalSocketMediaProxy

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值