一起看电影开发日志

知道有一个app叫做微光,可以多人一起在线看电视电影听音乐等,实质上类似于直播形式的点播应用。虽然很好用,但是其存在两个问题:一是所能看的视频都由用户申请后才可能添加,资源很有限,就是说只能是看上面有什么想看的看什么,而不能想看什么看什么;二是由于其资源由平台提供,存在很大的版权问题。
为了能够将自己本地有的视频资源在多终端同步观看,计划开发一个app。

整体规划(草稿,随日志更新)

整个项目将分为客户端服务端两部分。
初期简化考虑,客户端仅支持iOS,需要具备创建房间、选择本地文件、推流、记录播放进度、进入房间、拉流等功能。服务端仅支持linux,需具备直播服务器、房间信息管理等功能。
为了学习新技术及便于移植,计划iOS开发使用Flutter,服务端开发使用Go。
App流程应为:

  1. 创建房间与服务端交互,获得一对token,分别为房主身份和房客身份,同时服务端创建唯一直播服务器地址;
    【低优先级。初步使用固定地址,之后可使用多端口或多路径,再之后考虑CDN或P2P】
  2. 选择本地视频,开始推流,推流同时记录播放进度;
  3. 房客使用token进入房间,拉流播放;
  4. 可利用记录的播放进度进行重新推流以进行同步,也可考虑支持手动调节进度。

日志记录

2020-03-14

  • 测试本地macOS使用ffmpeg推流rtmp,服务端使用找到的一个golang编写的直播服务器golive,手机端使用VLC播放体验。

    1. 本地推流命令:
      % brew install ffmpeg
      % ffmpeg -re -i <本地文件路径> -c copy -f flv <rtmp服务器地址>
    2. 使用livego:
      % git clone https://github.com/gwuhaolin/livego.git
      % go build
      % ./livego

2020-03-16

  • 要支持将手机本地视频推流到服务器,肯定先得支持读取本地文件,找到Flutter pub包path_provider

    1. 使用path_provider,在Flutter项目的pubspec.yamldependencies下加入path_provider: ^1.6.5
      import 'dart:io';
      import 'dart:async';
      import 'package:path_provider/path_provider.dart';
         
      //获取应用文档目录
      String dir = (await getApplicationDocumentsDirectory()).path;
      //创建文件
      File file = new File('$dir/counter.txt');
      //文档读取
      String content = await file.readAsString();
      //文档写入
      await file.writeAsString('$_counter');
    2. 要想让App的文档目录在iOS的“文件”中可见,需要在Info.plist文件中添加键值对:
      <key>UIFileSharingEnabled</key>
      <true/>
      <key>LSSupportsOpeningDocumentsInPlace</key>
      <true/>
      这一步可在Flutter的VS Code环境中ios/Runner目录中修改,也可打开Xcode修改。
      % open ios/Runner.xcworkspace
  • 要实现iOS端推流,需要在iOS端集成ffmpeg。找到Flutter pub包flutter_ffmpeg

    1. 使用flutter_ffmpeg,在Flutter项目的pubspec.yamldependencies下加入flutter_ffmpeg: ^0.2.10

    2. iOS使用flutter_ffmpeg,还需要修改Podfile# Plugin Pods部分如下:

      symlink = File.join('.symlinks', 'plugins', name)
      File.symlink(path, symlink)
      if name == 'flutter_ffmpeg'
        pod name+'/<package name>', :path => File.join(symlink, 'ios')
      else
        pod name, :path => File.join(symlink, 'ios')
      end

      若找不到Podfile,尝试执行run后会生成

    3. flutter_ffmpeg测试推流成功:

      import 'package:flutter_ffmpeg/flutter_ffmpeg.dart';
      
      final FlutterFFmpeg _flutterFFmpeg = new FlutterFFmpeg();
      String dir = (await getApplicationDocumentsDirectory()).path;
      _flutterFFmpeg
        .execute(
          "-re -i $dir/<视频文件名> -c copy -f flv <直播服务器地址>")
        .then((rc) => print("FFmpeg process exited with rc $rc"));
  • 在安装这些包,尝试运行时常遇到卡在Pod Installing的状态,可尝试单独执行下面命令:

    % pod install --verbose 
  • 视频播放部分,考虑使用Flutter版的ijkplayer中……

2020-03-17

  • 经过一晚的测试,发现flutter_ijkplayer主要存在两个问题:
    1. 加入该包后会出现闪退,经调试发现,可能的原因是其本身也是基于ffmpeg的,所以会与已经引入的flutter_ffmpeg相冲突,当两个包都被引入,只要调用ffmpeg就会闪退;
    2. 后暂时去掉flutter_ffmpeg包,仅引入此包,发现无论播放本地mp4还是播放rtmp地址视频,均是有声音无图像(黑屏)。初步查询问题时根据网上说法以为可能是对mp4格式支持问题(但同时播放采用flv的rtmp流也有这问题其实可以排除这种可能性),尝试修改编译选项自编译flutter_ijkplayer,发现本身其默认编译选项就是很全的,而且替换为自编译的包之后,依然同样问题。后阅读flutter_ijkplayer的TODOList,发现其中写道:
      • iOS 部分视频无法显示图像的问题: 可能很长时间内都无法解决
  • 本身该包的使用方法还是记录一下吧:
    1. 在Flutter项目的pubspec.yamldependencies下加入flutter_ijkplayer: ^0.3.5+1
      另外从本地和从Git引入包的方式为:
      flutter_ijkplayer: 
        path: ./flutter_ijkplayer
      
      flutter_ijkplayer:
        git:
          url: https://github.com/CaiJingLong/flutter_ijkplayer.git
          ref: master
    2. 使用代码如下:
      import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';
      
      String dir = (await getApplicationDocumentsDirectory()).path;
      await controller.setDataSource(DataSource.file(File('$dir/BCSS05E01.mp4')),autoPlay: true);
      // await controller.setNetworkDataSource('<rtmp服务器地址>',autoPlay:true);
  • 开始尝试其他Flutter下的播放器:分别尝试了flutter_vlc_player【本身体积比较大,且编译无法通过,会出现precompile issue,搁置】和video_player【编译正常,但使用中控件总是不显示,判断controller.value.initialized总为false
  • 又尝试了一遍flutter_ijkplayer,使用线上版本,发现可以播放rtmp地址视频了!但是似乎有些不稳定。

2020-03-20

  • 之前尝试flutter_ijkplayer成功,但是依然存在和flutter_ffmpeg冲突的问题。这是因为两者最底层都用到了ffmpeg,从而导致有duplicated symbol。另外,flutter_ijkplayer基于的ffmpeg版本本身是3.4的(可更改支持到4.0),而flutter_ffmpeg基于的mobile_ffmpeg是基于ffmpeg4.3的,这两者版本也不一致。
  • 经过两天多的痛苦尝试,终于有了进展:
    • 先将flutter_ijkplayer的使用方式更改为本地包的使用方式:

      1. flutter_ijkplayer包下到本地,放置在flutter项目目录中;
      2. 获取ijkplayer源码进行本地编译,修改init-ios.sh
        IJK_FFMPEG_COMMIT=ff4.0--ijk0.8.25--20200221--001
        修改config/module.sh
        #下面注释掉是因为升级ffmpeg到4.0版
        #export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-ffserver"
        #export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-vda"
        
        #下面添加muxer是因为后面步骤需要支持flv的output format
        export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --enable-muxer=flv"
        
        #下面两个不确定是否要加(源于升级到4.0版ffmpeg)
        export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-protocol=https"
        export COMMON_FF_CFG_FLAGS="$COMMON_FF_CFG_FLAGS --disable-bsf=eac3_core"
      3. 执行如下命令进行编译:
        % ./init-config.sh
        % ./init-ios.sh
        
        % cd ios
        % ./compile-ffmpeg.sh clean
        % ./compile-common.sh
        % open ios/IJKMediaPlayer/IJKMediaPlayer.xcodeproj
      4. Edit Scheme中修改Run的构建配置为Release,然后分别选择构建目标为模拟器(如iPhone 8 Pus)和真机(Generic iOS Device),按Command+b进行编译构建;
      5. 之后进入生成的framework目录,将真机和模拟器库合并为通用库:
        % cd ~/Library/Developer/Xcode/DerivedData/IJKMediaPlayer-?????????/Build/Products
        % lipo -create Release-iphoneos/IJKMediaFramework.framework/IJKMediaFramework Release-iphonesimulator/IJKMediaFramework.framework/IJKMediaFramework -output IJKMediaFramework
        % cp IJKMediaFramework Release-iphoneos/IJKMediaFramework.framework
        % open Release-iphoneos/
      6. 将得到的IJKMediaFramework.framework复制进前面第一步flutter_ijkplayer目录中ios目录下;
      7. 本地包的引入为修改pubspec.yaml的依赖项如下:
        flutter_ijkplayer: #^0.3.5+1
          path: ./flutter_ijkplayer
        在flutter_ijkplayer本地包的ios/.podspec中修改如下:
        s.ios.vendored_frameworks = 'IJKMediaFramework.framework'
        s.frameworks  = "AudioToolbox", "AVFoundation", "CoreGraphics", "CoreMedia", "CoreVideo", "MobileCoreServices", "OpenGLES", "QuartzCore", "VideoToolbox", "Foundation", "UIKit", "MediaPlayer"
        s.libraries   = "bz2", "z", "stdc++"
        #s.dependency 'FlutterIJK', '~> 0.2.3'
    • 接下来就是尝试将mobile-ffmpeg整合进ijkplayer,尝试将flutter_ffmpeg整合进flutter_ijkplayer。其中前两者是iOS库,后两者是Flutter包。在整合过程中本着最小化原则,且尽可能只做增量。

      1. 【mobile-ffmpeg】入【ijkplayer】:在上一环节第3步后,在打开的Xcode中进行操作,将下列文件拖拽复制添加在左侧工程文件树的Classses/IJKFFMoviePlayerController/ijkmedia/ijkplayer下:
        mobileffmpeg.c
        mobileffmpeg.h
        ffmpeg.c
        ffmpeg.h
        cmdutils.c
        cmdutils.h
        ffmpeg_opt.c
        av_device.h
        av_device.c
        ffmpeg_hw.c
        ffmpeg_filter.c
        exception.c
        exception.h
        其实以添加mobile_ffmpeg为主,逐渐发现编译问题及后期加入Flutter项目之后编译问题再慢慢修改添加。
      2. 【mobile-ffmpeg】入【ijkplayer】:依然在Xcode中,修改Classses/IJKFFMoviePlayerController/ffmpeg/IJKFFMoviePlayerController.h加入接口函数声明:
        + (int)executeWithArguments: (NSArray*)arguments;
        修改Classses/IJKFFMoviePlayerController/ffmpeg/IJKFFMoviePlayerController.m加入接口函数:
        + (int)executeWithArguments: (NSArray*)arguments {
          
          char **commandCharPArray = (char **)av_malloc(sizeof(char*) * ([arguments count]));
          for (int i=0; i < [arguments count]; i++) {
            NSString *argument = [arguments objectAtIndex:i];
            commandCharPArray[i] = (char *) [argument UTF8String];
          }
        
          int lastReturnCode = mobileffmpeg_execute(([arguments count]), commandCharPArray);
        
          av_free(commandCharPArray);
          return lastReturnCode;
        }

        注意:修改完成后重复进行前一环节的后续步骤。

      3. 【flutter-ffmpeg】入【fluter-ijkplayer】:在本地包flutter_ijkplayer下lib/src/controller.dart下加入如下代码:
        static List<String> parseArguments(String command) {
          List<String> argumentList = new List();
          StringBuffer currentArgument = new StringBuffer();
          
          bool singleQuoteStarted = false;
          bool doubleQuoteStarted = false;
          for (int i = 0; i < command.length; i++) {
            var previousChar;
            if (i > 0) {
              previousChar = command.codeUnitAt(i - 1);
            } else {
              previousChar = null;
            }
            var currentChar = command.codeUnitAt(i);
            
            if (currentChar == ' '.codeUnitAt(0)) {
              if (singleQuoteStarted || doubleQuoteStarted) {
                currentArgument.write(String.fromCharCode(currentChar));
              } else if (currentArgument.length > 0) {
                argumentList.add(currentArgument.toString());
                currentArgument = new StringBuffer();
              }
            } else if (currentChar == '\''.codeUnitAt(0) &&
            (previousChar == null || previousChar != '\\'.codeUnitAt(0))) {
              if (singleQuoteStarted) {
                singleQuoteStarted = false;
              } else if (doubleQuoteStarted) {
                currentArgument.write(String.fromCharCode(currentChar));
              } else {
                singleQuoteStarted = true;
              }
            } else if (currentChar == '\"'.codeUnitAt(0) &&
            (previousChar == null || previousChar != '\\'.codeUnitAt(0))) {
              if (doubleQuoteStarted) {
                doubleQuoteStarted = false;
              } else if (singleQuoteStarted) {
                currentArgument.write(String.fromCharCode(currentChar));
              } else {
                doubleQuoteStarted = true;
              }
            } else {
              currentArgument.write(String.fromCharCode(currentChar));
            }
          }
          if (currentArgument.length > 0) {
            argumentList.add(currentArgument.toString());
          }
          return argumentList;
        }
        Future<int> executeWithArguments(String arguments) async {
          _ijkStatus = IjkStatus.preparing;
          await _initDataSource(false);
          final Map<dynamic, dynamic> result = await _plugin
          .executeFFmpegWithArguments(arguments: parseArguments(arguments));
          _ijkStatus = IjkStatus.prepared;
          return result['rc'];
        }
        lib/src/controller/plugin.dart中加入如下代码:
        Future<Map<dynamic, dynamic>> executeFFmpegWithArguments(
          {List<String> arguments} ) async {
            if (isDisposed) {
              return null;
            }
            return await channel.invokeMethod("executeFFmpegWithArguments", <String, dynamic>{'arguments': arguments});
        }
      4. 【flutter-ffmpeg】入【fluter-ijkplayer】:在本地包flutter_ijkplayer下ios/Classes/CoolFlutterIJK.m中增加如下代码:
        - (NSDictionary *)toIntDictionary:(NSString*)key :(NSNumber*)value {
           NSMutableDictionary *dictionary = [[NSMutableDictionary alloc] init];
           dictionary[key] = value;
           return dictionary;
         }
        handleMethodCall方法中头部加定义,并在其中判断call.method的判断语句中加入分支如下:
        NSArray* arguments = call.arguments[@"arguments"];
        NSString* command = call.arguments[@"command"];
        NSString* delimiter = call.arguments[@"delimiter"];
        
        else if   ([@"executeFFmpegWithArguments" isEqualToString:call.method]) {
          NSLog(@"Running FFmpeg with arguments: %@.\n", arguments);
          dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            int rc = [IJKFFMoviePlayerController executeWithArguments:arguments];
            NSLog(@"FFmpeg exited with rc: %d\n", rc);
            result([self toIntDictionary:@"rc" :[NSNumber numberWithInt:rc]]);
          });
        }
        如上述这几步骤后,即可令flutter_ffmpeg的主要功能“带参数执行ffmpeg命令”引入flutter_ijkplayer。但后来编译还发现ffmpeg.c中存在个小问题(似乎是fd_set溢出)如下修复:
        /*
        fd_set rfds;
        FD_ZERO(&rfds);
        FD_SET(0, &rfds);
        */
        struct pollfd rfds;
        rfds.fd=0;
        rfds.events=POLLIN;
            
        tv.tv_sec = 0; //未改动
        tv.tv_usec = 0; //未改动
            
        // n = select(1, &rfds, NULL, NULL, &tv);
            
        n=poll(&rfds,1,&tv);
    • 经过前面的整合,在Flutter项目中使用controller.executeWithArgumentscontroller.setNetworkDataSource均可成功了(需要注意这两个均是async函数,谨慎使用await放置前一操作的等待阻拦后一操作)

    • 但如此做似乎仍然存在问题,那便是在点击按钮执行一次executeWithArguments后如果再点一次按钮执行此操作即会闪退。这个问题还需要解决,因为app中可能存在需求需要在已发推流后重发推流。那么这种需要两种实现,要么设法取消前一命令,要么重推一命令覆盖掉前一命令。前者需研究如何实现通信,后者则需解决当前的这个“再触发闪退问题”。

2020-03-24

  • 如之前记录,尝试将flutter_ffmpeg(mobile_ffmpeg)整合进flutter_ijkplayer后出现在第一次发出ffmpeg命令后再次发出会有读空数据的情况导致闪退。认为该情况是由于某种原因在再次执行命令时,使得前一次或后一次执行的变量被清除所致。
  • 由于之前是尝试代码级整合,就用的和ijkplayer使用的ffmpeg版本(4.0)最接近的旧版mobile_ffmpeg(v2.0)。然后和flutter_ffmpeg插件使用的最新版mobile_ffmpeg进行比对,发现新版中很多变量被改为__thread(即线程本地)的,怀疑此可以解决变量被清除的问题。于是尝试手动对照修改已整合入的旧版的代码。但是经过尝试,发现改完后虽然不会闪退,但却出现更坏情况,命令参数解析的环节报错无效参数“-re”,断入手动追踪,发现在“cmdutil.c”中的split_commandline()函数while循环中第一处if判断时还为正常“-re”值得opt在第二处if时就变成了NULL……之后经过再三思考与查询,也并未有头绪。
  • 之后索性放弃代码级整合,尝试直接将最新版mobile_ffmpeg(及flutter_ffmpeg)的外层代码(及除ffmpeg之外由mobile_ffmpeg添加的,位于mobile_ffmpeg/ios/src)文件直接引入使用,即改为文件级别整合:
    • 最初的步骤跟前面的一样,构建flutter_ijkplayer本地库(使用ffmpeg4.0);
    • 之后将mobile_ffmpeg(最新版)整合进ijkplayer framework的方式为:
      • 将下列文件Xcode中放入IJKMediaFramework工程的Classes/IJKFFMoviePlayerController/ffmpeg下:
        mobileffmpeg_exception.h
        mobileffmpeg_exception.m
        MobileFFmpeg.h
        MobileFFmpeg.m
        ArchDetect.h
        ArchDetect.m
        LogDelegate.h
        MediaInformation.h
        MediaInformation.m
        MediaInformationParser.h
        MediaInformationParser.m
        MobileFFmpegConfig.h
        MobileFFmpegConfig.m
        MobileFFprobe.h
        MobileFFprobe.m
        Statistics.h
        Statistics.m
        StatisticsDelegate.h
        StreamInformation.h
        StreamInformation.m
      • 将下列文件Xcode放入IJKMediaFramework工程的Classes/IJKFFMoviePlayerController/ijkmedia/ijkplayer下:
        attributes.h
        avdevice.h
        avio.h
        fftools_cmdutils.c
        fftools_cmdutils.h
        fftools_ffmpeg_filter.c
        fftools_ffmpeg_hw.c
        fftools_ffmpeg_opt.c
        fftools_ffmpeg.c
        fftools_ffmpeg.h
        fftools_ffprobe.c
        intfloat.h
        libm.h
        mathematics.h
        network.h
        os_support.h
        url.h
        version.h
      • 之后将其中一些include路径进行订正(因为由原目录层级变成可直接使用的)
      • 需要将之后要暴露在framework库外供Flutter Plugin调用的头文件设置为公共可见
        Framework库头文件设置
    • 将flutter_ffmpeg整合入flutter_ijkplayer插件,最开始是想依然将原文件保留(即进行文件级别整合),但是会出现invokeMethod无法在Channel中找到的情况,后来发现,flutter插件需要在其pubspec.yamlflutter->plugin->platforms->ios->pluginClass设置插件类,之后在构建过程中便会由此在项目(而非插件)的ios/Runner下生成GeneratedPluginRegistrant.hGeneratedPluginRegistrant.m。其m文件结构如下:
      GeneratedPluginRegistrant
      也即是说,只有登记在插件的pubspec.yaml中的插件类,才会被调用其registerWithRegistrar方法,从而注册MethodChannel等。尝试在一个插件的pubspec中注册两个插件类,似乎没有办法。于是,也只能在Flutter插件这边,Dart文件进行文件级整合、iOS实现进行代码(类)级别整合:
      • 将flutter_ffmpeg插件的lib中两个dart文件flutter_ffmpeg.dartlog_level.dart复制到本地flutter_ijkplayer的lib文件夹中;
      • 将前一步骤生成的Framework目录放入本地flutter_ijkplayer的ios文件夹中;
      • 在flutter_ijkplayer的ios文件夹中将IjkplayerPlugin.m修改如下:
        • 加入
          #import <IJKMediaFramework/IJKMediaFramework.h>
        • 将全局常量、成员变量和方法、invokeMethod的判断分支整合到IjkplayerPlugin中;
    • 在使用时即可flutter项目只设置pubspec使用本地修改过的flutter_ijkplayer,在需要用到flutter_ffmpeg的方法时import 'package:flutter_ijkplayer/flutter_ffmpeg.dart';
  • 至此,flutter选择文件、执行推流命令、拉流播放的基本功能点打通。接下来暂时不考虑客户端了,因为客户端除UI/UE外功能部分剩余为播放时间点记录与同步、创建进入房间,而这两点基本都需要仰仗于服务端,故接下来的计划是先改造服务端golang版流媒体服务器livego。

持续更新中……