1
0
mirror of https://github.com/tateisu/SubwayTooter synced 2025-01-29 18:19:22 +01:00

TTSを利用したストリーム読み上げ機能を追加

This commit is contained in:
tateisu 2017-06-21 13:24:07 +09:00
parent 9705a9ad56
commit 35aa48e1bc
9 changed files with 208 additions and 15 deletions

View File

@ -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"
}

View File

@ -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(){

View File

@ -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
);
}
}

View File

@ -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 ){

View File

@ -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;
}
}

View File

@ -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"

View File

@ -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>-->

View File

@ -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>

View File

@ -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>