Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
SDK
/
exoplayer
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Settings
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
658a7ffb
authored
May 19, 2015
by
Oliver Woodman
Browse files
Options
_('Browse Files')
Download
Email Patches
Plain Diff
Step towards enhanced Webvtt parser to support HTML-rich captions and positioning.
parent
709fc773
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
560 additions
and
161 deletions
RELEASENOTES.md
demo/src/main/java/com/google/android/exoplayer/demo/PlayerActivity.java
demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java
demo/src/main/res/layout/player_activity.xml
library/src/main/java/com/google/android/exoplayer/text/Cue.java
library/src/main/java/com/google/android/exoplayer/text/Subtitle.java
library/src/main/java/com/google/android/exoplayer/text/SubtitleLayout.java
library/src/main/java/com/google/android/exoplayer/text/SubtitleView.java
library/src/main/java/com/google/android/exoplayer/text/TextRenderer.java
library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java
library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java
library/src/main/java/com/google/android/exoplayer/text/ttml/TtmlSubtitle.java
library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java
library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java
library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttSubtitle.java
library/src/test/assets/webvtt/typical_with_tags
library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java
library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttSubtitleTest.java
RELEASENOTES.md
View file @
658a7ffb
...
@@ -5,6 +5,7 @@
...
@@ -5,6 +5,7 @@
*
Add option to TsExtractor to allow non-IDR keyframes.
*
Add option to TsExtractor to allow non-IDR keyframes.
*
Added MulticastDataSource for connecting to multicast streams.
*
Added MulticastDataSource for connecting to multicast streams.
*
(WorkInProgress) - First steps to supporting seeking in DASH DVR window.
*
(WorkInProgress) - First steps to supporting seeking in DASH DVR window.
*
(WorkInProgress) - First steps to supporting styled + positioned subtitles.
### r1.3.2 (from r1.3.1) ###
### r1.3.2 (from r1.3.1) ###
...
...
demo/src/main/java/com/google/android/exoplayer/demo/PlayerActivity.java
View file @
658a7ffb
...
@@ -35,7 +35,8 @@ import com.google.android.exoplayer.metadata.GeobMetadata;
...
@@ -35,7 +35,8 @@ import com.google.android.exoplayer.metadata.GeobMetadata;
import
com.google.android.exoplayer.metadata.PrivMetadata
;
import
com.google.android.exoplayer.metadata.PrivMetadata
;
import
com.google.android.exoplayer.metadata.TxxxMetadata
;
import
com.google.android.exoplayer.metadata.TxxxMetadata
;
import
com.google.android.exoplayer.text.CaptionStyleCompat
;
import
com.google.android.exoplayer.text.CaptionStyleCompat
;
import
com.google.android.exoplayer.text.SubtitleView
;
import
com.google.android.exoplayer.text.Cue
;
import
com.google.android.exoplayer.text.SubtitleLayout
;
import
com.google.android.exoplayer.util.Util
;
import
com.google.android.exoplayer.util.Util
;
import
com.google.android.exoplayer.util.VerboseLogUtil
;
import
com.google.android.exoplayer.util.VerboseLogUtil
;
...
@@ -43,12 +44,10 @@ import android.annotation.TargetApi;
...
@@ -43,12 +44,10 @@ import android.annotation.TargetApi;
import
android.app.Activity
;
import
android.app.Activity
;
import
android.content.Context
;
import
android.content.Context
;
import
android.content.Intent
;
import
android.content.Intent
;
import
android.graphics.Point
;
import
android.net.Uri
;
import
android.net.Uri
;
import
android.os.Bundle
;
import
android.os.Bundle
;
import
android.text.TextUtils
;
import
android.text.TextUtils
;
import
android.util.Log
;
import
android.util.Log
;
import
android.view.Display
;
import
android.view.KeyEvent
;
import
android.view.KeyEvent
;
import
android.view.Menu
;
import
android.view.Menu
;
import
android.view.MenuItem
;
import
android.view.MenuItem
;
...
@@ -58,7 +57,6 @@ import android.view.View;
...
@@ -58,7 +57,6 @@ import android.view.View;
import
android.view.View.OnClickListener
;
import
android.view.View.OnClickListener
;
import
android.view.View.OnKeyListener
;
import
android.view.View.OnKeyListener
;
import
android.view.View.OnTouchListener
;
import
android.view.View.OnTouchListener
;
import
android.view.WindowManager
;
import
android.view.accessibility.CaptioningManager
;
import
android.view.accessibility.CaptioningManager
;
import
android.widget.Button
;
import
android.widget.Button
;
import
android.widget.MediaController
;
import
android.widget.MediaController
;
...
@@ -67,13 +65,14 @@ import android.widget.PopupMenu.OnMenuItemClickListener;
...
@@ -67,13 +65,14 @@ import android.widget.PopupMenu.OnMenuItemClickListener;
import
android.widget.TextView
;
import
android.widget.TextView
;
import
android.widget.Toast
;
import
android.widget.Toast
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Map
;
/**
/**
* An activity that plays media using {@link DemoPlayer}.
* An activity that plays media using {@link DemoPlayer}.
*/
*/
public
class
PlayerActivity
extends
Activity
implements
SurfaceHolder
.
Callback
,
OnClickListener
,
public
class
PlayerActivity
extends
Activity
implements
SurfaceHolder
.
Callback
,
OnClickListener
,
DemoPlayer
.
Listener
,
DemoPlayer
.
Text
Listener
,
DemoPlayer
.
Id3MetadataListener
,
DemoPlayer
.
Listener
,
DemoPlayer
.
Caption
Listener
,
DemoPlayer
.
Id3MetadataListener
,
AudioCapabilitiesReceiver
.
Listener
{
AudioCapabilitiesReceiver
.
Listener
{
public
static
final
String
CONTENT_TYPE_EXTRA
=
"content_type"
;
public
static
final
String
CONTENT_TYPE_EXTRA
=
"content_type"
;
...
@@ -81,7 +80,6 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
...
@@ -81,7 +80,6 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
private
static
final
String
TAG
=
"PlayerActivity"
;
private
static
final
String
TAG
=
"PlayerActivity"
;
private
static
final
float
CAPTION_LINE_HEIGHT_RATIO
=
0.0533f
;
private
static
final
int
MENU_GROUP_TRACKS
=
1
;
private
static
final
int
MENU_GROUP_TRACKS
=
1
;
private
static
final
int
ID_OFFSET
=
2
;
private
static
final
int
ID_OFFSET
=
2
;
...
@@ -92,7 +90,7 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
...
@@ -92,7 +90,7 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
private
VideoSurfaceView
surfaceView
;
private
VideoSurfaceView
surfaceView
;
private
TextView
debugTextView
;
private
TextView
debugTextView
;
private
TextView
playerStateTextView
;
private
TextView
playerStateTextView
;
private
Subtitle
View
subtitleView
;
private
Subtitle
Layout
subtitleLayout
;
private
Button
videoButton
;
private
Button
videoButton
;
private
Button
audioButton
;
private
Button
audioButton
;
private
Button
textButton
;
private
Button
textButton
;
...
@@ -154,7 +152,7 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
...
@@ -154,7 +152,7 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
debugTextView
=
(
TextView
)
findViewById
(
R
.
id
.
debug_text_view
);
debugTextView
=
(
TextView
)
findViewById
(
R
.
id
.
debug_text_view
);
playerStateTextView
=
(
TextView
)
findViewById
(
R
.
id
.
player_state_view
);
playerStateTextView
=
(
TextView
)
findViewById
(
R
.
id
.
player_state_view
);
subtitle
View
=
(
SubtitleView
)
findViewById
(
R
.
id
.
subtitles
);
subtitle
Layout
=
(
SubtitleLayout
)
findViewById
(
R
.
id
.
subtitles
);
mediaController
=
new
MediaController
(
this
);
mediaController
=
new
MediaController
(
this
);
mediaController
.
setAnchorView
(
root
);
mediaController
.
setAnchorView
(
root
);
...
@@ -256,7 +254,7 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
...
@@ -256,7 +254,7 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
if
(
player
==
null
)
{
if
(
player
==
null
)
{
player
=
new
DemoPlayer
(
getRendererBuilder
());
player
=
new
DemoPlayer
(
getRendererBuilder
());
player
.
addListener
(
this
);
player
.
addListener
(
this
);
player
.
set
Text
Listener
(
this
);
player
.
set
Caption
Listener
(
this
);
player
.
setMetadataListener
(
this
);
player
.
setMetadataListener
(
this
);
player
.
seekTo
(
playerPosition
);
player
.
seekTo
(
playerPosition
);
playerNeedsPrepare
=
true
;
playerNeedsPrepare
=
true
;
...
@@ -464,16 +462,11 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
...
@@ -464,16 +462,11 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
debugRootView
.
setVisibility
(
View
.
VISIBLE
);
debugRootView
.
setVisibility
(
View
.
VISIBLE
);
}
}
// DemoPlayer.
Text
Listener implementation
// DemoPlayer.
Caption
Listener implementation
@Override
@Override
public
void
onText
(
String
text
)
{
public
void
onCues
(
List
<
Cue
>
cues
)
{
if
(
TextUtils
.
isEmpty
(
text
))
{
subtitleLayout
.
setCues
(
cues
);
subtitleView
.
setVisibility
(
View
.
INVISIBLE
);
}
else
{
subtitleView
.
setVisibility
(
View
.
VISIBLE
);
subtitleView
.
setText
(
text
);
}
}
}
// DemoPlayer.MetadataListener implementation
// DemoPlayer.MetadataListener implementation
...
@@ -523,24 +516,16 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
...
@@ -523,24 +516,16 @@ public class PlayerActivity extends Activity implements SurfaceHolder.Callback,
private
void
configureSubtitleView
()
{
private
void
configureSubtitleView
()
{
CaptionStyleCompat
captionStyle
;
CaptionStyleCompat
captionStyle
;
float
caption
TextSize
=
getCaptionFontSize
()
;
float
caption
FontScale
;
if
(
Util
.
SDK_INT
>=
19
)
{
if
(
Util
.
SDK_INT
>=
19
)
{
captionStyle
=
getUserCaptionStyleV19
();
captionStyle
=
getUserCaptionStyleV19
();
caption
TextSize
*
=
getUserCaptionFontScaleV19
();
caption
FontScale
=
getUserCaptionFontScaleV19
();
}
else
{
}
else
{
captionStyle
=
CaptionStyleCompat
.
DEFAULT
;
captionStyle
=
CaptionStyleCompat
.
DEFAULT
;
captionFontScale
=
1.0f
;
}
}
subtitleView
.
setStyle
(
captionStyle
);
subtitleLayout
.
setStyle
(
captionStyle
);
subtitleView
.
setTextSize
(
captionTextSize
);
subtitleLayout
.
setFontScale
(
captionFontScale
);
}
private
float
getCaptionFontSize
()
{
Display
display
=
((
WindowManager
)
getSystemService
(
Context
.
WINDOW_SERVICE
))
.
getDefaultDisplay
();
Point
displaySize
=
new
Point
();
display
.
getSize
(
displaySize
);
return
Math
.
max
(
getResources
().
getDimension
(
R
.
dimen
.
subtitle_minimum_font_size
),
CAPTION_LINE_HEIGHT_RATIO
*
Math
.
min
(
displaySize
.
x
,
displaySize
.
y
));
}
}
@TargetApi
(
19
)
@TargetApi
(
19
)
...
...
demo/src/main/java/com/google/android/exoplayer/demo/player/DemoPlayer.java
View file @
658a7ffb
...
@@ -31,6 +31,7 @@ import com.google.android.exoplayer.dash.DashChunkSource;
...
@@ -31,6 +31,7 @@ import com.google.android.exoplayer.dash.DashChunkSource;
import
com.google.android.exoplayer.drm.StreamingDrmSessionManager
;
import
com.google.android.exoplayer.drm.StreamingDrmSessionManager
;
import
com.google.android.exoplayer.hls.HlsSampleSource
;
import
com.google.android.exoplayer.hls.HlsSampleSource
;
import
com.google.android.exoplayer.metadata.MetadataTrackRenderer
;
import
com.google.android.exoplayer.metadata.MetadataTrackRenderer
;
import
com.google.android.exoplayer.text.Cue
;
import
com.google.android.exoplayer.text.TextRenderer
;
import
com.google.android.exoplayer.text.TextRenderer
;
import
com.google.android.exoplayer.upstream.DefaultBandwidthMeter
;
import
com.google.android.exoplayer.upstream.DefaultBandwidthMeter
;
import
com.google.android.exoplayer.util.PlayerControl
;
import
com.google.android.exoplayer.util.PlayerControl
;
...
@@ -41,6 +42,8 @@ import android.os.Looper;
...
@@ -41,6 +42,8 @@ import android.os.Looper;
import
android.view.Surface
;
import
android.view.Surface
;
import
java.io.IOException
;
import
java.io.IOException
;
import
java.util.Collections
;
import
java.util.List
;
import
java.util.Map
;
import
java.util.Map
;
import
java.util.concurrent.CopyOnWriteArrayList
;
import
java.util.concurrent.CopyOnWriteArrayList
;
...
@@ -140,8 +143,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
...
@@ -140,8 +143,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
/**
/**
* A listener for receiving notifications of timed text.
* A listener for receiving notifications of timed text.
*/
*/
public
interface
Text
Listener
{
public
interface
Caption
Listener
{
void
on
Text
(
String
text
);
void
on
Cues
(
List
<
Cue
>
cues
);
}
}
/**
/**
...
@@ -193,7 +196,7 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
...
@@ -193,7 +196,7 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
private
int
[]
selectedTracks
;
private
int
[]
selectedTracks
;
private
boolean
backgrounded
;
private
boolean
backgrounded
;
private
TextListener
text
Listener
;
private
CaptionListener
caption
Listener
;
private
Id3MetadataListener
id3MetadataListener
;
private
Id3MetadataListener
id3MetadataListener
;
private
InternalErrorListener
internalErrorListener
;
private
InternalErrorListener
internalErrorListener
;
private
InfoListener
infoListener
;
private
InfoListener
infoListener
;
...
@@ -232,8 +235,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
...
@@ -232,8 +235,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
infoListener
=
listener
;
infoListener
=
listener
;
}
}
public
void
set
TextListener
(
Text
Listener
listener
)
{
public
void
set
CaptionListener
(
Caption
Listener
listener
)
{
text
Listener
=
listener
;
caption
Listener
=
listener
;
}
}
public
void
setMetadataListener
(
Id3MetadataListener
listener
)
{
public
void
setMetadataListener
(
Id3MetadataListener
listener
)
{
...
@@ -268,8 +271,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
...
@@ -268,8 +271,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
}
}
selectedTracks
[
type
]
=
index
;
selectedTracks
[
type
]
=
index
;
pushTrackSelection
(
type
,
true
);
pushTrackSelection
(
type
,
true
);
if
(
type
==
TYPE_TEXT
&&
index
==
DISABLED_TRACK
&&
text
Listener
!=
null
)
{
if
(
type
==
TYPE_TEXT
&&
index
==
DISABLED_TRACK
&&
caption
Listener
!=
null
)
{
textListener
.
onText
(
null
);
captionListener
.
onCues
(
Collections
.<
Cue
>
emptyList
()
);
}
}
}
}
...
@@ -509,8 +512,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
...
@@ -509,8 +512,8 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
}
}
@Override
@Override
public
void
on
Text
(
String
text
)
{
public
void
on
Cues
(
List
<
Cue
>
cues
)
{
process
Text
(
text
);
process
Cues
(
cues
);
}
}
@Override
@Override
...
@@ -617,11 +620,11 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
...
@@ -617,11 +620,11 @@ public class DemoPlayer implements ExoPlayer.Listener, ChunkSampleSource.EventLi
}
}
}
}
/* package */
void
process
Text
(
String
text
)
{
/* package */
void
process
Cues
(
List
<
Cue
>
cues
)
{
if
(
text
Listener
==
null
||
selectedTracks
[
TYPE_TEXT
]
==
DISABLED_TRACK
)
{
if
(
caption
Listener
==
null
||
selectedTracks
[
TYPE_TEXT
]
==
DISABLED_TRACK
)
{
return
;
return
;
}
}
textListener
.
onText
(
text
);
captionListener
.
onCues
(
cues
);
}
}
private
class
InternalRendererBuilderCallback
implements
RendererBuilderCallback
{
private
class
InternalRendererBuilderCallback
implements
RendererBuilderCallback
{
...
...
demo/src/main/res/layout/player_activity.xml
View file @
658a7ffb
...
@@ -26,14 +26,12 @@
...
@@ -26,14 +26,12 @@
android:layout_height=
"match_parent"
android:layout_height=
"match_parent"
android:layout_gravity=
"center"
/>
android:layout_gravity=
"center"
/>
<com.google.android.exoplayer.text.SubtitleView
android:id=
"@+id/subtitles"
<com.google.android.exoplayer.text.SubtitleLayout
android:id=
"@+id/subtitles"
android:layout_width=
"wrap_content"
android:layout_width=
"match_parent"
android:layout_height=
"wrap_content"
android:layout_height=
"match_parent"
android:layout_gravity=
"bottom|center_horizontal"
android:layout_marginLeft=
"16dp"
android:layout_marginLeft=
"16dp"
android:layout_marginRight=
"16dp"
android:layout_marginRight=
"16dp"
android:layout_marginBottom=
"32dp"
android:layout_marginBottom=
"32dp"
/>
android:visibility=
"invisible"
/>
<View
android:id=
"@+id/shutter"
<View
android:id=
"@+id/shutter"
android:layout_width=
"match_parent"
android:layout_width=
"match_parent"
...
...
library/src/main/java/com/google/android/exoplayer/text/Cue.java
0 → 100644
View file @
658a7ffb
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package
com
.
google
.
android
.
exoplayer
.
text
;
import
android.text.Layout.Alignment
;
/**
* Contains information about a specific cue, including textual content and formatting data.
*/
public
class
Cue
{
/**
* Used by some methods to indicate that no value is set.
*/
public
static
final
int
UNSET_VALUE
=
-
1
;
public
final
CharSequence
text
;
public
final
int
line
;
public
final
int
position
;
public
final
Alignment
alignment
;
public
final
int
size
;
public
Cue
()
{
this
(
null
);
}
public
Cue
(
CharSequence
text
)
{
this
(
text
,
UNSET_VALUE
,
UNSET_VALUE
,
null
,
UNSET_VALUE
);
}
public
Cue
(
CharSequence
text
,
int
line
,
int
position
,
Alignment
alignment
,
int
size
)
{
this
.
text
=
text
;
this
.
line
=
line
;
this
.
position
=
position
;
this
.
alignment
=
alignment
;
this
.
size
=
size
;
}
}
library/src/main/java/com/google/android/exoplayer/text/Subtitle.java
View file @
658a7ffb
...
@@ -15,6 +15,8 @@
...
@@ -15,6 +15,8 @@
*/
*/
package
com
.
google
.
android
.
exoplayer
.
text
;
package
com
.
google
.
android
.
exoplayer
.
text
;
import
java.util.List
;
/**
/**
* A subtitle that contains textual data associated with time indices.
* A subtitle that contains textual data associated with time indices.
*/
*/
...
@@ -39,8 +41,8 @@ public interface Subtitle {
...
@@ -39,8 +41,8 @@ public interface Subtitle {
public
int
getNextEventTimeIndex
(
long
timeUs
);
public
int
getNextEventTimeIndex
(
long
timeUs
);
/**
/**
* Gets the number of event times, where events are defined as points in time at which the
text
* Gets the number of event times, where events are defined as points in time at which the
cues
* returned by {@link #get
Text
(long)} changes.
* returned by {@link #get
Cues
(long)} changes.
*
*
* @return The number of event times.
* @return The number of event times.
*/
*/
...
@@ -62,11 +64,11 @@ public interface Subtitle {
...
@@ -62,11 +64,11 @@ public interface Subtitle {
public
long
getLastEventTime
();
public
long
getLastEventTime
();
/**
/**
* Retrieve the subtitle
text
that should be displayed at a given time.
* Retrieve the subtitle
cues
that should be displayed at a given time.
*
*
* @param timeUs The time in microseconds.
* @param timeUs The time in microseconds.
* @return
The text that should be displayed, or null
.
* @return
A list of cues that should be displayed, possibly empty
.
*/
*/
public
String
getText
(
long
timeUs
);
public
List
<
Cue
>
getCues
(
long
timeUs
);
}
}
library/src/main/java/com/google/android/exoplayer/text/SubtitleLayout.java
0 → 100644
View file @
658a7ffb
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package
com
.
google
.
android
.
exoplayer
.
text
;
import
android.content.Context
;
import
android.text.Layout.Alignment
;
import
android.util.AttributeSet
;
import
android.view.ViewGroup
;
import
java.util.ArrayList
;
import
java.util.List
;
/**
* A view for rendering rich-formatted captions.
*/
public
final
class
SubtitleLayout
extends
ViewGroup
{
/**
* Use the same line height ratio as WebVtt to match the display with the preview.
* WebVtt specifies line height as 5.3% of the viewport height.
*/
private
static
final
float
LINE_HEIGHT_RATIO
=
0.0533f
;
private
final
List
<
SubtitleView
>
subtitleViews
;
private
List
<
Cue
>
subtitleCues
;
private
int
viewsInUse
;
private
float
fontScale
;
private
float
textSize
;
private
CaptionStyleCompat
captionStyle
;
public
SubtitleLayout
(
Context
context
)
{
this
(
context
,
null
);
}
public
SubtitleLayout
(
Context
context
,
AttributeSet
attrs
)
{
super
(
context
,
attrs
);
subtitleViews
=
new
ArrayList
<
SubtitleView
>();
fontScale
=
1
;
captionStyle
=
CaptionStyleCompat
.
DEFAULT
;
}
/**
* Sets the cues to be displayed by the view.
*
* @param cues The cues to display.
*/
public
void
setCues
(
List
<
Cue
>
cues
)
{
subtitleCues
=
cues
;
int
size
=
(
cues
==
null
)
?
0
:
cues
.
size
();
// create new subtitle views if necessary
if
(
size
>
subtitleViews
.
size
())
{
for
(
int
i
=
subtitleViews
.
size
();
i
<
size
;
i
++)
{
SubtitleView
newView
=
createSubtitleView
();
subtitleViews
.
add
(
newView
);
}
}
// add the views we currently need, if necessary
for
(
int
i
=
viewsInUse
;
i
<
size
;
i
++)
{
addView
(
subtitleViews
.
get
(
i
));
}
// remove the views we don't currently need, if necessary
for
(
int
i
=
size
;
i
<
viewsInUse
;
i
++)
{
removeView
(
subtitleViews
.
get
(
i
));
}
viewsInUse
=
size
;
for
(
int
i
=
0
;
i
<
size
;
i
++)
{
subtitleViews
.
get
(
i
).
setText
(
cues
.
get
(
i
).
text
);
}
requestLayout
();
}
/**
* Sets the scale of the font.
*
* @param scale The scale of the font.
*/
public
void
setFontScale
(
float
scale
)
{
fontScale
=
scale
;
updateSubtitlesTextSize
();
for
(
SubtitleView
subtitleView
:
subtitleViews
)
{
subtitleView
.
setTextSize
(
textSize
);
}
requestLayout
();
}
/**
* Configures the view according to the given style.
*
* @param captionStyle A style for the view.
*/
public
void
setStyle
(
CaptionStyleCompat
captionStyle
)
{
this
.
captionStyle
=
captionStyle
;
for
(
SubtitleView
subtitleView
:
subtitleViews
)
{
subtitleView
.
setStyle
(
captionStyle
);
}
requestLayout
();
}
@Override
protected
void
onMeasure
(
int
widthMeasureSpec
,
int
heightMeasureSpec
)
{
int
width
=
MeasureSpec
.
getSize
(
widthMeasureSpec
);
int
height
=
MeasureSpec
.
getSize
(
heightMeasureSpec
);
setMeasuredDimension
(
width
,
height
);
updateSubtitlesTextSize
();
for
(
int
i
=
0
;
i
<
viewsInUse
;
i
++)
{
subtitleViews
.
get
(
i
).
setTextSize
(
textSize
);
subtitleViews
.
get
(
i
).
measure
(
MeasureSpec
.
makeMeasureSpec
(
width
,
MeasureSpec
.
AT_MOST
),
MeasureSpec
.
makeMeasureSpec
(
height
,
MeasureSpec
.
AT_MOST
));
}
}
@Override
protected
void
onLayout
(
boolean
changed
,
int
left
,
int
top
,
int
right
,
int
bottom
)
{
int
width
=
right
-
left
;
int
height
=
bottom
-
top
;
for
(
int
i
=
0
;
i
<
viewsInUse
;
i
++)
{
SubtitleView
subtitleView
=
subtitleViews
.
get
(
i
);
Cue
subtitleCue
=
subtitleCues
.
get
(
i
);
int
viewLeft
=
(
width
-
subtitleView
.
getMeasuredWidth
())
/
2
;
int
viewRight
=
viewLeft
+
subtitleView
.
getMeasuredWidth
();
int
viewTop
=
bottom
-
subtitleView
.
getMeasuredHeight
();
int
viewBottom
=
bottom
;
if
(
subtitleCue
.
alignment
!=
null
)
{
subtitleView
.
setTextAlignment
(
subtitleCue
.
alignment
);
}
else
{
subtitleView
.
setTextAlignment
(
Alignment
.
ALIGN_CENTER
);
}
if
(
subtitleCue
.
position
!=
Cue
.
UNSET_VALUE
)
{
if
(
subtitleCue
.
alignment
==
Alignment
.
ALIGN_OPPOSITE
)
{
viewRight
=
(
int
)
((
width
*
(
double
)
subtitleCue
.
position
)
/
100
)
+
left
;
viewLeft
=
Math
.
max
(
viewRight
-
subtitleView
.
getMeasuredWidth
(),
left
);
}
else
{
viewLeft
=
(
int
)
((
width
*
(
double
)
subtitleCue
.
position
)
/
100
)
+
left
;
viewRight
=
Math
.
min
(
viewLeft
+
subtitleView
.
getMeasuredWidth
(),
right
);
}
}
if
(
subtitleCue
.
line
!=
Cue
.
UNSET_VALUE
)
{
viewTop
=
(
int
)
(
height
*
(
double
)
subtitleCue
.
line
/
100
)
+
top
;
viewBottom
=
viewTop
+
subtitleView
.
getMeasuredHeight
();
if
(
viewBottom
>
bottom
)
{
viewTop
=
bottom
-
subtitleView
.
getMeasuredHeight
();
viewBottom
=
bottom
;
}
}
subtitleView
.
layout
(
viewLeft
,
viewTop
,
viewRight
,
viewBottom
);
}
}
private
void
updateSubtitlesTextSize
()
{
textSize
=
LINE_HEIGHT_RATIO
*
getHeight
()
*
fontScale
;
}
private
SubtitleView
createSubtitleView
()
{
SubtitleView
view
=
new
SubtitleView
(
getContext
());
view
.
setStyle
(
captionStyle
);
view
.
setTextSize
(
textSize
);
return
view
;
}
}
library/src/main/java/com/google/android/exoplayer/text/SubtitleView.java
View file @
658a7ffb
...
@@ -28,6 +28,7 @@ import android.graphics.Paint.Join;
...
@@ -28,6 +28,7 @@ import android.graphics.Paint.Join;
import
android.graphics.Paint.Style
;
import
android.graphics.Paint.Style
;
import
android.graphics.RectF
;
import
android.graphics.RectF
;
import
android.graphics.Typeface
;
import
android.graphics.Typeface
;
import
android.text.Layout.Alignment
;
import
android.text.StaticLayout
;
import
android.text.StaticLayout
;
import
android.text.TextPaint
;
import
android.text.TextPaint
;
import
android.util.AttributeSet
;
import
android.util.AttributeSet
;
...
@@ -35,10 +36,7 @@ import android.util.DisplayMetrics;
...
@@ -35,10 +36,7 @@ import android.util.DisplayMetrics;
import
android.view.View
;
import
android.view.View
;
/**
/**
* A view for rendering captions.
* A view for rendering a single caption.
* <p>
* The caption style and text size can be configured using {@link #setStyle(CaptionStyleCompat)} and
* {@link #setTextSize(float)} respectively.
*/
*/
public
class
SubtitleView
extends
View
{
public
class
SubtitleView
extends
View
{
...
@@ -52,11 +50,6 @@ public class SubtitleView extends View {
...
@@ -52,11 +50,6 @@ public class SubtitleView extends View {
*/
*/
private
final
RectF
lineBounds
=
new
RectF
();
private
final
RectF
lineBounds
=
new
RectF
();
/**
* Reusable string builder used for holding text.
*/
private
final
StringBuilder
textBuilder
=
new
StringBuilder
();
// Styled dimensions.
// Styled dimensions.
private
final
float
cornerRadius
;
private
final
float
cornerRadius
;
private
final
float
outlineWidth
;
private
final
float
outlineWidth
;
...
@@ -66,6 +59,8 @@ public class SubtitleView extends View {
...
@@ -66,6 +59,8 @@ public class SubtitleView extends View {
private
TextPaint
textPaint
;
private
TextPaint
textPaint
;
private
Paint
paint
;
private
Paint
paint
;
private
CharSequence
text
;
private
int
foregroundColor
;
private
int
foregroundColor
;
private
int
backgroundColor
;
private
int
backgroundColor
;
private
int
edgeColor
;
private
int
edgeColor
;
...
@@ -75,10 +70,15 @@ public class SubtitleView extends View {
...
@@ -75,10 +70,15 @@ public class SubtitleView extends View {
private
int
lastMeasuredWidth
;
private
int
lastMeasuredWidth
;
private
StaticLayout
layout
;
private
StaticLayout
layout
;
private
Alignment
alignment
;
private
float
spacingMult
;
private
float
spacingMult
;
private
float
spacingAdd
;
private
float
spacingAdd
;
private
int
innerPaddingX
;
private
int
innerPaddingX
;
public
SubtitleView
(
Context
context
)
{
this
(
context
,
null
);
}
public
SubtitleView
(
Context
context
,
AttributeSet
attrs
)
{
public
SubtitleView
(
Context
context
,
AttributeSet
attrs
)
{
this
(
context
,
attrs
,
0
);
this
(
context
,
attrs
,
0
);
}
}
...
@@ -107,6 +107,8 @@ public class SubtitleView extends View {
...
@@ -107,6 +107,8 @@ public class SubtitleView extends View {
textPaint
.
setAntiAlias
(
true
);
textPaint
.
setAntiAlias
(
true
);
textPaint
.
setSubpixelText
(
true
);
textPaint
.
setSubpixelText
(
true
);
alignment
=
Alignment
.
ALIGN_CENTER
;
paint
=
new
Paint
();
paint
=
new
Paint
();
paint
.
setAntiAlias
(
true
);
paint
.
setAntiAlias
(
true
);
...
@@ -116,10 +118,6 @@ public class SubtitleView extends View {
...
@@ -116,10 +118,6 @@ public class SubtitleView extends View {
setStyle
(
CaptionStyleCompat
.
DEFAULT
);
setStyle
(
CaptionStyleCompat
.
DEFAULT
);
}
}
public
SubtitleView
(
Context
context
)
{
this
(
context
,
null
);
}
@Override
@Override
public
void
setBackgroundColor
(
int
color
)
{
public
void
setBackgroundColor
(
int
color
)
{
backgroundColor
=
color
;
backgroundColor
=
color
;
...
@@ -132,8 +130,7 @@ public class SubtitleView extends View {
...
@@ -132,8 +130,7 @@ public class SubtitleView extends View {
* @param text The text to display.
* @param text The text to display.
*/
*/
public
void
setText
(
CharSequence
text
)
{
public
void
setText
(
CharSequence
text
)
{
textBuilder
.
setLength
(
0
);
this
.
text
=
text
;
textBuilder
.
append
(
text
);
forceUpdate
(
true
);
forceUpdate
(
true
);
}
}
...
@@ -151,6 +148,15 @@ public class SubtitleView extends View {
...
@@ -151,6 +148,15 @@ public class SubtitleView extends View {
}
}
/**
/**
* Sets the text alignment.
*
* @param textAlignment The text alignment.
*/
public
void
setTextAlignment
(
Alignment
textAlignment
)
{
alignment
=
textAlignment
;
}
/**
* Configures the view according to the given style.
* Configures the view according to the given style.
*
*
* @param style A style for the view.
* @param style A style for the view.
...
@@ -227,8 +233,7 @@ public class SubtitleView extends View {
...
@@ -227,8 +233,7 @@ public class SubtitleView extends View {
hasMeasurements
=
true
;
hasMeasurements
=
true
;
lastMeasuredWidth
=
maxWidth
;
lastMeasuredWidth
=
maxWidth
;
layout
=
new
StaticLayout
(
textBuilder
,
textPaint
,
maxWidth
,
null
,
spacingMult
,
spacingAdd
,
layout
=
new
StaticLayout
(
text
,
textPaint
,
maxWidth
,
alignment
,
spacingMult
,
spacingAdd
,
true
);
true
);
return
true
;
return
true
;
}
}
...
...
library/src/main/java/com/google/android/exoplayer/text/TextRenderer.java
View file @
658a7ffb
...
@@ -15,16 +15,18 @@
...
@@ -15,16 +15,18 @@
*/
*/
package
com
.
google
.
android
.
exoplayer
.
text
;
package
com
.
google
.
android
.
exoplayer
.
text
;
import
java.util.List
;
/**
/**
* An interface for components that render text.
* An interface for components that render text.
*/
*/
public
interface
TextRenderer
{
public
interface
TextRenderer
{
/**
/**
* Invoked each time there is a change in the
text
to be rendered.
* Invoked each time there is a change in the
{@link Cue}s
to be rendered.
*
*
* @param
text The text to render, or null if no text is
to be rendered.
* @param
cues The {@link Cue}s to be rendered, or an empty list if no cues are
to be rendered.
*/
*/
void
on
Text
(
String
text
);
void
on
Cues
(
List
<
Cue
>
cues
);
}
}
library/src/main/java/com/google/android/exoplayer/text/TextTrackRenderer.java
View file @
658a7ffb
...
@@ -30,6 +30,8 @@ import android.os.Looper;
...
@@ -30,6 +30,8 @@ import android.os.Looper;
import
android.os.Message
;
import
android.os.Message
;
import
java.io.IOException
;
import
java.io.IOException
;
import
java.util.Collections
;
import
java.util.List
;
/**
/**
* A {@link TrackRenderer} for textual subtitles. The actual rendering of each line of text to a
* A {@link TrackRenderer} for textual subtitles. The actual rendering of each line of text to a
...
@@ -255,34 +257,36 @@ public class TextTrackRenderer extends TrackRenderer implements Callback {
...
@@ -255,34 +257,36 @@ public class TextTrackRenderer extends TrackRenderer implements Callback {
}
}
private
void
updateTextRenderer
(
long
positionUs
)
{
private
void
updateTextRenderer
(
long
positionUs
)
{
String
text
=
subtitle
.
getText
(
positionUs
);
List
<
Cue
>
cues
=
subtitle
.
getCues
(
positionUs
);
if
(
textRendererHandler
!=
null
)
{
if
(
textRendererHandler
!=
null
)
{
textRendererHandler
.
obtainMessage
(
MSG_UPDATE_OVERLAY
,
text
).
sendToTarget
();
textRendererHandler
.
obtainMessage
(
MSG_UPDATE_OVERLAY
,
cues
).
sendToTarget
();
}
else
{
}
else
{
invokeRendererInternal
(
text
);
invokeRendererInternal
Cues
(
cues
);
}
}
}
}
private
void
clearTextRenderer
()
{
private
void
clearTextRenderer
()
{
if
(
textRendererHandler
!=
null
)
{
if
(
textRendererHandler
!=
null
)
{
textRendererHandler
.
obtainMessage
(
MSG_UPDATE_OVERLAY
,
null
).
sendToTarget
();
textRendererHandler
.
obtainMessage
(
MSG_UPDATE_OVERLAY
,
Collections
.<
Cue
>
emptyList
())
.
sendToTarget
();
}
else
{
}
else
{
invokeRendererInternal
(
null
);
invokeRendererInternal
Cues
(
Collections
.<
Cue
>
emptyList
()
);
}
}
}
}
@SuppressWarnings
(
"unchecked"
)
@Override
@Override
public
boolean
handleMessage
(
Message
msg
)
{
public
boolean
handleMessage
(
Message
msg
)
{
switch
(
msg
.
what
)
{
switch
(
msg
.
what
)
{
case
MSG_UPDATE_OVERLAY:
case
MSG_UPDATE_OVERLAY:
invokeRendererInternal
((
String
)
msg
.
obj
);
invokeRendererInternal
Cues
((
List
<
Cue
>
)
msg
.
obj
);
return
true
;
return
true
;
}
}
return
false
;
return
false
;
}
}
private
void
invokeRendererInternal
(
String
text
)
{
private
void
invokeRendererInternal
Cues
(
List
<
Cue
>
cues
)
{
textRenderer
.
on
Text
(
text
);
textRenderer
.
on
Cues
(
cues
);
}
}
}
}
library/src/main/java/com/google/android/exoplayer/text/eia608/Eia608TrackRenderer.java
View file @
658a7ffb
...
@@ -21,6 +21,7 @@ import com.google.android.exoplayer.MediaFormatHolder;
...
@@ -21,6 +21,7 @@ import com.google.android.exoplayer.MediaFormatHolder;
import
com.google.android.exoplayer.SampleHolder
;
import
com.google.android.exoplayer.SampleHolder
;
import
com.google.android.exoplayer.SampleSource
;
import
com.google.android.exoplayer.SampleSource
;
import
com.google.android.exoplayer.TrackRenderer
;
import
com.google.android.exoplayer.TrackRenderer
;
import
com.google.android.exoplayer.text.Cue
;
import
com.google.android.exoplayer.text.TextRenderer
;
import
com.google.android.exoplayer.text.TextRenderer
;
import
com.google.android.exoplayer.util.Assertions
;
import
com.google.android.exoplayer.util.Assertions
;
import
com.google.android.exoplayer.util.Util
;
import
com.google.android.exoplayer.util.Util
;
...
@@ -31,6 +32,7 @@ import android.os.Looper;
...
@@ -31,6 +32,7 @@ import android.os.Looper;
import
android.os.Message
;
import
android.os.Message
;
import
java.io.IOException
;
import
java.io.IOException
;
import
java.util.Collections
;
import
java.util.TreeSet
;
import
java.util.TreeSet
;
/**
/**
...
@@ -227,8 +229,9 @@ public class Eia608TrackRenderer extends TrackRenderer implements Callback {
...
@@ -227,8 +229,9 @@ public class Eia608TrackRenderer extends TrackRenderer implements Callback {
return
false
;
return
false
;
}
}
private
void
invokeRendererInternal
(
String
text
)
{
private
void
invokeRendererInternal
(
String
cueText
)
{
textRenderer
.
onText
(
text
);
Cue
cue
=
new
Cue
(
cueText
);
textRenderer
.
onCues
(
Collections
.
singletonList
(
cue
));
}
}
private
void
maybeParsePendingSample
()
{
private
void
maybeParsePendingSample
()
{
...
...
library/src/main/java/com/google/android/exoplayer/text/ttml/TtmlSubtitle.java
View file @
658a7ffb
...
@@ -15,9 +15,13 @@
...
@@ -15,9 +15,13 @@
*/
*/
package
com
.
google
.
android
.
exoplayer
.
text
.
ttml
;
package
com
.
google
.
android
.
exoplayer
.
text
.
ttml
;
import
com.google.android.exoplayer.text.Cue
;
import
com.google.android.exoplayer.text.Subtitle
;
import
com.google.android.exoplayer.text.Subtitle
;
import
com.google.android.exoplayer.util.Util
;
import
com.google.android.exoplayer.util.Util
;
import
java.util.Collections
;
import
java.util.List
;
/**
/**
* A representation of a TTML subtitle.
* A representation of a TTML subtitle.
*/
*/
...
@@ -60,8 +64,14 @@ public final class TtmlSubtitle implements Subtitle {
...
@@ -60,8 +64,14 @@ public final class TtmlSubtitle implements Subtitle {
}
}
@Override
@Override
public
String
getText
(
long
timeUs
)
{
public
List
<
Cue
>
getCues
(
long
timeUs
)
{
return
root
.
getText
(
timeUs
-
startTimeUs
);
String
cueText
=
root
.
getText
(
timeUs
-
startTimeUs
);
if
(
cueText
==
null
)
{
return
Collections
.<
Cue
>
emptyList
();
}
else
{
Cue
cue
=
new
Cue
(
cueText
);
return
Collections
.
singletonList
(
cue
);
}
}
}
}
}
library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java
0 → 100644
View file @
658a7ffb
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package
com
.
google
.
android
.
exoplayer
.
text
.
webvtt
;
import
com.google.android.exoplayer.text.Cue
;
import
android.text.Layout.Alignment
;
/**
* A representation of a WebVTT cue.
*/
/* package */
final
class
WebvttCue
extends
Cue
{
public
final
long
startTime
;
public
final
long
endTime
;
public
WebvttCue
(
CharSequence
text
)
{
this
(
Cue
.
UNSET_VALUE
,
Cue
.
UNSET_VALUE
,
text
);
}
public
WebvttCue
(
long
startTime
,
long
endTime
,
CharSequence
text
)
{
this
(
startTime
,
endTime
,
text
,
Cue
.
UNSET_VALUE
,
Cue
.
UNSET_VALUE
,
null
,
Cue
.
UNSET_VALUE
);
}
public
WebvttCue
(
long
startTime
,
long
endTime
,
CharSequence
text
,
int
line
,
int
position
,
Alignment
alignment
,
int
size
)
{
super
(
text
,
line
,
position
,
alignment
,
size
);
this
.
startTime
=
startTime
;
this
.
endTime
=
endTime
;
}
/**
* Returns whether or not this cue should be placed in the default position and rolled-up with
* the other "normal" cues.
*
* @return True if this cue should be placed in the default position; false otherwise.
*/
public
boolean
isNormalCue
()
{
return
(
line
==
UNSET_VALUE
&&
position
==
UNSET_VALUE
);
}
}
library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java
View file @
658a7ffb
...
@@ -17,9 +17,14 @@ package com.google.android.exoplayer.text.webvtt;
...
@@ -17,9 +17,14 @@ package com.google.android.exoplayer.text.webvtt;
import
com.google.android.exoplayer.C
;
import
com.google.android.exoplayer.C
;
import
com.google.android.exoplayer.ParserException
;
import
com.google.android.exoplayer.ParserException
;
import
com.google.android.exoplayer.text.Cue
;
import
com.google.android.exoplayer.text.SubtitleParser
;
import
com.google.android.exoplayer.text.SubtitleParser
;
import
com.google.android.exoplayer.util.MimeTypes
;
import
com.google.android.exoplayer.util.MimeTypes
;
import
android.text.Html
;
import
android.text.Layout.Alignment
;
import
android.util.Log
;
import
java.io.BufferedReader
;
import
java.io.BufferedReader
;
import
java.io.IOException
;
import
java.io.IOException
;
import
java.io.InputStream
;
import
java.io.InputStream
;
...
@@ -35,6 +40,8 @@ import java.util.regex.Pattern;
...
@@ -35,6 +40,8 @@ import java.util.regex.Pattern;
*/
*/
public
class
WebvttParser
implements
SubtitleParser
{
public
class
WebvttParser
implements
SubtitleParser
{
static
final
String
TAG
=
"WebvttParser"
;
/**
/**
* This parser allows a custom header to be prepended to the WebVTT data, in the form of a text
* This parser allows a custom header to be prepended to the WebVTT data, in the form of a text
* line starting with this string.
* line starting with this string.
...
@@ -63,21 +70,26 @@ public class WebvttParser implements SubtitleParser {
...
@@ -63,21 +70,26 @@ public class WebvttParser implements SubtitleParser {
private
static
final
String
WEBVTT_TIMESTAMP_STRING
=
"(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}"
;
private
static
final
String
WEBVTT_TIMESTAMP_STRING
=
"(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}"
;
private
static
final
Pattern
WEBVTT_TIMESTAMP
=
Pattern
.
compile
(
WEBVTT_TIMESTAMP_STRING
);
private
static
final
Pattern
WEBVTT_TIMESTAMP
=
Pattern
.
compile
(
WEBVTT_TIMESTAMP_STRING
);
private
static
final
String
WEBVTT_CUE_SETTING_STRING
=
"\\S*:\\S*"
;
private
static
final
Pattern
WEBVTT_CUE_SETTING
=
Pattern
.
compile
(
WEBVTT_CUE_SETTING_STRING
);
private
static
final
Pattern
MEDIA_TIMESTAMP_OFFSET
=
Pattern
.
compile
(
OFFSET
+
"\\d+"
);
private
static
final
Pattern
MEDIA_TIMESTAMP_OFFSET
=
Pattern
.
compile
(
OFFSET
+
"\\d+"
);
private
static
final
Pattern
MEDIA_TIMESTAMP
=
Pattern
.
compile
(
"MPEGTS:\\d+"
);
private
static
final
Pattern
MEDIA_TIMESTAMP
=
Pattern
.
compile
(
"MPEGTS:\\d+"
);
private
static
final
String
WEBVTT_CUE_TAG_STRING
=
"\\<.*?>"
;
private
static
final
String
NON_NUMERIC_STRING
=
".*[^0-9].*"
;
private
final
StringBuilder
textBuilder
;
private
final
boolean
strictParsing
;
private
final
boolean
strictParsing
;
private
final
boolean
filterTags
;
public
WebvttParser
()
{
public
WebvttParser
()
{
this
(
true
,
true
);
this
(
true
);
}
}
public
WebvttParser
(
boolean
strictParsing
,
boolean
filterTags
)
{
public
WebvttParser
(
boolean
strictParsing
)
{
this
.
strictParsing
=
strictParsing
;
this
.
strictParsing
=
strictParsing
;
this
.
filterTags
=
filterTags
;
textBuilder
=
new
StringBuilder
();
}
}
@Override
@Override
...
@@ -145,6 +157,7 @@ public class WebvttParser implements SubtitleParser {
...
@@ -145,6 +157,7 @@ public class WebvttParser implements SubtitleParser {
// process the cues and text
// process the cues and text
while
((
line
=
webvttData
.
readLine
())
!=
null
)
{
while
((
line
=
webvttData
.
readLine
())
!=
null
)
{
// parse the cue identifier (if present) {
// parse the cue identifier (if present) {
Matcher
matcher
=
WEBVTT_CUE_IDENTIFIER
.
matcher
(
line
);
Matcher
matcher
=
WEBVTT_CUE_IDENTIFIER
.
matcher
(
line
);
if
(
matcher
.
find
())
{
if
(
matcher
.
find
())
{
...
@@ -152,11 +165,16 @@ public class WebvttParser implements SubtitleParser {
...
@@ -152,11 +165,16 @@ public class WebvttParser implements SubtitleParser {
line
=
webvttData
.
readLine
();
line
=
webvttData
.
readLine
();
}
}
long
startTime
=
Cue
.
UNSET_VALUE
;
long
endTime
=
Cue
.
UNSET_VALUE
;
CharSequence
text
=
null
;
int
lineNum
=
Cue
.
UNSET_VALUE
;
int
position
=
Cue
.
UNSET_VALUE
;
Alignment
alignment
=
null
;
int
size
=
Cue
.
UNSET_VALUE
;
// parse the cue timestamps
// parse the cue timestamps
matcher
=
WEBVTT_TIMESTAMP
.
matcher
(
line
);
matcher
=
WEBVTT_TIMESTAMP
.
matcher
(
line
);
long
startTime
;
long
endTime
;
String
text
=
""
;
// parse start timestamp
// parse start timestamp
if
(!
matcher
.
find
())
{
if
(!
matcher
.
find
())
{
...
@@ -166,36 +184,76 @@ public class WebvttParser implements SubtitleParser {
...
@@ -166,36 +184,76 @@ public class WebvttParser implements SubtitleParser {
}
}
// parse end timestamp
// parse end timestamp
String
endTimeString
;
if
(!
matcher
.
find
())
{
if
(!
matcher
.
find
())
{
throw
new
ParserException
(
"Expected cue end time: "
+
line
);
throw
new
ParserException
(
"Expected cue end time: "
+
line
);
}
else
{
}
else
{
endTime
=
parseTimestampUs
(
matcher
.
group
())
+
mediaTimestampUs
;
endTimeString
=
matcher
.
group
();
endTime
=
parseTimestampUs
(
endTimeString
)
+
mediaTimestampUs
;
}
// parse the (optional) cue setting list
line
=
line
.
substring
(
line
.
indexOf
(
endTimeString
)
+
endTimeString
.
length
());
matcher
=
WEBVTT_CUE_SETTING
.
matcher
(
line
);
while
(
matcher
.
find
())
{
String
match
=
matcher
.
group
();
String
[]
parts
=
match
.
split
(
":"
,
2
);
String
name
=
parts
[
0
];
String
value
=
parts
[
1
];
try
{
if
(
"line"
.
equals
(
name
))
{
if
(
value
.
endsWith
(
"%"
))
{
lineNum
=
parseIntPercentage
(
value
);
}
else
if
(
value
.
matches
(
NON_NUMERIC_STRING
))
{
Log
.
w
(
TAG
,
"Invalid line value: "
+
value
);
}
else
{
lineNum
=
Integer
.
parseInt
(
value
);
}
}
else
if
(
"align"
.
equals
(
name
))
{
// TODO: handle for RTL languages
if
(
"start"
.
equals
(
value
))
{
alignment
=
Alignment
.
ALIGN_NORMAL
;
}
else
if
(
"middle"
.
equals
(
value
))
{
alignment
=
Alignment
.
ALIGN_CENTER
;
}
else
if
(
"end"
.
equals
(
value
))
{
alignment
=
Alignment
.
ALIGN_OPPOSITE
;
}
else
if
(
"left"
.
equals
(
value
))
{
alignment
=
Alignment
.
ALIGN_NORMAL
;
}
else
if
(
"right"
.
equals
(
value
))
{
alignment
=
Alignment
.
ALIGN_OPPOSITE
;
}
else
{
Log
.
w
(
TAG
,
"Invalid align value: "
+
value
);
}
}
else
if
(
"position"
.
equals
(
name
))
{
position
=
parseIntPercentage
(
value
);
}
else
if
(
"size"
.
equals
(
name
))
{
size
=
parseIntPercentage
(
value
);
}
else
{
Log
.
w
(
TAG
,
"Unknown cue setting "
+
name
+
":"
+
value
);
}
}
catch
(
NumberFormatException
e
)
{
Log
.
w
(
TAG
,
name
+
" contains an invalid value "
+
value
,
e
);
}
}
}
// parse text
// parse text
textBuilder
.
setLength
(
0
);
while
(((
line
=
webvttData
.
readLine
())
!=
null
)
&&
(!
line
.
isEmpty
()))
{
while
(((
line
=
webvttData
.
readLine
())
!=
null
)
&&
(!
line
.
isEmpty
()))
{
text
+=
processCueText
(
line
.
trim
())
+
"\n"
;
if
(
textBuilder
.
length
()
>
0
)
{
textBuilder
.
append
(
"<br>"
);
}
textBuilder
.
append
(
line
.
trim
());
}
}
text
=
Html
.
fromHtml
(
textBuilder
.
toString
());
WebvttCue
cue
=
new
WebvttCue
(
startTime
,
endTime
,
text
);
WebvttCue
cue
=
new
WebvttCue
(
startTime
,
endTime
,
text
,
lineNum
,
position
,
alignment
,
size
);
subtitles
.
add
(
cue
);
subtitles
.
add
(
cue
);
}
}
webvttData
.
close
();
webvttData
.
close
();
inputStream
.
close
();
inputStream
.
close
();
WebvttSubtitle
subtitle
=
new
WebvttSubtitle
(
subtitles
,
mediaTimestampUs
);
// copy WebvttCue data into arrays for WebvttSubtitle constructor
String
[]
cueText
=
new
String
[
subtitles
.
size
()];
long
[]
cueTimesUs
=
new
long
[
2
*
subtitles
.
size
()];
for
(
int
subtitleIndex
=
0
;
subtitleIndex
<
subtitles
.
size
();
subtitleIndex
++)
{
int
arrayIndex
=
subtitleIndex
*
2
;
WebvttCue
cue
=
subtitles
.
get
(
subtitleIndex
);
cueTimesUs
[
arrayIndex
]
=
cue
.
startTime
;
cueTimesUs
[
arrayIndex
+
1
]
=
cue
.
endTime
;
cueText
[
subtitleIndex
]
=
cue
.
text
;
}
WebvttSubtitle
subtitle
=
new
WebvttSubtitle
(
cueText
,
mediaTimestampUs
,
cueTimesUs
);
return
subtitle
;
return
subtitle
;
}
}
...
@@ -208,25 +266,29 @@ public class WebvttParser implements SubtitleParser {
...
@@ -208,25 +266,29 @@ public class WebvttParser implements SubtitleParser {
return
startTimeUs
;
return
startTimeUs
;
}
}
protected
String
processCueText
(
String
line
)
{
if
(
filterTags
)
{
line
=
line
.
replaceAll
(
WEBVTT_CUE_TAG_STRING
,
""
);
line
=
line
.
replaceAll
(
"<"
,
"<"
);
line
=
line
.
replaceAll
(
">"
,
">"
);
line
=
line
.
replaceAll
(
" "
,
" "
);
line
=
line
.
replaceAll
(
"&"
,
"&"
);
return
line
;
}
else
{
return
line
;
}
}
protected
void
handleNoncompliantLine
(
String
line
)
throws
ParserException
{
protected
void
handleNoncompliantLine
(
String
line
)
throws
ParserException
{
if
(
strictParsing
)
{
if
(
strictParsing
)
{
throw
new
ParserException
(
"Unexpected line: "
+
line
);
throw
new
ParserException
(
"Unexpected line: "
+
line
);
}
}
}
}
private
static
int
parseIntPercentage
(
String
s
)
throws
NumberFormatException
{
if
(!
s
.
endsWith
(
"%"
))
{
throw
new
NumberFormatException
(
s
+
" doesn't end with '%'"
);
}
s
=
s
.
substring
(
0
,
s
.
length
()
-
1
);
if
(
s
.
matches
(
NON_NUMERIC_STRING
))
{
throw
new
NumberFormatException
(
s
+
" contains an invalid character"
);
}
int
value
=
Integer
.
parseInt
(
s
);
if
(
value
<
0
||
value
>
100
)
{
throw
new
NumberFormatException
(
value
+
" is out of range [0-100]"
);
}
return
value
;
}
private
static
long
parseTimestampUs
(
String
s
)
throws
NumberFormatException
{
private
static
long
parseTimestampUs
(
String
s
)
throws
NumberFormatException
{
if
(!
s
.
matches
(
WEBVTT_TIMESTAMP_STRING
))
{
if
(!
s
.
matches
(
WEBVTT_TIMESTAMP_STRING
))
{
throw
new
NumberFormatException
(
"has invalid format"
);
throw
new
NumberFormatException
(
"has invalid format"
);
...
@@ -240,16 +302,4 @@ public class WebvttParser implements SubtitleParser {
...
@@ -240,16 +302,4 @@ public class WebvttParser implements SubtitleParser {
return
(
value
*
1000
+
Long
.
parseLong
(
parts
[
1
]))
*
1000
;
return
(
value
*
1000
+
Long
.
parseLong
(
parts
[
1
]))
*
1000
;
}
}
private
static
class
WebvttCue
{
public
final
long
startTime
;
public
final
long
endTime
;
public
final
String
text
;
public
WebvttCue
(
long
startTime
,
long
endTime
,
String
text
)
{
this
.
startTime
=
startTime
;
this
.
endTime
=
endTime
;
this
.
text
=
text
;
}
}
}
}
library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttSubtitle.java
View file @
658a7ffb
...
@@ -15,32 +15,46 @@
...
@@ -15,32 +15,46 @@
*/
*/
package
com
.
google
.
android
.
exoplayer
.
text
.
webvtt
;
package
com
.
google
.
android
.
exoplayer
.
text
.
webvtt
;
import
com.google.android.exoplayer.text.Cue
;
import
com.google.android.exoplayer.text.Subtitle
;
import
com.google.android.exoplayer.text.Subtitle
;
import
com.google.android.exoplayer.util.Assertions
;
import
com.google.android.exoplayer.util.Assertions
;
import
com.google.android.exoplayer.util.Util
;
import
com.google.android.exoplayer.util.Util
;
import
android.text.SpannableStringBuilder
;
import
java.util.ArrayList
;
import
java.util.Arrays
;
import
java.util.Arrays
;
import
java.util.Collections
;
import
java.util.List
;
/**
/**
* A representation of a WebVTT subtitle.
* A representation of a WebVTT subtitle.
*/
*/
public
class
WebvttSubtitle
implements
Subtitle
{
public
class
WebvttSubtitle
implements
Subtitle
{
private
final
String
[]
cueText
;
private
final
List
<
WebvttCue
>
cues
;
private
final
int
numCues
;
private
final
long
startTimeUs
;
private
final
long
startTimeUs
;
private
final
long
[]
cueTimesUs
;
private
final
long
[]
cueTimesUs
;
private
final
long
[]
sortedCueTimesUs
;
private
final
long
[]
sortedCueTimesUs
;
/**
/**
* @param cue
Text Text to be displayed during each cu
e.
* @param cue
s A list of the cues in this subtitl
e.
* @param startTimeUs The start time of the subtitle.
* @param startTimeUs The start time of the subtitle.
* @param cueTimesUs Cue event times, where cueTimesUs[2 * i] and cueTimesUs[(2 * i) + 1] are
* the start and end times, respectively, corresponding to cueText[i].
*/
*/
public
WebvttSubtitle
(
String
[]
cueText
,
long
startTimeUs
,
long
[]
cueTimesUs
)
{
public
WebvttSubtitle
(
List
<
WebvttCue
>
cues
,
long
startTimeUs
)
{
this
.
cueText
=
cueText
;
this
.
cues
=
cues
;
numCues
=
cues
.
size
();
this
.
startTimeUs
=
startTimeUs
;
this
.
startTimeUs
=
startTimeUs
;
this
.
cueTimesUs
=
cueTimesUs
;
this
.
cueTimesUs
=
new
long
[
2
*
numCues
];
for
(
int
cueIndex
=
0
;
cueIndex
<
numCues
;
cueIndex
++)
{
WebvttCue
cue
=
cues
.
get
(
cueIndex
);
int
arrayIndex
=
cueIndex
*
2
;
cueTimesUs
[
arrayIndex
]
=
cue
.
startTime
;
cueTimesUs
[
arrayIndex
+
1
]
=
cue
.
endTime
;
}
this
.
sortedCueTimesUs
=
Arrays
.
copyOf
(
cueTimesUs
,
cueTimesUs
.
length
);
this
.
sortedCueTimesUs
=
Arrays
.
copyOf
(
cueTimesUs
,
cueTimesUs
.
length
);
Arrays
.
sort
(
sortedCueTimesUs
);
Arrays
.
sort
(
sortedCueTimesUs
);
}
}
...
@@ -78,22 +92,47 @@ public class WebvttSubtitle implements Subtitle {
...
@@ -78,22 +92,47 @@ public class WebvttSubtitle implements Subtitle {
}
}
@Override
@Override
public
String
getText
(
long
timeUs
)
{
public
List
<
Cue
>
getCues
(
long
timeUs
)
{
StringBuilder
stringBuilder
=
new
StringBuilder
();
ArrayList
<
Cue
>
list
=
null
;
WebvttCue
firstNormalCue
=
null
;
SpannableStringBuilder
normalCueTextBuilder
=
null
;
for
(
int
i
=
0
;
i
<
cueTimesUs
.
length
;
i
+=
2
)
{
for
(
int
i
=
0
;
i
<
numCues
;
i
++)
{
if
((
cueTimesUs
[
i
]
<=
timeUs
)
&&
(
timeUs
<
cueTimesUs
[
i
+
1
]))
{
if
((
cueTimesUs
[
i
*
2
]
<=
timeUs
)
&&
(
timeUs
<
cueTimesUs
[
i
*
2
+
1
]))
{
stringBuilder
.
append
(
cueText
[
i
/
2
]);
if
(
list
==
null
)
{
list
=
new
ArrayList
<
Cue
>();
}
WebvttCue
cue
=
cues
.
get
(
i
);
if
(
cue
.
isNormalCue
())
{
// we want to merge all of the normal cues into a single cue to ensure they are drawn
// correctly (i.e. don't overlap) and to emulate roll-up, but only if there are multiple
// normal cues, otherwise we can just append the single normal cue
if
(
firstNormalCue
==
null
)
{
firstNormalCue
=
cue
;
}
else
if
(
normalCueTextBuilder
==
null
)
{
normalCueTextBuilder
=
new
SpannableStringBuilder
();
normalCueTextBuilder
.
append
(
firstNormalCue
.
text
).
append
(
"\n"
).
append
(
cue
.
text
);
}
else
{
normalCueTextBuilder
.
append
(
"\n"
).
append
(
cue
.
text
);
}
}
else
{
list
.
add
(
cue
);
}
}
}
}
}
if
(
normalCueTextBuilder
!=
null
)
{
int
stringLength
=
stringBuilder
.
length
();
// there were multiple normal cues, so create a new cue with all of the text
if
(
stringLength
>
0
&&
stringBuilder
.
charAt
(
stringLength
-
1
)
==
'\n'
)
{
list
.
add
(
new
WebvttCue
(
normalCueTextBuilder
));
// Adjust the length to remove the trailing newline character.
}
else
if
(
firstNormalCue
!=
null
)
{
stringLength
-=
1
;
// there was only a single normal cue, so just add it to the list
list
.
add
(
firstNormalCue
);
}
}
return
stringLength
==
0
?
null
:
stringBuilder
.
substring
(
0
,
stringLength
);
if
(
list
!=
null
)
{
return
list
;
}
else
{
return
Collections
.<
Cue
>
emptyList
();
}
}
}
}
}
library/src/test/assets/webvtt/typical_with_tags
View file @
658a7ffb
...
@@ -11,4 +11,4 @@ This is the <b><i>second</b></i> subtitle.
...
@@ -11,4 +11,4 @@ This is the <b><i>second</b></i> subtitle.
This is the <c.red.caps>third</c> subtitle.
This is the <c.red.caps>third</c> subtitle.
00:06.000 --> 00:07.000
00:06.000 --> 00:07.000
This is
the <fourth> &subtitle.
This is
the <fourth> &subtitle.
library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java
View file @
658a7ffb
...
@@ -59,13 +59,13 @@ public class WebvttParserTest extends InstrumentationTestCase {
...
@@ -59,13 +59,13 @@ public class WebvttParserTest extends InstrumentationTestCase {
// test first cue
// test first cue
assertEquals
(
startTimeUs
,
subtitle
.
getEventTime
(
0
));
assertEquals
(
startTimeUs
,
subtitle
.
getEventTime
(
0
));
assertEquals
(
"This is the first subtitle."
,
assertEquals
(
"This is the first subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
0
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
0
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
1234000
,
subtitle
.
getEventTime
(
1
));
assertEquals
(
startTimeUs
+
1234000
,
subtitle
.
getEventTime
(
1
));
// test second cue
// test second cue
assertEquals
(
startTimeUs
+
2345000
,
subtitle
.
getEventTime
(
2
));
assertEquals
(
startTimeUs
+
2345000
,
subtitle
.
getEventTime
(
2
));
assertEquals
(
"This is the second subtitle."
,
assertEquals
(
"This is the second subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
2
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
2
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
3456000
,
subtitle
.
getEventTime
(
3
));
assertEquals
(
startTimeUs
+
3456000
,
subtitle
.
getEventTime
(
3
));
}
}
...
@@ -84,13 +84,13 @@ public class WebvttParserTest extends InstrumentationTestCase {
...
@@ -84,13 +84,13 @@ public class WebvttParserTest extends InstrumentationTestCase {
// test first cue
// test first cue
assertEquals
(
startTimeUs
,
subtitle
.
getEventTime
(
0
));
assertEquals
(
startTimeUs
,
subtitle
.
getEventTime
(
0
));
assertEquals
(
"This is the first subtitle."
,
assertEquals
(
"This is the first subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
0
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
0
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
1234000
,
subtitle
.
getEventTime
(
1
));
assertEquals
(
startTimeUs
+
1234000
,
subtitle
.
getEventTime
(
1
));
// test second cue
// test second cue
assertEquals
(
startTimeUs
+
2345000
,
subtitle
.
getEventTime
(
2
));
assertEquals
(
startTimeUs
+
2345000
,
subtitle
.
getEventTime
(
2
));
assertEquals
(
"This is the second subtitle."
,
assertEquals
(
"This is the second subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
2
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
2
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
3456000
,
subtitle
.
getEventTime
(
3
));
assertEquals
(
startTimeUs
+
3456000
,
subtitle
.
getEventTime
(
3
));
}
}
...
@@ -109,25 +109,25 @@ public class WebvttParserTest extends InstrumentationTestCase {
...
@@ -109,25 +109,25 @@ public class WebvttParserTest extends InstrumentationTestCase {
// test first cue
// test first cue
assertEquals
(
startTimeUs
,
subtitle
.
getEventTime
(
0
));
assertEquals
(
startTimeUs
,
subtitle
.
getEventTime
(
0
));
assertEquals
(
"This is the first subtitle."
,
assertEquals
(
"This is the first subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
0
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
0
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
1234000
,
subtitle
.
getEventTime
(
1
));
assertEquals
(
startTimeUs
+
1234000
,
subtitle
.
getEventTime
(
1
));
// test second cue
// test second cue
assertEquals
(
startTimeUs
+
2345000
,
subtitle
.
getEventTime
(
2
));
assertEquals
(
startTimeUs
+
2345000
,
subtitle
.
getEventTime
(
2
));
assertEquals
(
"This is the second subtitle."
,
assertEquals
(
"This is the second subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
2
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
2
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
3456000
,
subtitle
.
getEventTime
(
3
));
assertEquals
(
startTimeUs
+
3456000
,
subtitle
.
getEventTime
(
3
));
// test third cue
// test third cue
assertEquals
(
startTimeUs
+
4000000
,
subtitle
.
getEventTime
(
4
));
assertEquals
(
startTimeUs
+
4000000
,
subtitle
.
getEventTime
(
4
));
assertEquals
(
"This is the third subtitle."
,
assertEquals
(
"This is the third subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
4
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
4
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
5000000
,
subtitle
.
getEventTime
(
5
));
assertEquals
(
startTimeUs
+
5000000
,
subtitle
.
getEventTime
(
5
));
// test fourth cue
// test fourth cue
assertEquals
(
startTimeUs
+
6000000
,
subtitle
.
getEventTime
(
6
));
assertEquals
(
startTimeUs
+
6000000
,
subtitle
.
getEventTime
(
6
));
assertEquals
(
"This is the <fourth> &subtitle."
,
assertEquals
(
"This is the <fourth> &subtitle."
,
subtitle
.
get
Text
(
subtitle
.
getEventTime
(
6
)
));
subtitle
.
get
Cues
(
subtitle
.
getEventTime
(
6
)).
get
(
0
).
text
.
toString
(
));
assertEquals
(
startTimeUs
+
7000000
,
subtitle
.
getEventTime
(
7
));
assertEquals
(
startTimeUs
+
7000000
,
subtitle
.
getEventTime
(
7
));
}
}
...
...
library/src/test/java/com/google/android/exoplayer/text/webvtt/WebvttSubtitleTest.java
View file @
658a7ffb
This diff is collapsed.
Click to expand it.
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment