0.2.46 • Published 17 days ago

react-native-jw-media-player v0.2.46

Weekly downloads
150
License
MIT
Repository
github
Last release
17 days ago

react-native-jw-media-player

A react-native bridge for JWPlayer native SDK's

⚠️ Important you need a JWPlayer license to use this library https://jwplayer.com/

Getting started

npm i react-native-jw-media-player --save

Mostly automatic installation

For iOS you have to run cd ios/ && pod install.

For Android the package is automatically linked.

Important

This README is for react-native-jw-media-player version 0.2.0 and higher, for previous version check out the Old README.

Since version 0.2.0 we use the new JWPlayerKit && SDK 4 check out iOS get started && Android get started

Android dependencies

Insert the following lines inside the allProjects.dependencies block in android/build.gradle:

maven{
    url 'https://mvn.jwplayer.com/content/repositories/releases/'
}

As so

allprojects {
    repositories {
        mavenLocal()
        maven {
            // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
            url("$rootDir/../node_modules/react-native/android")
        }
        maven {
            // Android JSC is installed from npm
            url("$rootDir/../node_modules/jsc-android/dist")
        }

        google()
        jcenter()
        maven { url 'https://jitpack.io' }
        // Add these lines
        maven{
            url 'https://mvn.jwplayer.com/content/repositories/releases/'
        }
    }
}

Usage

...

import JWPlayer, { JWPlayerState } from 'react-native-jw-media-player';

...

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  player: {
    flex: 1,
  },
});

...

const playlistItem = {
  title: 'Track',
  mediaId: -1,
  image: 'http://image.com/image.png',
  description: 'My beautiful track',
  startTime: 0,
  file: 'http://file.com/file.mp3',
  autostart: true,
  repeat: false,
  displayDescription: true,
  displayTitle: true,
  tracks: [
    {
      file: 'http://file.com/english.vtt',
      label: 'en'
    },
    {
      file: 'http://file.com/spanish.srt',
      label: 'es'
    }
  ],
  sources: [
    {
      file: 'http://file.com/file.mp3',
      label: 'audio'
    },
    {
      file: 'http://file.com/file.mp4',
      label: 'video',
      default: true
    }
  ]
}

const config = {
  license:
    Platform.OS === 'android'
      ? 'YOUR_ANDROID_SDK_KEY'
      : 'YOUR_IOS_SDK_KEY',
  backgroundAudioEnabled: true,
  autostart: true,
  styling: {
    colors: {
      timeslider: {
        rail: "0000FF",
      },
    },
  },
  playlist: [playlistItem],
}

...

async isPlaying() {
  const playerState = await this.JWPlayer.playerState();
  return playerState === JWPlayerState.JWPlayerStatePlaying;
}

...

render() {

...

<View style={styles.container}>
  <JWPlayer
    ref={p => (this.JWPlayer = p)}
    style={styles.player}
    config={config}
    onBeforePlay={() => this.onBeforePlay()}
    onPlay={() => this.onPlay()}
    onPause={() => this.onPause()}
    onIdle={() => console.log("onIdle")}
    onPlaylistItem={event => this.onPlaylistItem(event)}
    onSetupPlayerError={event => this.onPlayerError(event)}
    onPlayerError={event => this.onPlayerError(event)}
    onBuffer={() => this.onBuffer()}
    onTime={event => this.onTime(event)}
    onFullScreen={() => this.onFullScreen()}
    onFullScreenExit={() => this.onFullScreenExit()}
  />
</View>

...

}

Run example project

Running the example project:

  1. Checkout this repository.
  2. Go to Example directory and run yarn or npm i
  3. Go to Example/ios and install Pods with pod install
  4. Open RNJWPlayer.xcworkspace with XCode.
  5. Add your JW SDK license in App.js under the configprop.

Available props

PropDescriptionDefaultType
configThe JW Config object.undefinedObject
controlsShould the player controls show.trueBoolean
Config
PropDescriptionTypePlatformDefault
offlineImageThe url for the player offline thumbnail.StringiOSnone
offlineMessageThe message when the player is offline.StringiOSnone
autostartShould the tracks auto start.BooleaniOS && Androidfalse
controlsShould the control buttons show.BooleanAndroidtrue
repeatShould the track repeat.BooleaniOS && Androidfalse
playlistAn array of playlistItems.[playlistItem] see PlaylistItem]iOS && Androidnone
nextUpStyleHow the next up videos should be presented.{offsetSeconds: Int, offsetPercentage, Int}iOS && Androidnone
stylingAll the stylings for the player see Styling section.ObjectiOS && Androidnone
advertisingGeneral Advertising settings on the player see Advertising section.ObjectiOS && Androidnone
fullScreenOnLandscapeWhen this is true the player will go into full screen on rotate of phone to landscapeBooleaniOS && Androidfalse
landscapeOnFullScreenWhen this is true the player will go into landscape orientation when on full screenBooleaniOS && Androidfalse
portraitOnExitFullScreenWhen this is true the player will go into portrait orientation when exiting full screenBooleanAndroidfalse
exitFullScreenOnPortraitWhen this is true the player will exit full screen when the phone goes into portraitBooleanAndroidfalse
enableLockScreenControlsWhen this is true the player will show media controls on lock screenBooleaniOStrue
stretchingResize images and video to fit player dimensions. See below Stretching section.StringAndroidnone
backgroundAudioEnabledShould the player continue playing in the background and handle interruptions.BooleaniOS && Androidfalse
categoryControls the audio session category. See below AudioSessionCategoryStringiOSPlayback
modeControls the audio session mode. See below AudioSessionModeStringiOSnone
viewOnlyWhen true the player will not have any controls it will show only the video.BooleaniOSfalse
pipEnabledWhen true the player will be able to go into Picture in Picture mode. Note: This is true by default for iOS PlayerViewController. For Android you will also need to follow the instruction mentioned here && below Picture in picture section.BooleaniOS when viewOnly prop is true && Androidfalse
interfaceBehaviorThe behavior of the player interface.'normal', 'hidden', 'onscreen'iOSnormal
interfaceFadeDelayThe number of seconds to wait when fading the interface. The default value is 3 seconds.numberiOS3
preloadThe behavior of the preload.'auto', 'none'iOSauto
relatedThe related videos behaviors. Check out the Related section.ObjectiOSnone
hideUIGroupsA way to hide certain UI groups in the player.Array of 'overlay', 'control_bar', 'center_controls', 'next_up', 'error', 'playlist', 'controls_container', 'settings_menu', 'quality_submenu', 'captions_submenu', 'playback_submenu', 'audiotracks_submenu', 'casting_menu'Androidnone
processSpcUrlYour DRM License URL. Checkout the DRM section below.StringiOSnone
fairplayCertUrlYour DRM Certificate URL. Checkout the DRM section below.StringiOSnone
contentUUIDYour DRM content UUID. Checkout the DRM section below.StringiOSnone
PlaylistItem
PropDescriptionType
mediaIdThe JW media id.Int
startTimethe player should start from a certain second.Int
adVmapThe url of ads VMAP xml. (iOS only)String
adScheduleArray of tags and and offsets for ads. (iOS only){tag: String, offset: String}
descriptionDescription of the track.String
fileThe url of the file to play.String
tracksArray of caption tracks.{file: String, label: String}
sourcesArray of media sources.{file: String, label: String, default: Boolean}
imageThe url of the player thumbnail.String
titleThe title of the track.String
recommendationsUrl for recommended videos.String
autostartShould the track auto start.Boolean
authUrlOnly Available on Android, Used for Authorizing DRM content. Checkout the DRM section below.String
JWControlType
KeyValue
forward0
rewind1
pip2
airplay3
chromecast4
next5
previous6
settings7
languages8
fullscreen9
JWPlayerAdClients
ClientValue
JWAdClientVast0
JWAdClientGoogima1
JWAdClientGoogimaDAI2
JWPlayerState

iOS

StateValue
JWPlayerStateUnknown0
JWPlayerStateIdle1
JWPlayerStateBuffering2
JWPlayerStatePlaying3
JWPlayerStatePaused4
JWPlayerStateComplete5
JWPlayerStateError6

Android

StateValue
JWPlayerStateIdle0
JWPlayerStateBuffering1
JWPlayerStatePlaying2
JWPlayerStatePaused3
JWPlayerStateComplete4
JWPlayerStateError5
Styling
PropDescriptionTypePlatformDefault
displayDescriptionShould the player show the description.BooleaniOS && Androidtrue
displayTitleShould the player show the title.BooleaniOS && Androidtrue
colorsObject with colors in hex format (without hashtag), for the icons and progress bar See below Colors section.Object
fontName and size of the fonts for all texts in the player. Note: the font must be added properly in your native project{name: String, size: Int}iOSSystem
captionsStyleStyle of the captions: name and size of the fonts, backgroundColor, edgeStyle and highlightColor. Note: the font must be added properly in your native project{font: {name: String, size: Int}, backgroundColor: String, highlightColor: String, edgeStyle: 'none', 'dropshadow', 'raised', 'depressed', 'uniform'} See the edgeStyle enum belowiOSSystem
menuStyleStyle of the menu: name and size of the fonts, backgroundColor and fontColor. Note: the font must be added properly in your native project{font: {name: String, size: Int}, backgroundColor: String, fontColor: String}iOSSystem
Colors
PropDescriptionTypePlatformDefault
buttonsColor of all the icons.StringiOSFFFFFF
timesliderColors for the progress bar.{progress: String, buffer: String, rail: String, thumb: String,}iOS & Android **Note: buffer is only available on android**FFFFFF
backgroundColorColor for the background.StringiOS & AndroidFFFFFF

Note: It is expected to pass the colors in hex format without the hashtag example for white FFFFFF.

colors: PropTypes.shape({
  buttons: PropTypes.string,
  timeslider: PropTypes.shape({
    progress: PropTypes.string,
    rail: PropTypes.string,
    thumb: PropTypes.string,
  }),
});
EdgeStyle
StateValue
JWCaptionEdgeStyleUndefined1
JWCaptionEdgeStyleNone2
JWCaptionEdgeStyleDropshadow3
JWCaptionEdgeStyleRaised4
JWCaptionEdgeStyleDepressed5
JWCaptionEdgeStyleUniform6

AudioTrack

Each AudioTrack object has the following keys:

autoSelect: boolean

defaultTrack: boolean

groupId: string

name: string

language: string

A video file can include multiple audio tracks. The onAudioTracks event is fired when the list of available AudioTracks is updated (happens shortly after a playlist item starts playing).

Once the AudioTracks list is available, use getAudioTracks to return an array of available AudioTracks.

Then use getCurrentAudioTrack or setCurrentAudioTrack(index) to view or change the current AudioTrack.

This is all handled automatically if using the default player controls, but these functions are helpful if you're implementing custom controls.

Stretching

uniform: (default) Fits JW Player dimensions while maintaining aspect ratio

exactFit: Will fit JW Player dimensions without maintaining aspect ratio

fill: Will zoom and crop video to fill dimensions, maintaining aspect ratio

none: Displays the actual size of the video file. (Black borders)

Stretching Examples:

Stretching Example

(image from JW Player docs - here use exactFit instead of exactfit)

DRM

Checkout the official DRM docs iOS & Android.

As of now June 7, 2022 there is only support for Fairplay on iOS and Widevine for Android.

In short when passing a protected file URL in the file prop you will also need to pass additional props for the player to be able to decode your encrypted content; per each respective platform.

  • iOS - your processSpcUrl?: string (License) and fairplayCertUrl?: string (Certificate) on the config prop. We will try to parse the contentUUID from the FPS server playback context (SPC), but you can override it by passing it in config along the other two props;
  • Android - your authUrl?: string on each PlaylistItem in the playlist prop;

Checkout the DRMExample in the Example app. (The DRMExample cannot be run in the Simulator).

Advertising

Important

When using an IMA ad client you need to do some additional setup.

  • iOS: Add $RNJWPlayerUseGoogleIMA = true to your Podfile, this will add GoogleAds-IMA-iOS-SDK pod.

  • Android: Add RNJWPlayerUseGoogleIMA = true in your app/build.gradle ext {} this will add 'com.google.ads.interactivemedia.v3:interactivemedia:3.29.0' and 'com.google.android.gms:play-services-ads-identifier:18.0.1'.

PropDescriptionTypeAvailability
adVmapThe URL of the ads VMAP XML.StringAll Clients (iOS only)
adScheduleArray of tags and offsets for ads.{tag: String, offset: String}[]All Clients
openBrowserOnAdClickShould the player open the browser when clicking on an ad.BooleanAll Clients
adClientThe ad client. One of vast, ima, or ima_dai, check out JWPlayerAdClients, defaults to vast.'vast', 'ima', 'ima_dai'All Clients
adRulesAd rules for VAST client.{startOn: Number, frequency: Number, timeBetweenAds: Number, startOnSeek: 'none' \| 'pre'}VAST only
imaSettingsSettings specific to Google IMA SDK.{locale: String, ppid: String, maxRedirects: Number, sessionID: String, debugMode: Boolean}IMA and IMA DAI
companionAdSlotsArray of objects representing companion ad slots.{viewId: String, size?: {width: Number, height: Number}}[]IMA only
friendlyObstructionsArray of objects representing friendly obstructions for viewability measurement.{viewId: String, purpose: 'mediaControls' \| 'closeAd' \| 'notVisible' \| 'other', reason?: String}[]IMA and IMA DAI
googleDAIStreamStream configuration for Google DAI (Dynamic Ad Insertion).{videoID?: String, cmsID?: String, assetKey?: String, apiKey?: String, adTagParameters?: {[key: string]: string}}IMA DAI only
tagVast xml URL.StringVast only (iOS only)
Related
PropDescriptionType
onClickSets the related content onClick action using a JWRelatedOnClick. Defaults to play'play', 'link'
onCompleteSets the related content onComplete action using a JWRelatedOnComplete. Defaults to show'show', 'hide', 'autoplay'
headingSets the related content heading using a String. Defaults to “Next up”.String
urlSets the related content url using a URL.String
autoplayMessageSets the related content autoplayMessage using a String. Defaults to titleString
autoplayTimerSets the related content autoplayTimer using a Int. Defaults to 10 seconds.Int

AudioSessions iOS

Check out the official apple docs for more information on AVAudioSessions. Below is a summarized description of each value.

AudioSessionCategory

Available on iOS

PropDescription
AmbientUse this category for background sounds such as rain, car engine noise, etc. Mixes with other music.
SoloAmbientUse this category for background sounds. Other music will stop playing.
PlaybackUse this category for music tracks.
RecordUse this category when recording audio.
PlayAndRecordUse this category when recording and playing back audio.
MultiRouteUse this category when recording and playing back audio.
AudioSessionCategoryOptions

Available on iOS

PropDescription
MixWithOthersControls whether other active audio apps will be interrupted or mixed with when your app's audio session goes active. Details depend on the category.
DuckOthersControls whether or not other active audio apps will be ducked when when your app's audio session goes active. An example of this is a workout app, which provides periodic updates to the user. It reduces the volume of any music currently being played while it provides its status.
AllowBluetoothAllows an application to change the default behavior of some audio session categories with regard to whether Bluetooth Hands-Free Profile (HFP) devices are available for routing. The exact behavior depends on the category.
DefaultToSpeakerAllows an application to change the default behavior of some audio session categories with regard to the audio route. The exact behavior depends on the category.
InterruptSpokenAudioAndMixWhen a session with InterruptSpokenAudioAndMixWithOthers set goes active, then if there is another playing app whose session mode is AVAudioSessionModeSpokenAudio (for podcast playback in the background, for example), then the spoken-audio session will be interrupted. A good use of this is for a navigation app that provides prompts to its user: it pauses any spoken audio currently being played while it plays the prompt.
AllowBluetoothA2DPAllows an application to change the default behavior of some audio session categories with regard to whether Bluetooth Advanced Audio Distribution Profile (A2DP) devices are available for routing. The exact behavior depends on the category.
AllowAirPlayAllows an application to change the default behavior of some audio session categories with regard to showing AirPlay devices as available routes. This option applies to various categories in the same way as AVAudioSessionCategoryOptionAllowBluetoothA2DP; see above for details.
OverrideMutedMicrophoneSome devices include a privacy feature that mutes the built-in microphone at a hardware level under certain conditions e.g. when the Smart Folio of an iPad is closed. The default behavior is to interrupt the session using the built-in microphone when that microphone is muted in hardware. This option allows an application to opt out of the default behavior if it is using a category that supports both input and output, such as AVAudioSessionCategoryPlayAndRecord, and wants to allow its session to stay activated even when the microphone has been muted. The result would be that playback continues as normal, and microphone sample buffers would continue to be produced but all microphone samples would have a value of zero.
AudioSessionMode

Available on iOS

PropDescription
DefaultThe default mode.
VoiceChatOnly valid with AVAudioSessionCategoryPlayAndRecord. Appropriate for Voice over IP (VoIP) applications. Reduces the number of allowable audio routes to be only those that are appropriate for VoIP applications and may engage appropriate system-supplied signal processing. Has the side effect of setting AVAudioSessionCategoryOptionAllowBluetooth.
VideoChatOnly valid with kAudioSessionCategory_PlayAndRecord. Reduces the number of allowable audio routes to be only those that are appropriate for video chat applications. May engage appropriate system-supplied signal processing. Has the side effect of setting AVAudioSessionCategoryOptionAllowBluetooth and AVAudioSessionCategoryOptionDefaultToSpeaker.
GameChatSet by Game Kit on behalf of an application that uses a GKVoiceChat object; valid only with the AVAudioSessionCategoryPlayAndRecord category. Do not set this mode directly. If you need similar behavior and are not using a GKVoiceChat object, use AVAudioSessionModeVoiceChat instead.
VideoRecordingOnly valid with AVAudioSessionCategoryPlayAndRecord or AVAudioSessionCategoryRecord Modifies the audio routing options and may engage appropriate system-supplied signal processing.
MeasurementAppropriate for applications that wish to minimize the effect of system-supplied signal processing for input and/or output audio signals.
MoviePlaybackEngages appropriate output signal processing for movie playback scenarios. Currently only applied during playback over built-in speaker.
SpokenAudioAppropriate for applications which play spoken audio and wish to be paused (via audio session interruption) rather than ducked if another app (such as a navigation app) plays a spoken audio prompt. Examples of apps that would use this are podcast players and audio books. For more information, see the related category option AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers.
VoicePromptAppropriate for applications which play spoken audio and wish to be paused (via audio session interruption) rather than ducked if another app (such as a navigation app) plays a spoken audio prompt. Examples of apps that would use this are podcast players and audio books. For more information, see the related category option AVAudioSessionCategoryOptionInterruptSpokenAudioAndMixWithOthers.
Picture-in-picture

Picture in picture mode is enabled by JW on iOS for the PlayerViewController, however when setting the viewOnly prop to true you will also need to set the pipEnabled prop to true, and call the togglePIP method to enable / disable PIP. For Android you will have to add the following code in your MainActivity.java

@Override
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
  super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);

  Intent intent = new Intent("onPictureInPictureModeChanged");
  intent.putExtra("isInPictureInPictureMode", isInPictureInPictureMode);
  intent.putExtra("newConfig", newConfig);
  this.sendBroadcast(intent);
}

Available methods

FuncDescriptionArgument
seekToTells the player to seek to position, use in onPlaylistItem callback so player finishes buffering file.Int
togglePIPEnter or exist Picture in picture mode.none
playStarts playing.none
pausePauses playing.none
stopStops the player completely.none
playerStateReturns promise that then returns the current state of the player. Check out the JWPlayerState Object.none
positionReturns promise that then returns the current position of the player in seconds.none
toggleSpeedToggles the player speed one of 0.5, 1.0, 1.5, 2.0.none
setSpeedSets the player speed.Double
setVolumeSets the player volume.Double
setPlaylistIndexSets the current playing item in the loaded playlist.Int
setControlsSets the display of all the control buttons on the player.Boolean
setVisibility(iOS only) Sets the visibility of specific control buttons on the player. You pass visibility: Boolean && controls that is an array of JWControlTypevisibility: Boolean, controls: JWControlType[]
setLockScreenControls(iOS only) Sets the locks screen controls for the currently playing media, can be used to control what player to show the controls for.Boolean
setFullScreenSet full screen.Boolean
loadPlaylistLoads a playlist. (Using this function before the player has finished initializing may result in assert crash or blank screen, put in a timeout to make sure JWPlayer is mounted).[PlaylistItems]
loadPlaylistItemLoads a playlist item. (Using this function before the player has finished initializing may result in assert crash or blank screen, put in a timeout to make sure JWPlayer is mounted).PlaylistItem
getAudioTracksReturns promise that returns an array of AudioTracksnone
getCurrentAudioTrackReturns promise that returns the index of the current audio track in array returned by getAudioTracksnone
setCurrentAudioTrackSets the current audio track to the audio track at the specified index in the array returned by getAudioTracksInt
setCurrentCaptionsTurns off captions when argument is 0. Setting argument to another integer, sets captions to track at playlistItem.tracksinteger - 1Int

Available callbacks

All Callbacks with data are wrapped in native events for instance this is how to get the data from onTime callback ->
  onTime(event) {
    const {position, duration} = event.nativeEvent;
  }
FuncDescriptionArgument
onPlaylistA new playlist is loaded.[playlistItem] see PlaylistItem
onPlayerReadyThe player has finished setting up and is ready to play.none
onBeforePlayRight before playing.none
onBeforeCompleteRight before playing completed and is starting to play.none
onCompleteRight after media playing is completed.none
onPlayPlayer started playing.
0.2.46

17 days ago

0.2.45

2 months ago

0.2.44

3 months ago

0.2.43

4 months ago

0.2.42

4 months ago

0.2.41

5 months ago

0.2.40

5 months ago

0.2.39

9 months ago

0.2.38

9 months ago

0.2.37

10 months ago

0.2.36

10 months ago

0.2.35

1 year ago

0.2.34

1 year ago

0.2.27

1 year ago

0.2.30

1 year ago

0.2.33

1 year ago

0.2.32

1 year ago

0.2.31

1 year ago

0.2.29

1 year ago

0.2.28

1 year ago

0.2.26

2 years ago

0.2.25

2 years ago

0.2.24

2 years ago

0.2.23

2 years ago

0.2.22

2 years ago

0.2.21

2 years ago

0.2.20

2 years ago

0.2.19

2 years ago

0.2.18

2 years ago

0.2.17

2 years ago

0.2.16

2 years ago

0.2.15

2 years ago

0.2.14

2 years ago

0.2.13

2 years ago

0.2.12

2 years ago

0.2.11

2 years ago

0.2.10

2 years ago

0.2.7

2 years ago

0.2.9

2 years ago

0.2.8

2 years ago

0.2.6

2 years ago

0.2.5

2 years ago

0.2.4

2 years ago

0.2.3

3 years ago

0.2.2

3 years ago

0.2.1

3 years ago

0.2.0-beta.2

3 years ago

0.2.0-beta.1

3 years ago

0.2.0-beta.0

3 years ago

0.1.56

3 years ago

0.1.57

3 years ago

0.1.54

3 years ago

0.1.55

3 years ago

0.1.53

3 years ago

0.1.52

3 years ago

0.1.51

4 years ago

0.1.50

4 years ago

0.1.49

4 years ago

0.1.48

4 years ago

0.1.47

4 years ago

0.1.45

4 years ago

0.1.46

4 years ago

0.1.44

4 years ago

0.1.43

4 years ago

0.1.42

4 years ago

0.1.41

4 years ago

0.1.40

4 years ago

0.1.38

4 years ago

0.1.39

4 years ago

0.1.37

4 years ago

0.1.35

4 years ago

0.1.36

4 years ago

0.1.34

4 years ago

0.1.33

4 years ago

0.1.31

4 years ago

0.1.32

4 years ago

0.1.30

4 years ago

0.1.29

4 years ago

0.1.28

4 years ago

0.1.27

4 years ago

0.1.26

4 years ago

0.1.25

4 years ago

0.1.24

4 years ago

0.1.23

4 years ago

0.1.22

4 years ago

0.1.21

4 years ago

0.1.20

4 years ago

0.1.19

4 years ago

0.1.18

4 years ago

0.1.17

5 years ago

0.1.16

5 years ago

0.1.15

5 years ago

0.1.14

5 years ago

0.1.13

5 years ago

0.1.12

5 years ago

0.1.11

5 years ago

0.1.10

5 years ago

0.1.9

5 years ago

0.1.8

5 years ago

0.1.7

5 years ago

0.1.6

5 years ago

0.1.5

5 years ago

0.1.4

5 years ago

0.1.3

5 years ago

0.1.2

5 years ago