mirror of
https://github.com/tateisu/SubwayTooter
synced 2025-01-29 18:19:22 +01:00
TTSを利用したストリーム読み上げ機能を追加
This commit is contained in:
parent
9705a9ad56
commit
35aa48e1bc
@ -9,8 +9,8 @@ android {
|
||||
applicationId "jp.juggler.subwaytooter"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 25
|
||||
versionCode 81
|
||||
versionName "0.8.1"
|
||||
versionCode 82
|
||||
versionName "0.8.2"
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.speech.tts.TextToSpeech;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.customtabs.CustomTabsIntent;
|
||||
@ -134,6 +135,31 @@ public class ActMain extends AppCompatActivity
|
||||
if( savedInstanceState != null && sent_intent2 != null ){
|
||||
handleSentIntent( sent_intent2 );
|
||||
}
|
||||
|
||||
log.d("speech initialize start");
|
||||
if( app_state.tts == null ){
|
||||
new AsyncTask<Void,Void,TextToSpeech>(){
|
||||
TextToSpeech tmp_tts;
|
||||
@Override protected TextToSpeech doInBackground( Void... params ){
|
||||
tmp_tts = new TextToSpeech( getApplicationContext(), tts_init_listener );
|
||||
return tmp_tts;
|
||||
}
|
||||
final TextToSpeech.OnInitListener tts_init_listener = new TextToSpeech.OnInitListener() {
|
||||
@Override public void onInit( int status ){
|
||||
if (TextToSpeech.SUCCESS != status){
|
||||
log.d( "speech initialize failed. status=%s", status );
|
||||
}else{
|
||||
log.d( "speech initialize complete.");
|
||||
Utils.runOnMainThread( new Runnable() {
|
||||
@Override public void run(){
|
||||
app_state.setTextToSpeech( tmp_tts );
|
||||
}
|
||||
} );
|
||||
}
|
||||
}
|
||||
};
|
||||
}.executeOnExecutor( App1.task_executor );
|
||||
}
|
||||
}
|
||||
|
||||
@Override protected void onDestroy(){
|
||||
|
@ -3,6 +3,11 @@ package jp.juggler.subwaytooter;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Handler;
|
||||
import android.speech.tts.TextToSpeech;
|
||||
import android.speech.tts.UtteranceProgressListener;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.text.Spannable;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.json.JSONArray;
|
||||
@ -14,15 +19,21 @@ import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus;
|
||||
import jp.juggler.subwaytooter.table.SavedAccount;
|
||||
import jp.juggler.subwaytooter.util.LogCategory;
|
||||
import jp.juggler.subwaytooter.util.MyClickableSpan;
|
||||
import jp.juggler.subwaytooter.util.PostAttachment;
|
||||
import jp.juggler.subwaytooter.util.Utils;
|
||||
|
||||
class AppState {
|
||||
|
||||
static final LogCategory log = new LogCategory( "AppState" );
|
||||
final Context context;
|
||||
final float density;
|
||||
final SharedPreferences pref;
|
||||
@ -32,18 +43,18 @@ class AppState {
|
||||
|
||||
int media_thumb_height;
|
||||
|
||||
AppState( Context applicationContext ,SharedPreferences pref){
|
||||
AppState( Context applicationContext, SharedPreferences pref ){
|
||||
this.context = applicationContext;
|
||||
this.pref = pref;
|
||||
this.density = context.getResources().getDisplayMetrics().density;
|
||||
this.stream_reader = new StreamReader(applicationContext,pref);
|
||||
this.stream_reader = new StreamReader( applicationContext, pref );
|
||||
this.handler = new Handler();
|
||||
|
||||
loadColumnList();
|
||||
}
|
||||
|
||||
// データ保存用 および カラム一覧への伝達用
|
||||
static void saveColumnList( Context context,String fileName, JSONArray array ){
|
||||
static void saveColumnList( Context context, String fileName, JSONArray array ){
|
||||
|
||||
try{
|
||||
OutputStream os = context.openFileOutput( fileName, Context.MODE_PRIVATE );
|
||||
@ -59,12 +70,12 @@ class AppState {
|
||||
}
|
||||
|
||||
// データ保存用 および カラム一覧への伝達用
|
||||
static JSONArray loadColumnList( Context context,String fileName ){
|
||||
static JSONArray loadColumnList( Context context, String fileName ){
|
||||
try{
|
||||
InputStream is = context.openFileInput( fileName );
|
||||
try{
|
||||
ByteArrayOutputStream bao = new ByteArrayOutputStream( is.available() );
|
||||
IOUtils.copy( is,bao);
|
||||
IOUtils.copy( is, bao );
|
||||
return new JSONArray( Utils.decodeUTF8( bao.toByteArray() ) );
|
||||
}finally{
|
||||
is.close();
|
||||
@ -76,7 +87,6 @@ class AppState {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static final String FILE_COLUMN_LIST = "column_list";
|
||||
final ArrayList< Column > column_list = new ArrayList<>();
|
||||
@ -98,17 +108,17 @@ class AppState {
|
||||
|
||||
void saveColumnList(){
|
||||
JSONArray array = encodeColumnList();
|
||||
saveColumnList( context,FILE_COLUMN_LIST, array );
|
||||
saveColumnList( context, FILE_COLUMN_LIST, array );
|
||||
|
||||
}
|
||||
|
||||
private void loadColumnList(){
|
||||
JSONArray array = loadColumnList( context,FILE_COLUMN_LIST );
|
||||
JSONArray array = loadColumnList( context, FILE_COLUMN_LIST );
|
||||
if( array != null ){
|
||||
for( int i = 0, ie = array.length() ; i < ie ; ++ i ){
|
||||
try{
|
||||
JSONObject src = array.optJSONObject( i );
|
||||
Column col = new Column( this,src );
|
||||
Column col = new Column( this, src );
|
||||
column_list.add( col );
|
||||
}catch( Throwable ex ){
|
||||
ex.printStackTrace();
|
||||
@ -127,7 +137,7 @@ class AppState {
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
|
||||
|
||||
final HashSet< String > map_busy_boost = new HashSet<>();
|
||||
|
||||
boolean isBusyBoost( SavedAccount account, TootStatus status ){
|
||||
@ -137,7 +147,130 @@ class AppState {
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
|
||||
|
||||
ArrayList< PostAttachment > attachment_list = null;
|
||||
|
||||
TextToSpeech tts;
|
||||
|
||||
void setTextToSpeech( @NonNull TextToSpeech tmp_tts ){
|
||||
this.tts = tmp_tts;
|
||||
tts.setOnUtteranceProgressListener( new UtteranceProgressListener() {
|
||||
@Override public void onStart( String utteranceId ){
|
||||
log.d( "UtteranceProgressListener.onStart id=%s", utteranceId );
|
||||
}
|
||||
|
||||
@Override public void onDone( String utteranceId ){
|
||||
log.d( "UtteranceProgressListener.onStart id=%s", utteranceId );
|
||||
flushSpeechQueue();
|
||||
}
|
||||
|
||||
@Override public void onError( String utteranceId ){
|
||||
log.d( "UtteranceProgressListener.onError id=%s", utteranceId );
|
||||
flushSpeechQueue();
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
static final Pattern reURL = Pattern.compile( "\\bhttp(s)?://[:/?#@!$&'.,;=%()*+\\w\\-\\[\\]]+\\b" );
|
||||
static final Pattern reSpaces = Pattern.compile( "[\\s ]+" );
|
||||
|
||||
private static Spannable getStatusText( TootStatus status ){
|
||||
if( status == null ){
|
||||
return null;
|
||||
}
|
||||
|
||||
if( ! TextUtils.isEmpty( status.decoded_spoiler_text ) ){
|
||||
return status.decoded_spoiler_text;
|
||||
}
|
||||
|
||||
if( ! TextUtils.isEmpty( status.decoded_content ) ){
|
||||
return status.decoded_content;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void addSpeech( TootStatus status ){
|
||||
|
||||
if( tts == null ) return;
|
||||
|
||||
final Spannable text = getStatusText( status );
|
||||
if( text == null || text.length() == 0 ) return;
|
||||
|
||||
MyClickableSpan[] span_list = text.getSpans( 0, text.length(), MyClickableSpan.class );
|
||||
if( span_list == null || span_list.length == 0 ){
|
||||
addSpeech( text.toString() );
|
||||
return;
|
||||
}
|
||||
Arrays.sort( span_list, new Comparator< MyClickableSpan >() {
|
||||
@Override public int compare( MyClickableSpan a, MyClickableSpan b ){
|
||||
int a_start = text.getSpanStart( a );
|
||||
int b_start = text.getSpanStart( b );
|
||||
return a_start - b_start;
|
||||
}
|
||||
} );
|
||||
String str_text = text.toString();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int last_end = 0;
|
||||
for( MyClickableSpan span : span_list ){
|
||||
int start = text.getSpanStart( span );
|
||||
int end = text.getSpanEnd( span );
|
||||
//
|
||||
if( start > last_end ){
|
||||
sb.append( str_text.substring( last_end, start ) );
|
||||
}
|
||||
last_end = end;
|
||||
//
|
||||
String span_text = str_text.substring( start, end );
|
||||
if( span_text.length() > 0 ){
|
||||
char c = span_text.charAt( 0 );
|
||||
if( c == '#' || c == '@' ){
|
||||
// #hashtag や @user はそのまま読み上げる
|
||||
sb.append( span_text );
|
||||
}else{
|
||||
// それ以外はURL省略
|
||||
sb.append( context.getString( R.string.url_omitted ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
int text_end = str_text.length();
|
||||
if( text_end > last_end ){
|
||||
sb.append( str_text.substring( last_end, text_end ) );
|
||||
}
|
||||
addSpeech( sb.toString() );
|
||||
}
|
||||
|
||||
final LinkedList< String > tts_queue = new LinkedList<>();
|
||||
final LinkedList< String > duplication_check = new LinkedList<>();
|
||||
|
||||
private void addSpeech( String sv ){
|
||||
if( tts == null ) return;
|
||||
|
||||
sv = reSpaces.matcher( sv ).replaceAll( " " ).trim();
|
||||
if( TextUtils.isEmpty( sv ) ) return;
|
||||
|
||||
for( String check : duplication_check ){
|
||||
if( check.equals( sv ) ) return;
|
||||
}
|
||||
duplication_check.addLast( sv );
|
||||
if( duplication_check.size() >= 60 ){
|
||||
duplication_check.removeFirst();
|
||||
}
|
||||
|
||||
tts_queue.add( sv );
|
||||
flushSpeechQueue();
|
||||
}
|
||||
|
||||
static int utteranceIdSeed = 0;
|
||||
|
||||
void flushSpeechQueue(){
|
||||
if( tts_queue.isEmpty() ) return;
|
||||
if( tts.isSpeaking() ) return;
|
||||
String sv = tts_queue.removeFirst();
|
||||
tts.speak(
|
||||
sv
|
||||
, TextToSpeech.QUEUE_ADD // int queueMode
|
||||
, null // Bundle params
|
||||
, Integer.toString( ++ utteranceIdSeed ) // String utteranceId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -122,6 +122,7 @@ class Column implements StreamReader.Callback {
|
||||
private static final String KEY_DONT_STREAMING = "dont_streaming";
|
||||
private static final String KEY_DONT_AUTO_REFRESH = "dont_auto_refresh";
|
||||
private static final String KEY_HIDE_MEDIA_DEFAULT = "hide_media_default";
|
||||
private static final String KEY_ENABLE_SPEECH = "enable_speech";
|
||||
|
||||
private static final String KEY_REGEX_TEXT = "regex_text";
|
||||
|
||||
@ -174,6 +175,7 @@ class Column implements StreamReader.Callback {
|
||||
boolean dont_streaming;
|
||||
boolean dont_auto_refresh;
|
||||
boolean hide_media_default;
|
||||
boolean enable_speech;
|
||||
|
||||
String regex_text;
|
||||
|
||||
@ -240,6 +242,7 @@ class Column implements StreamReader.Callback {
|
||||
item.put( KEY_DONT_STREAMING, dont_streaming );
|
||||
item.put( KEY_DONT_AUTO_REFRESH, dont_auto_refresh );
|
||||
item.put( KEY_HIDE_MEDIA_DEFAULT, hide_media_default );
|
||||
item.put( KEY_ENABLE_SPEECH, enable_speech );
|
||||
|
||||
item.put( KEY_REGEX_TEXT, regex_text );
|
||||
|
||||
@ -292,6 +295,7 @@ class Column implements StreamReader.Callback {
|
||||
this.dont_streaming = src.optBoolean( KEY_DONT_STREAMING );
|
||||
this.dont_auto_refresh = src.optBoolean( KEY_DONT_AUTO_REFRESH );
|
||||
this.hide_media_default = src.optBoolean( KEY_HIDE_MEDIA_DEFAULT );
|
||||
this.enable_speech = src.optBoolean( KEY_ENABLE_SPEECH );
|
||||
|
||||
this.regex_text = Utils.optStringX( src, KEY_REGEX_TEXT );
|
||||
|
||||
@ -2562,6 +2566,10 @@ class Column implements StreamReader.Callback {
|
||||
if( column_type == TYPE_NOTIFICATIONS ) return;
|
||||
if( column_type == TYPE_LOCAL && status.account.acct.indexOf( '@' ) != - 1 ) return;
|
||||
if( isFiltered( status ) ) return;
|
||||
|
||||
if( this.enable_speech ){
|
||||
App1.getAppState( context ).addSpeech( status.reblog != null ? status.reblog : status);
|
||||
}
|
||||
}
|
||||
stream_data_queue.addFirst( o );
|
||||
proc_stream_data.run();
|
||||
@ -2697,6 +2705,11 @@ class Column implements StreamReader.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
boolean canSpeech(){
|
||||
return canStreaming() && column_type != TYPE_NOTIFICATIONS;
|
||||
}
|
||||
|
||||
|
||||
private boolean bPutGap;
|
||||
|
||||
private void resumeStreaming( boolean bPutGap ){
|
||||
|
@ -76,6 +76,7 @@ class ColumnViewHolder
|
||||
private final CheckBox cbDontStreaming;
|
||||
private final CheckBox cbDontAutoRefresh;
|
||||
private final CheckBox cbHideMediaDefault;
|
||||
private final CheckBox cbEnableSpeech;
|
||||
private final View llRegexFilter;
|
||||
private final Button btnDeleteNotification;
|
||||
|
||||
@ -131,6 +132,7 @@ class ColumnViewHolder
|
||||
cbDontStreaming = (CheckBox) root.findViewById( R.id.cbDontStreaming );
|
||||
cbDontAutoRefresh = (CheckBox) root.findViewById( R.id.cbDontAutoRefresh );
|
||||
cbHideMediaDefault = (CheckBox) root.findViewById( R.id.cbHideMediaDefault );
|
||||
cbEnableSpeech = (CheckBox) root.findViewById( R.id.cbEnableSpeech );
|
||||
etRegexFilter = (EditText) root.findViewById( R.id.etRegexFilter );
|
||||
llRegexFilter = root.findViewById( R.id.llRegexFilter );
|
||||
tvRegexFilterError = (TextView) root.findViewById( R.id.tvRegexFilterError );
|
||||
@ -158,6 +160,7 @@ class ColumnViewHolder
|
||||
cbDontStreaming.setOnCheckedChangeListener( this );
|
||||
cbDontAutoRefresh.setOnCheckedChangeListener( this );
|
||||
cbHideMediaDefault.setOnCheckedChangeListener( this );
|
||||
cbEnableSpeech.setOnCheckedChangeListener( this );
|
||||
|
||||
// 入力の追跡
|
||||
etRegexFilter.addTextChangedListener( new TextWatcher() {
|
||||
@ -275,6 +278,7 @@ class ColumnViewHolder
|
||||
cbDontStreaming.setChecked( column.dont_streaming );
|
||||
cbDontAutoRefresh.setChecked( column.dont_auto_refresh );
|
||||
cbHideMediaDefault.setChecked( column.hide_media_default );
|
||||
cbEnableSpeech.setChecked( column.enable_speech );
|
||||
|
||||
etRegexFilter.setText( column.regex_text );
|
||||
etSearch.setText( column.search_query );
|
||||
@ -286,6 +290,7 @@ class ColumnViewHolder
|
||||
vg( cbDontStreaming, column.canStreaming() );
|
||||
vg( cbDontAutoRefresh, column.canAutoRefresh() );
|
||||
vg( cbHideMediaDefault, column.canShowMedia() );
|
||||
vg( cbEnableSpeech, column.canSpeech() );
|
||||
|
||||
vg( etRegexFilter, bAllowFilter );
|
||||
vg( llRegexFilter, bAllowFilter );
|
||||
@ -563,6 +568,11 @@ class ColumnViewHolder
|
||||
activity.app_state.saveColumnList();
|
||||
column.fireShowContent();
|
||||
break;
|
||||
|
||||
case R.id.cbEnableSpeech:
|
||||
column.enable_speech = isChecked;
|
||||
activity.app_state.saveColumnList();
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -185,7 +185,12 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/hide_media_default"
|
||||
/>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/cbEnableSpeech"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/enable_speech"
|
||||
/>
|
||||
<LinearLayout
|
||||
android:id="@+id/llRegexFilter"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -354,6 +354,8 @@
|
||||
<string name="account_change_failed_old_draft_has_no_in_reply_to_url">Account change failed. Old draft data has no in_reply_to_url, can\'t convert in_repley_to for selected instance.</string>
|
||||
<string name="in_reply_to_id_conversion_failed">\"in_reply_to\" ID conversion failed.</string>
|
||||
<string name="prior_chrome_custom_tabs">Prior Chrome Custom Tabs</string>
|
||||
<string name="enable_speech">Enables speech</string>
|
||||
<string name="url_omitted">(URL omitted)</string>
|
||||
|
||||
<!--<string name="abc_action_bar_home_description">Revenir à l\'accueil</string>-->
|
||||
<!--<string name="abc_action_bar_home_description_format">%1$s, %2$s</string>-->
|
||||
|
@ -641,5 +641,7 @@
|
||||
<string name="account_change_failed_old_draft_has_no_in_reply_to_url">アカウント切り替えできません。古い下書きデータにはin_reply_to_urlがなくて、別タンスへの切り替えができません。</string>
|
||||
<string name="in_reply_to_id_conversion_failed">アカウント切り替えできません。in_reply_toのID変換に失敗しました。</string>
|
||||
<string name="prior_chrome_custom_tabs">Chrome Custom Tabs を優先的に利用する</string>
|
||||
<string name="enable_speech">読み上げを有効にする</string>
|
||||
<string name="url_omitted">(URL略)</string>
|
||||
|
||||
</resources>
|
||||
|
@ -350,4 +350,6 @@
|
||||
<string name="account_change_failed_old_draft_has_no_in_reply_to_url">Account change failed. Old draft data has no in_reply_to_url, can\'t convert in_repley_to for selected instance.</string>
|
||||
<string name="in_reply_to_id_conversion_failed">\"in_reply_to\" ID conversion failed.</string>
|
||||
<string name="prior_chrome_custom_tabs">Prior Chrome Custom Tabs</string>
|
||||
<string name="enable_speech">Enables speech</string>
|
||||
<string name="url_omitted">(URL omitted)</string>
|
||||
</resources>
|
||||
|
Loading…
x
Reference in New Issue
Block a user