Skip to content

hansemannn/titanium-replaykit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 

Repository files navigation

iOS 11+ ReplayKit API

Record or stream video from the screen, and audio from the app and microphone in Titanium and Hyperloop.

Using the ReplayKit framework, users can record video from the screen, and audio from the app and microphone. They can then share their recordings with other users through email, messages, and social media. You can build app extensions for live broadcasting your content to sharing services.

Requirements

  • iOS 11+
  • Titanium SDK 7.1.0
  • Hyperloop 3.1.0
  • An iOS device (I was not able to run the native API's on the Simulator)

(Alternatively, you can use require() statements with SDK 7.0.2 and Hyperloop 3.0.2)

Usage

  1. Create a new file ti.screenrecorder.js in your project
  2. Paste the contents of the "TiScreenRecorder" class in it
  3. Include the following snippet into your code
import TiScreenRecorder from 'ti.screenrecorder';

const screenRecorder = new TiScreenRecorder({
  filePath: `${Ti.Filesystem.applicationDataDirectory}recording-${(new Date()).getTime()}.mp4`,
  callback: (recording, err) => {
    if (err) {
      alert(`Error recording screen: ${err}`)
      return;
    }
    const message = `Success! See recording in ${recording}`
    alert(message);
  }
});

// Start
// screenRecorder.startCapture();

// Stop
// screenRecorder.stopCapture();
  1. Thats it! You can now capture the screen and mic-audio!

(App-audio is not supported so far, but can be done the same way than the mic-audio ~ via AVAssetWriterInput)

Example

import {
  RPScreenRecorder,
  ReplayKit
} from 'ReplayKit';

import {
  AVAssetWriter,
  AVAssetWriterInput,
  AVCaptureDevice,
  AVFoundation
} from 'AVFoundation';

import { NSURL, NSError } from 'Foundation';

import { UIScreen } from 'UIKit';

import { CoreMedia } from 'CoreMedia';

const AVVideoCodecKey = AVFoundation.AVVideoCodecKey;
const AVVideoWidthKey = AVFoundation.AVVideoWidthKey;
const AVVideoHeightKey = AVFoundation.AVVideoHeightKey;
const AVVideoCompressionPropertiesKey = AVFoundation.AVVideoCompressionPropertiesKey;

const AVVideoPixelAspectRatioKey = AVFoundation.AVVideoPixelAspectRatioKey;
const AVVideoCleanApertureKey = AVFoundation.AVVideoCleanApertureKey;
const AVVideoCleanApertureWidthKey = AVFoundation.AVVideoCleanApertureWidthKey;
const AVVideoCleanApertureHeightKey = AVFoundation.AVVideoCleanApertureHeightKey;
const AVVideoCleanApertureVerticalOffsetKey = AVFoundation.AVVideoCleanApertureVerticalOffsetKey;
const AVVideoCleanApertureHorizontalOffsetKey = AVFoundation.AVVideoCleanApertureHorizontalOffsetKey;
const AVVideoPixelAspectRatioVerticalSpacingKey = AVFoundation.AVVideoPixelAspectRatioVerticalSpacingKey;
const AVVideoPixelAspectRatioHorizontalSpacingKey = AVFoundation.AVVideoPixelAspectRatioHorizontalSpacingKey;

const AVFormatIDKey = AVFoundation.AVFormatIDKey;
const AVNumberOfChannelsKey = AVFoundation.AVNumberOfChannelsKey;
const AVSampleRateKey = AVFoundation.AVSampleRateKey;
const AVEncoderBitRateKey = AVFoundation.AVEncoderBitRateKey;

export default class TiScreenRecorder {
  constructor(args = {}) {
    const filePath = args.filePath;
    const callback = args.callback;

    if (!filePath) {
      Ti.API.error('Missing `filePath` property!');
      return;
    }

    if (!callback) {
      Ti.API.error('Missing `callback` property!');
      return;
    }

    this.filePath = filePath;
    this.callback = callback;

    this.isMicEnabled = false;
    this.videoSessionStarted = false;
    this.micSessionStarted = false;
  }
  startCapture() {
    if (!RPScreenRecorder.sharedRecorder().isAvailable()) {
      callback(null, 'Screen Recorder is not available!');
      return;
    }

    // Prepare asset writer
    const fileURL = NSURL.fileURLWithPath(this.filePath);
    const screenSize = UIScreen.mainScreen.bounds.size;

    Ti.API.info(`Recording video to ${fileURL.absoluteString} ...`);

    this.assetWriter = AVAssetWriter.assetWriterWithURLFileTypeError(fileURL, AVFoundation.AVFileTypeMPEG4, null); // TODO: Handle error?
    this.assetWriter.movieTimeScale = 60;

    // Video input
    const videoInputSettings = {
      // AVVideoCompressionPropertiesKey: {
      //   AVVideoPixelAspectRatioKey: {
      //     AVVideoPixelAspectRatioVerticalSpacingKey: 1,
      //     AVVideoPixelAspectRatioHorizontalSpacingKey: 1
      //   },
      //   AVVideoCleanApertureKey: {
      //     AVVideoCleanApertureWidthKey: screenSize.width,
      //     AVVideoCleanApertureHeightKey: screenSize.height,
      //     AVVideoCleanApertureVerticalOffsetKey: 10,
      //     AVVideoCleanApertureHorizontalOffsetKey: 10
      //   }
      // },
      AVVideoCodecKey: AVFoundation.AVVideoCodecTypeH264,
      AVVideoWidthKey: screenSize.width,
      AVVideoHeightKey: screenSize.height
    };

    this.videoInput = AVAssetWriterInput.assetWriterInputWithMediaTypeOutputSettings(AVFoundation.AVMediaTypeVideo, videoInputSettings);
    this.videoInput.expectsMediaDataInRealTime = true;
    this.videoInput.mediaTimeScale = 60
    if (this.assetWriter.canAddInput(this.videoInput)) {
      this.assetWriter.addInput(this.videoInput);
    } else {
      this.callback(null, 'Cannot add video input');
      return;
    }

    // Enable mic if selected
    if (this.isMicEnabled) {
      const audioInputSettings = {
        AVFormatIDKey: 1633772320,
        AVNumberOfChannelsKey: 1,
        AVSampleRateKey: 44100.0,
        AVEncoderBitRateKey: 64000
      };

      this.audioInput = AVAssetWriterInput.assetWriterInputWithMediaTypeOutputSettings(AVFoundation.AVMediaTypeAudio, audioInputSettings);
      this.audioInput.expectsMediaDataInRealTime = true;

      if (this.assetWriter.canAddInput(this.videoInput)) {
        this.assetWriter.addInput(this.audioInput);
        RPScreenRecorder.sharedRecorder().microphoneEnabled = true;
      } else {
        this.callback(null, 'Cannot add audio input');
        return;
      }
    }

    // Start screen capture
    RPScreenRecorder.sharedRecorder().startCaptureWithHandlerCompletionHandler((sample, bufferType, error) => {
      if (error !== null) {
        Ti.API.error('Error in startCapture: ' + error.localizedDescription);
        this.callback(null, error.localizedDescription);
        return;
      }

      if (!CoreMedia.CMSampleBufferDataIsReady(sample)) {
        Ti.API.warn('Not ready for writing ...');
        return;
      }

      if (this.assetWriter.status === AVFoundation.AVAssetWriterStatusFailed) {
        Ti.API.error(`Error handling writer: ${this.assetWriter.error.localizedDescription}`);
        this.callback(null, assetWriter.error.localizedDescription);
        return;
      }

      if (this.assetWriter.status !== AVFoundation.AVAssetWriterStatusWriting) {
        Ti.API.debug(`Not writing ... Status = ${this.assetWriter.status}`);
      }

      switch (bufferType) {
        case ReplayKit.RPSampleBufferTypeVideo:
          if (!this.videoSessionStarted) {
            Ti.API.info('Starting video session ...');
            this.videoSessionStarted = true;
            if (!this.assetWriter.startWriting()) {
              this.callback(null, this.assetWriter.error.localizedDescription);
              return;
            }
            this.assetWriter.startSessionAtSourceTime(CMSampleBufferGetPresentationTimeStamp(sample));
          }

          if (this.videoInput.isReadyForMoreMediaData) {
            Ti.API.info('Adding buffer to video input ...');
            this.videoInput.appendSampleBuffer(sampleBuffer);
          }
          break;
        case ReplayKit.RPSampleBufferTypeAudioMic:
          if (!this.isMicEnabled) {
            Ti.API.warn('Received audio buffer, but mic not enabled. Skipping ...');
            break;
          }

          if (!this.micSessionStarted) {
            Ti.API.info('Starting audio session ...');
            this.micSessionStarted = true;
            this.assetWriter.startSessionAtSourceTime(CMSampleBufferGetPresentationTimeStamp(sample));
          }

          if (this.audioInput.isReadyForMoreMediaData) {
            // Ti.API.info('Adding buffer to audio input ...');
            this.audioInput.appendSampleBuffer(sampleBuffer);
          }
          break;

        default:
          break;
      }
    }, error => {
      if (!error) { return; }
      Ti.API.error(`Error recording screen: ${error.localizedDescription}, Code = ${error.code}`);
      this.callback(null, error.localizedDescription);
    });
  }

  stopCapture() {
    RPScreenRecorder.sharedRecorder().stopCaptureWithHandler(error => {
      if (!error && this.assetWriter.status !== AVFoundation.AVAssetWriterStatusFailed) {
        this.assetWriter.finishWritingWithCompletionHandler(() => {
          Ti.API.info('Status: ' + this._assetWriterStatusToString(this.assetWriter.status));
          if (this.assetWriter.status !== AVFoundation.AVAssetWriterStatusCompleted) {
            Ti.API.error('Error finishing writing ...');
            this.callback(null, this.assetWriter.error.localizedDescription);
            return;
          }
          this.callback(this.assetWriter.outputURL.absoluteString, null);
          this.assetWriter = null;
        });
      } else {
        Ti.API.error(`Error stopping capture: ${error.localizedDescription}`);
        this.callback(null, error.localizedDescription);
      }
    });
  }

  requestAudioPermissions(block) {
    AVCaptureDevice.requestAccessForMediaTypeCompletionHandler(AVFoundation.AVMediaTypeAudio, granted => {
      this.isMicEnabled = granted;
      block(granted);
    });
  }

  /* unused */
  requestVideoPermissions(block) {
    AVCaptureDevice.requestAccessForMediaTypeCompletionHandler(AVFoundation.AVMediaTypeVideo, granted => {
      block(granted);
    });
  }

  _assetWriterStatusToString(status) {
    switch (status) {
      case AVFoundation.AVAssetWriterStatusUnknown:
        return 'Unknown';
      case AVFoundation.AVAssetWriterStatusWriting:
        return 'Writing';
      case AVFoundation.AVAssetWriterStatusCompleted:
        return 'Completed';
      case AVFoundation.AVAssetWriterStatusFailed:
        return 'Failed';
      case AVFoundation.AVAssetWriterStatusCancelled:
        return 'Cancelled';
    }

    return '(Unhandled)';
  }
}

Known issues

  • The video size seems to be 0 bytes on iOS 11.3, I have no idea why so far.

License

MIT

Author

Hans Knöchel (@hansemannnn / Web)

About

Use the iOS ReplayKit API's to record the app screen in Titanium and Hyperloop.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published