From 3d5b4e6876ac63f8f66836f88d94321e0633e883 Mon Sep 17 00:00:00 2001 From: Philip White Date: Fri, 27 Sep 2019 20:26:15 -0400 Subject: [PATCH 01/64] Ignore vim files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 60d2c045..31c8fb6a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ /captures *debug* *release* +*.swp +*.swo From 5e7bd91b65190154d0e48dee7a861737491c4457 Mon Sep 17 00:00:00 2001 From: Philip White Date: Fri, 27 Sep 2019 22:06:57 -0400 Subject: [PATCH 02/64] Make phone request local laptop server --- app/build.gradle | 1 + .../controller/RegistrationActivity.kt | 8 ++++--- .../controller/remote/RemoteCheckFrag.java | 7 ++++-- .../tools/Network/VolleySingleton.java | 24 +++++++++++++++---- app/src/main/res/values/strings.xml | 12 +++++----- build.gradle | 2 +- gradle.properties | 3 ++- 7 files changed, 39 insertions(+), 18 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 9b72296b..12e9a84b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,6 +30,7 @@ android { } debug { versionNameSuffix ".debug" + buildConfigField "String", "ROCC_URL_PREFIX", ROCC_URL_PREFIX } } defaultConfig { diff --git a/app/src/main/java/org/sil/storyproducer/controller/RegistrationActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/RegistrationActivity.kt index b03bf3ff..8bef7bca 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/RegistrationActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/RegistrationActivity.kt @@ -26,6 +26,7 @@ import com.android.volley.Request import com.android.volley.Response import com.android.volley.toolbox.StringRequest import com.crashlytics.android.Crashlytics +import org.sil.storyproducer.BuildConfig import org.sil.storyproducer.R import org.sil.storyproducer.model.Workspace import org.sil.storyproducer.tools.Network.VolleySingleton @@ -338,8 +339,9 @@ open class RegistrationActivity : AppCompatActivity() { js["TrainerPhone"] = reg.getString("trainer_phone", " ") Log.i("LOG_VOLLEY", js.toString()) - val req = object : StringRequest(Request.Method.POST, getString(R.string.url_register_phone), Response.Listener { response -> - Log.i("LOG_VOLEY", response) + val registerPhoneUrl = BuildConfig.ROCC_URL_PREFIX + getString(R.string.url_register_phone) + val req = object : StringRequest(Request.Method.POST, registerPhoneUrl, Response.Listener { response -> + Log.i("LOG_VOLLEY", response) resp = response }, Response.ErrorListener { error -> Log.e("LOG_VOLLEY", error.toString()) @@ -702,4 +704,4 @@ class WorkspaceUpdateActivity : AppCompatActivity() { companion object { private val RQS_OPEN_DOCUMENT_TREE = 52 } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/sil/storyproducer/controller/remote/RemoteCheckFrag.java b/app/src/main/java/org/sil/storyproducer/controller/remote/RemoteCheckFrag.java index b8863bd3..c4dcdb30 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/remote/RemoteCheckFrag.java +++ b/app/src/main/java/org/sil/storyproducer/controller/remote/RemoteCheckFrag.java @@ -35,6 +35,7 @@ import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.sil.storyproducer.BuildConfig; import org.sil.storyproducer.R; import org.sil.storyproducer.controller.adapter.MessageAdapter; import org.sil.storyproducer.model.messaging.Message; @@ -264,7 +265,8 @@ private void sendMessage(){ // js.put("StoryTitle" , StoryState.getStoryName()); js.put("SlideNumber", Integer.toString(slideNumber)); - paramStringRequest req = new paramStringRequest(Request.Method.POST, getString(R.string.url_send_message), js, new Response.Listener() { + String sendMessagesUrl = BuildConfig.ROCC_URL_PREFIX + getString(R.string.url_send_message); + paramStringRequest req = new paramStringRequest(Request.Method.POST, sendMessagesUrl, js, new Response.Listener() { @Override public void onResponse(String response) { Log.i("LOG_VOLLEY_MSG", response.toString()); @@ -344,7 +346,8 @@ private void getMessages(){ js.put("LastId", Integer.toString(msgAdapter.getLastID())); - StringRequest req = new StringRequest(Request.Method.POST, getString(R.string.url_get_messages), new Response.Listener() { + String getMessagesUrl = BuildConfig.ROCC_URL_PREFIX + getString(R.string.url_get_messages); + StringRequest req = new StringRequest(Request.Method.POST, getMessagesUrl, new Response.Listener() { @Override public void onResponse(String response) { diff --git a/app/src/main/java/org/sil/storyproducer/tools/Network/VolleySingleton.java b/app/src/main/java/org/sil/storyproducer/tools/Network/VolleySingleton.java index ca808f8a..f51dc1ab 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/Network/VolleySingleton.java +++ b/app/src/main/java/org/sil/storyproducer/tools/Network/VolleySingleton.java @@ -6,6 +6,7 @@ import android.content.Context; import android.widget.Toast; +import org.sil.storyproducer.BuildConfig; import org.sil.storyproducer.R; /** @@ -49,20 +50,33 @@ public void addToRequestQueue(Request req) { if(isStopped){ //notify currently no connection - if(req.getUrl() == mCtx.getString(R.string.url_upload_audio)){ + String uploadAudioUrl = BuildConfig.ROCC_URL_PREFIX + mCtx.getString(R.string.url_upload_audio); + String registerPhoneUrl = BuildConfig.ROCC_URL_PREFIX + mCtx.getString(R.string.url_register_phone); + String sendMessageUrl = BuildConfig.ROCC_URL_PREFIX + mCtx.getString(R.string.url_send_message); + String getMessagesUrl = BuildConfig.ROCC_URL_PREFIX + mCtx.getString(R.string.url_get_messages); + String getSlideStatusUrl = BuildConfig.ROCC_URL_PREFIX + mCtx.getString(R.string.url_get_slide_status); + // TODO @pwhite: This is basically a switch statement on what + // type of request the parameter is. I'll have to look more + // thoroughly through the code to understand, but I think it + // would be better if we didn't have to compare URL strings to + // see which one of these kinds of requests it is. It would be + // better if there were an enum that has all possible kinds of + // requests. This would make it unnecessary to re-compute the + // request urls here. + if (req.getUrl() == uploadAudioUrl) { Toast.makeText(mCtx, R.string.queue_status_upload, Toast.LENGTH_SHORT).show(); - }else if(req.getUrl() == mCtx.getString(R.string.url_register_phone)){ + } else if (req.getUrl() == registerPhoneUrl) { Toast.makeText(mCtx, R.string.queue_status_register, Toast.LENGTH_SHORT).show(); //TODO: allow for the queueing of sending & receiving msgs right now it does //TODO: allow for this as it causes major issues //TODO: may eventually just want to use WebSockets for send/get msgs instead - }else if(req.getUrl() == mCtx.getString(R.string.url_send_message)){ + } else if (req.getUrl() == sendMessageUrl) { Toast.makeText(mCtx, R.string.remote_check_msg_no_connection, Toast.LENGTH_SHORT).show(); return; - }else if(req.getUrl() == mCtx.getString(R.string.url_get_messages)){ + } else if (req.getUrl() == getMessagesUrl) { Toast.makeText(mCtx, R.string.queue_status_message_get, Toast.LENGTH_SHORT).show(); return; - }else if(req.getUrl() == mCtx.getString(R.string.url_get_slide_status)){ + } else if (req.getUrl() == getSlideStatusUrl) { Toast.makeText(mCtx, R.string.queue_status_approved, Toast.LENGTH_SHORT).show(); return; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07568879..c951df66 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,12 +128,12 @@ No Connection: Cannot Retrieve New Messages No Connection: Registration Queued for Delivery No Connection: Cannot Retrieve Slide Status - https://storyproducer.eastus.cloudapp.azure.com/API/GetMessages.php - https://storyproducer.eastus.cloudapp.azure.com/API/SendMessage.php - https://storyproducer.eastus.cloudapp.azure.com/API/RequestRemoteReview.php - https://storyproducer.eastus.cloudapp.azure.com/API/GetSlideStatuses.php - https://storyproducer.eastus.cloudapp.azure.com/API/UploadSlideBacktranslation.php - https://storyproducer.eastus.cloudapp.azure.com/API/RegisterPhone.php + /API/GetMessages.php + /API/SendMessage.php + /API/RequestRemoteReview.php + /API/GetSlideStatuses.php + /API/UploadSlideBacktranslation.php + /API/RegisterPhone.php XUKYjBHCsD6OVla8dYAt298D9zkaKSqd Fill with Audio Comment diff --git a/build.gradle b/build.gradle index 083c9f19..dacdd370 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ buildscript { } dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.android.tools.build:gradle:3.5.0-beta05' + classpath 'com.android.tools.build:gradle:3.5.0' classpath 'com.google.gms:google-services:4.2.0' classpath 'io.fabric.tools:gradle:1.28.1' // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle.properties b/gradle.properties index 1d3591c8..c4ebd19c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,5 @@ # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true \ No newline at end of file +# org.gradle.parallel=true +ROCC_URL_PREFIX="http://192.168.10.118:3030" From 418d709fed99ba2849dd2f502d9c3fa73739eade Mon Sep 17 00:00:00 2001 From: Philip White Date: Tue, 1 Oct 2019 15:49:08 -0400 Subject: [PATCH 03/64] Make environment for building android without android-studio --- shell.nix | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 shell.nix diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..c606bbf8 --- /dev/null +++ b/shell.nix @@ -0,0 +1,17 @@ +with import {}; + +(buildFHSUserEnv { + name = "android-studio-less-gradle-builder"; + targetPkgs = pkgs: (with pkgs; [ + stdenv.cc.cc.lib + pkgsi686Linux.stdenv.cc.cc.lib + zlib + pkgsi686Linux.zlib + jdk + which + ]); + + runScript = '' + bash -c "JAVA_HOME=${jdk.home} bash" + ''; +}).env From 08d490cd53f2e3e2f6cd2eb67b057509f54f7f06 Mon Sep 17 00:00:00 2001 From: Philip White Date: Thu, 3 Oct 2019 13:59:54 -0400 Subject: [PATCH 04/64] Finish branch --- build.gradle | 5 +- gradle.properties | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 55616 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 22 ++- gradlew.bat | 184 ++++++++++++----------- 6 files changed, 125 insertions(+), 93 deletions(-) mode change 100644 => 100755 gradlew diff --git a/build.gradle b/build.gradle index dacdd370..1d01b504 100644 --- a/build.gradle +++ b/build.gradle @@ -3,8 +3,8 @@ buildscript { ext.kotlin_version = '1.3.40' repositories { - jcenter() google() + jcenter() mavenCentral() maven { url 'https://maven.fabric.io/public' @@ -24,13 +24,14 @@ apply plugin: "kotlin" allprojects { repositories { - jcenter() google() + jcenter() mavenCentral() maven { url "https://jitpack.io" } } } repositories { + google() mavenCentral() jcenter() } diff --git a/gradle.properties b/gradle.properties index c4ebd19c..29e0686f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,4 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -ROCC_URL_PREFIX="http://192.168.10.118:3030" +ROCC_URL_PREFIX="http://10.13.119.69:3030" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 01b8bf6b1f99cad9213fc495b33ad5bbab8efd20..5c2d1cf016b3885f6930543d57b744ea8c220a1a 100644 GIT binary patch literal 55616 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

<5G2M1WZP5 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 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From c7d7489ed119317368a61c004a5d6fbbe5fb6a02 Mon Sep 17 00:00:00 2001 From: Philip White Date: Fri, 11 Oct 2019 15:32:54 -0400 Subject: [PATCH 05/64] Convert files to unix line endings --- .../controller/MultiRecordFrag.kt | 364 +++++------ .../controller/SlidePhaseFrag.kt | 434 ++++++------- .../consultant/ConsultantCheckFrag.kt | 464 +++++++------- .../controller/learn/LearnActivity.kt | 572 +++++++++--------- .../controller/logging/LogListAdapter.kt | 148 ++--- .../java/org/sil/storyproducer/model/Phase.kt | 402 ++++++------ .../java/org/sil/storyproducer/model/Story.kt | 132 ++-- .../storyproducer/model/logging/LogEntry.kt | 120 ++-- .../tools/media/AudioRecorder.kt | 288 ++++----- .../tools/media/story/StoryFrameDrawer.kt | 396 ++++++------ 10 files changed, 1660 insertions(+), 1660 deletions(-) diff --git a/app/src/main/java/org/sil/storyproducer/controller/MultiRecordFrag.kt b/app/src/main/java/org/sil/storyproducer/controller/MultiRecordFrag.kt index 4531ac1a..dcc895f7 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/MultiRecordFrag.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/MultiRecordFrag.kt @@ -1,182 +1,182 @@ -package org.sil.storyproducer.controller - -import android.app.Activity -import android.app.AlertDialog -import android.content.Intent -import android.os.Bundle -import android.os.Environment -import android.provider.MediaStore -import android.support.v4.content.FileProvider -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.EditText -import android.widget.ImageView -import android.widget.Toast -import com.crashlytics.android.Crashlytics -import org.sil.storyproducer.BuildConfig -import org.sil.storyproducer.R -import org.sil.storyproducer.model.PROJECT_DIR -import org.sil.storyproducer.model.SLIDE_NUM -import org.sil.storyproducer.model.SlideType -import org.sil.storyproducer.model.Workspace -import org.sil.storyproducer.tools.file.copyToWorkspacePath -import org.sil.storyproducer.tools.toolbar.MultiRecordRecordingToolbar -import org.sil.storyproducer.tools.toolbar.PlayBackRecordingToolbar -import org.sil.storyproducer.tools.toolbar.RecordingToolbar -import java.io.File - -/** - * The fragment for the Draft view. This is where a user can draft out the story slide by slide - */ -abstract class MultiRecordFrag : SlidePhaseFrag(), PlayBackRecordingToolbar.ToolbarMediaListener { - protected open var recordingToolbar: RecordingToolbar = MultiRecordRecordingToolbar() - - private var tempPicFile: File? = null - - - override fun onCreateView(inflater: LayoutInflater, - container: ViewGroup?, savedInstanceState: Bundle?): View? { - super.onCreateView(inflater, container, savedInstanceState) - if (Workspace.activeStory.slides[slideNum].slideType != SlideType.LOCALCREDITS) { - setToolbar() - } - - setupCameraAndEditButton() - - return rootView - } - - /** - * Setup camera button for updating background image - * and edit button for renaming text and local credits - */ - fun setupCameraAndEditButton() { - // display the image selection button, if on the title slide - if(Workspace.activeStory.slides[slideNum].slideType in - arrayOf(SlideType.FRONTCOVER,SlideType.LOCALSONG)) - { - val imageFab: ImageView = rootView!!.findViewById(R.id.insert_image_view) as ImageView - imageFab.visibility = View.VISIBLE - imageFab.setOnClickListener { - val chooser = Intent(Intent.ACTION_CHOOSER) - chooser.putExtra(Intent.EXTRA_TITLE, "Select From:") - - val galleryIntent = Intent(Intent.ACTION_GET_CONTENT) - galleryIntent.type = "image/*" - chooser.putExtra(Intent.EXTRA_INTENT, galleryIntent) - - val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - cameraIntent.resolveActivity(activity!!.packageManager).also { - tempPicFile = File.createTempFile("temp", ".jpg", activity?.getExternalFilesDir(Environment.DIRECTORY_PICTURES)) - cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, FileProvider.getUriForFile(activity!!, "${BuildConfig.APPLICATION_ID}.fileprovider", tempPicFile!!)) - } - chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent)) - - startActivityForResult(chooser, ACTIVITY_SELECT_IMAGE) - } - } - - // display the image selection button, if on the title slide - if(Workspace.activeStory.slides[slideNum].slideType in - arrayOf(SlideType.FRONTCOVER,SlideType.LOCALCREDITS)) - { - //for these, use the edit text button instead of the text in the lower half. - //In the phases that these are not there, do nothing. - val editBox = rootView?.findViewById(R.id.fragment_dramatization_edit_text) as EditText? - editBox?.visibility = View.INVISIBLE - - val editFab = rootView!!.findViewById(R.id.edit_text_view) as ImageView? - editFab?.visibility = View.VISIBLE - editFab?.setOnClickListener { - val editText = EditText(context) - editText.id = R.id.edit_text_input - - // Programmatically set layout properties for edit text field - val params = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT) - // Apply layout properties - editText.layoutParams = params - editText.minLines = 5 - editText.text.insert(0,Workspace.activeSlide!!.translatedContent) - - val dialog = AlertDialog.Builder(context) - .setTitle(getString(R.string.enter_text)) - .setView(editText) - .setNegativeButton(getString(R.string.cancel), null) - .setPositiveButton(getString(R.string.save)) { _, _ -> - Workspace.activeSlide!!.translatedContent = editText.text.toString() - setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) - }.create() - - dialog.show() - } - } - } - - - /** - * Change the picture behind the screen. - */ - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - try { - if (resultCode == Activity.RESULT_OK && requestCode == ACTIVITY_SELECT_IMAGE) { - //copy image into workspace - var uri = data?.data - if (uri == null) uri = FileProvider.getUriForFile(context!!, "${BuildConfig.APPLICATION_ID}.fileprovider", tempPicFile!!) //it was a camera intent - Workspace.activeStory.slides[slideNum].imageFile = "$PROJECT_DIR/${slideNum}_Local.png" - copyToWorkspacePath(context!!, uri!!, - "${Workspace.activeStory.title}/${Workspace.activeStory.slides[slideNum].imageFile}") - tempPicFile?.delete() - setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) - } - }catch (e:Exception){ - Toast.makeText(context,"Error",Toast.LENGTH_SHORT).show() - Crashlytics.logException(e) - } - } - - /** - * This function serves to handle page changes and stops the audio streams from - * continuing. - * - * @param isVisibleToUser whether fragment is currently visible to user - */ - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - super.setUserVisibleHint(isVisibleToUser) - - // Make sure that we are currently visible - if (this.isVisible) { - // If we are becoming invisible, then... - if (!isVisibleToUser) { - recordingToolbar.stopToolbarMedia() - } - } - } - - protected open fun setToolbar() { - val bundle = Bundle() - bundle.putInt(SLIDE_NUM, slideNum) - recordingToolbar.arguments = bundle - childFragmentManager.beginTransaction().replace(R.id.toolbar_for_recording_toolbar, recordingToolbar).commit() - - recordingToolbar.keepToolbarVisible() - } - - override fun onStartedToolbarMedia() { - super.onStartedToolbarMedia() - - stopSlidePlayBack() - } - - override fun onStartedSlidePlayBack() { - super.onStartedSlidePlayBack() - - recordingToolbar.stopToolbarMedia() - } - - companion object { - private const val ACTIVITY_SELECT_IMAGE = 53 - } -} +package org.sil.storyproducer.controller + +import android.app.Activity +import android.app.AlertDialog +import android.content.Intent +import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import android.support.v4.content.FileProvider +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.ImageView +import android.widget.Toast +import com.crashlytics.android.Crashlytics +import org.sil.storyproducer.BuildConfig +import org.sil.storyproducer.R +import org.sil.storyproducer.model.PROJECT_DIR +import org.sil.storyproducer.model.SLIDE_NUM +import org.sil.storyproducer.model.SlideType +import org.sil.storyproducer.model.Workspace +import org.sil.storyproducer.tools.file.copyToWorkspacePath +import org.sil.storyproducer.tools.toolbar.MultiRecordRecordingToolbar +import org.sil.storyproducer.tools.toolbar.PlayBackRecordingToolbar +import org.sil.storyproducer.tools.toolbar.RecordingToolbar +import java.io.File + +/** + * The fragment for the Draft view. This is where a user can draft out the story slide by slide + */ +abstract class MultiRecordFrag : SlidePhaseFrag(), PlayBackRecordingToolbar.ToolbarMediaListener { + protected open var recordingToolbar: RecordingToolbar = MultiRecordRecordingToolbar() + + private var tempPicFile: File? = null + + + override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?): View? { + super.onCreateView(inflater, container, savedInstanceState) + if (Workspace.activeStory.slides[slideNum].slideType != SlideType.LOCALCREDITS) { + setToolbar() + } + + setupCameraAndEditButton() + + return rootView + } + + /** + * Setup camera button for updating background image + * and edit button for renaming text and local credits + */ + fun setupCameraAndEditButton() { + // display the image selection button, if on the title slide + if(Workspace.activeStory.slides[slideNum].slideType in + arrayOf(SlideType.FRONTCOVER,SlideType.LOCALSONG)) + { + val imageFab: ImageView = rootView!!.findViewById(R.id.insert_image_view) as ImageView + imageFab.visibility = View.VISIBLE + imageFab.setOnClickListener { + val chooser = Intent(Intent.ACTION_CHOOSER) + chooser.putExtra(Intent.EXTRA_TITLE, "Select From:") + + val galleryIntent = Intent(Intent.ACTION_GET_CONTENT) + galleryIntent.type = "image/*" + chooser.putExtra(Intent.EXTRA_INTENT, galleryIntent) + + val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + cameraIntent.resolveActivity(activity!!.packageManager).also { + tempPicFile = File.createTempFile("temp", ".jpg", activity?.getExternalFilesDir(Environment.DIRECTORY_PICTURES)) + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, FileProvider.getUriForFile(activity!!, "${BuildConfig.APPLICATION_ID}.fileprovider", tempPicFile!!)) + } + chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(cameraIntent)) + + startActivityForResult(chooser, ACTIVITY_SELECT_IMAGE) + } + } + + // display the image selection button, if on the title slide + if(Workspace.activeStory.slides[slideNum].slideType in + arrayOf(SlideType.FRONTCOVER,SlideType.LOCALCREDITS)) + { + //for these, use the edit text button instead of the text in the lower half. + //In the phases that these are not there, do nothing. + val editBox = rootView?.findViewById(R.id.fragment_dramatization_edit_text) as EditText? + editBox?.visibility = View.INVISIBLE + + val editFab = rootView!!.findViewById(R.id.edit_text_view) as ImageView? + editFab?.visibility = View.VISIBLE + editFab?.setOnClickListener { + val editText = EditText(context) + editText.id = R.id.edit_text_input + + // Programmatically set layout properties for edit text field + val params = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT) + // Apply layout properties + editText.layoutParams = params + editText.minLines = 5 + editText.text.insert(0,Workspace.activeSlide!!.translatedContent) + + val dialog = AlertDialog.Builder(context) + .setTitle(getString(R.string.enter_text)) + .setView(editText) + .setNegativeButton(getString(R.string.cancel), null) + .setPositiveButton(getString(R.string.save)) { _, _ -> + Workspace.activeSlide!!.translatedContent = editText.text.toString() + setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) + }.create() + + dialog.show() + } + } + } + + + /** + * Change the picture behind the screen. + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + try { + if (resultCode == Activity.RESULT_OK && requestCode == ACTIVITY_SELECT_IMAGE) { + //copy image into workspace + var uri = data?.data + if (uri == null) uri = FileProvider.getUriForFile(context!!, "${BuildConfig.APPLICATION_ID}.fileprovider", tempPicFile!!) //it was a camera intent + Workspace.activeStory.slides[slideNum].imageFile = "$PROJECT_DIR/${slideNum}_Local.png" + copyToWorkspacePath(context!!, uri!!, + "${Workspace.activeStory.title}/${Workspace.activeStory.slides[slideNum].imageFile}") + tempPicFile?.delete() + setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) + } + }catch (e:Exception){ + Toast.makeText(context,"Error",Toast.LENGTH_SHORT).show() + Crashlytics.logException(e) + } + } + + /** + * This function serves to handle page changes and stops the audio streams from + * continuing. + * + * @param isVisibleToUser whether fragment is currently visible to user + */ + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + + // Make sure that we are currently visible + if (this.isVisible) { + // If we are becoming invisible, then... + if (!isVisibleToUser) { + recordingToolbar.stopToolbarMedia() + } + } + } + + protected open fun setToolbar() { + val bundle = Bundle() + bundle.putInt(SLIDE_NUM, slideNum) + recordingToolbar.arguments = bundle + childFragmentManager.beginTransaction().replace(R.id.toolbar_for_recording_toolbar, recordingToolbar).commit() + + recordingToolbar.keepToolbarVisible() + } + + override fun onStartedToolbarMedia() { + super.onStartedToolbarMedia() + + stopSlidePlayBack() + } + + override fun onStartedSlidePlayBack() { + super.onStartedSlidePlayBack() + + recordingToolbar.stopToolbarMedia() + } + + companion object { + private const val ACTIVITY_SELECT_IMAGE = 53 + } +} diff --git a/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt b/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt index a55eb029..fc19dee1 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt @@ -1,217 +1,217 @@ -package org.sil.storyproducer.controller - -import android.media.MediaPlayer -import android.os.Bundle -import android.support.constraint.ConstraintLayout -import android.support.design.widget.Snackbar -import android.support.v4.app.Fragment -import android.view.* -import android.widget.* -import org.sil.storyproducer.R -import org.sil.storyproducer.controller.phase.PhaseBaseActivity -import org.sil.storyproducer.model.* -import org.sil.storyproducer.model.logging.saveLog -import org.sil.storyproducer.tools.file.storyRelPathExists -import org.sil.storyproducer.tools.media.AudioPlayer -import java.util.* - -/** - * The fragment for the Draft view. This is where a user can draft out the story slide by slide - */ -abstract class SlidePhaseFrag : Fragment() { - protected var rootView: View? = null - - protected var referenceAudioPlayer: AudioPlayer = AudioPlayer() - protected var referencePlayButton: ImageButton? = null - protected var refPlaybackSeekBar: SeekBar? = null - private var mSeekBarTimer = Timer() - - private var refPlaybackProgress = 0 - private var refPlaybackDuration = 0 - private var wasAudioPlaying = false - - - protected var slideNum: Int = 0 //gets overwritten - protected lateinit var slide: Slide - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - slideNum = this.arguments!!.getInt(SLIDE_NUM) - slide = Workspace.activeStory.slides[slideNum] - setHasOptionsMenu(true) - } - - override fun onCreateView(inflater: LayoutInflater, - container: ViewGroup?, savedInstanceState: Bundle?): View? { - // The last two arguments ensure LayoutParams are inflated - // properly. - rootView = inflater.inflate(R.layout.fragment_slide, container, false) - - setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) - - return rootView - } - - override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) { - val item = menu!!.getItem(0) - super.onCreateOptionsMenu(menu, inflater) - item.setIcon(R.drawable.ic_mic_white_48dp) - } - - - override fun onResume() { - super.onResume() - - referenceAudioPlayer = AudioPlayer() - referenceAudioPlayer.setStorySource(context!!,Workspace.activePhase.getReferenceAudioFile(slideNum)) - - referenceAudioPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { - referencePlayButton!!.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) - referenceAudioPlayer.stopAudio() - }) - - //If it is the local credits slide, do not show the audio stuff at all. - val refPlaybackHolder: ConstraintLayout = rootView!!.findViewById(R.id.seek_bar) - if(Workspace.activeStory.slides[slideNum].slideType == SlideType.LOCALCREDITS){ - refPlaybackHolder.visibility = View.GONE - }else{ - refPlaybackSeekBar = rootView!!.findViewById(R.id.videoSeekBar) - mSeekBarTimer = Timer() - mSeekBarTimer.schedule(object : TimerTask() { - override fun run() { - activity!!.runOnUiThread{ - refPlaybackProgress = referenceAudioPlayer.currentPosition - refPlaybackSeekBar?.progress = refPlaybackProgress - } - } - },0,33) - - setSeekBarListener() - } - } - - private fun setSeekBarListener() { - refPlaybackDuration = referenceAudioPlayer.audioDurationInMilliseconds - refPlaybackSeekBar?.max = refPlaybackDuration - referenceAudioPlayer.currentPosition = refPlaybackProgress - refPlaybackSeekBar?.progress = refPlaybackProgress - refPlaybackSeekBar?.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { - override fun onStopTrackingTouch(sBar: SeekBar) { - referenceAudioPlayer.currentPosition = refPlaybackProgress - if(wasAudioPlaying){ - referenceAudioPlayer.resumeAudio() - } - } - override fun onStartTrackingTouch(sBar: SeekBar) { - wasAudioPlaying = referenceAudioPlayer.isAudioPlaying - referenceAudioPlayer.pauseAudio() - referencePlayButton!!.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) - } - override fun onProgressChanged(sBar: SeekBar, progress: Int, fromUser: Boolean) { - if (fromUser) { - refPlaybackProgress = progress - } - } - }) - } - /** - * This function serves to stop the audio streams from continuing after the draft has been - * put on pause. - */ - override fun onPause() { - super.onPause() - refPlaybackProgress = referenceAudioPlayer.currentPosition - mSeekBarTimer.cancel() - referenceAudioPlayer.release() - } - - /** - * This function serves to handle page changes and stops the audio streams from - * continuing. - */ - - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - super.setUserVisibleHint(isVisibleToUser) - referenceAudioPlayer.stopAudio() - referencePlayButton?.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) - } - - /** - * This function allows the picture to scale with the phone's screen size. - * - * @param slideImage The ImageView that will contain the picture. - */ - protected fun setPic(slideImage: ImageView) { - - (activity as PhaseBaseActivity).setPic(slideImage, slideNum) - //Set up the reference audio and slide number overlays - referencePlayButton = rootView?.findViewById(R.id.fragment_reference_audio_button) - setReferenceAudioButton() - - val slideNumberText = rootView?.findViewById(R.id.slide_number_text) - slideNumberText?.text = slideNum.toString() - } - - /** - * Sets the main text of the layout. - * - * @param textView The text view that will be filled with the verse's text. - */ - protected fun setScriptureText(textView: TextView) { - textView.text = slide.content - } - - /** - * This function sets the reference text. - * - * @param textView The view that will be populated with the reference text. - */ - protected fun setReferenceText(textView: TextView) { - val titleNamePriority = arrayOf(slide.reference, slide.subtitle, slide.title) - - for (title in titleNamePriority) { - if (title != "") { - textView.text = title - return - } - } - //There is no reference text. - textView.text = "" - } - - private fun setReferenceAudioButton() { - referencePlayButton!!.setOnClickListener { - if (!storyRelPathExists(context!!,Workspace.activePhase.getReferenceAudioFile(slideNum))) { - //TODO make "no audio" string work for all phases - Snackbar.make(rootView!!, R.string.draft_playback_no_lwc_audio, Snackbar.LENGTH_SHORT).show() - } else { - //stop other playback streams. - if (referenceAudioPlayer.isAudioPlaying) { - stopSlidePlayBack() - refPlaybackProgress = referenceAudioPlayer.currentPosition - refPlaybackSeekBar?.progress = refPlaybackProgress - } else { - stopSlidePlayBack() - onStartedSlidePlayBack() - referenceAudioPlayer.currentPosition = refPlaybackProgress - referenceAudioPlayer.resumeAudio() - - referencePlayButton!!.setBackgroundResource(R.drawable.ic_pause_white_48dp) - Toast.makeText(context, R.string.draft_playback_lwc_audio, Toast.LENGTH_SHORT).show() - when(Workspace.activePhase.phaseType){ - PhaseType.DRAFT -> saveLog(activity!!.getString(R.string.LWC_PLAYBACK)) - PhaseType.COMMUNITY_CHECK -> saveLog(activity!!.getString(R.string.DRAFT_PLAYBACK)) - else -> {} - } - } - } - } - } - - protected fun stopSlidePlayBack() { - referenceAudioPlayer.pauseAudio() - referencePlayButton!!.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) - } - - open fun onStartedSlidePlayBack() {} -} +package org.sil.storyproducer.controller + +import android.media.MediaPlayer +import android.os.Bundle +import android.support.constraint.ConstraintLayout +import android.support.design.widget.Snackbar +import android.support.v4.app.Fragment +import android.view.* +import android.widget.* +import org.sil.storyproducer.R +import org.sil.storyproducer.controller.phase.PhaseBaseActivity +import org.sil.storyproducer.model.* +import org.sil.storyproducer.model.logging.saveLog +import org.sil.storyproducer.tools.file.storyRelPathExists +import org.sil.storyproducer.tools.media.AudioPlayer +import java.util.* + +/** + * The fragment for the Draft view. This is where a user can draft out the story slide by slide + */ +abstract class SlidePhaseFrag : Fragment() { + protected var rootView: View? = null + + protected var referenceAudioPlayer: AudioPlayer = AudioPlayer() + protected var referencePlayButton: ImageButton? = null + protected var refPlaybackSeekBar: SeekBar? = null + private var mSeekBarTimer = Timer() + + private var refPlaybackProgress = 0 + private var refPlaybackDuration = 0 + private var wasAudioPlaying = false + + + protected var slideNum: Int = 0 //gets overwritten + protected lateinit var slide: Slide + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + slideNum = this.arguments!!.getInt(SLIDE_NUM) + slide = Workspace.activeStory.slides[slideNum] + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?): View? { + // The last two arguments ensure LayoutParams are inflated + // properly. + rootView = inflater.inflate(R.layout.fragment_slide, container, false) + + setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) + + return rootView + } + + override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) { + val item = menu!!.getItem(0) + super.onCreateOptionsMenu(menu, inflater) + item.setIcon(R.drawable.ic_mic_white_48dp) + } + + + override fun onResume() { + super.onResume() + + referenceAudioPlayer = AudioPlayer() + referenceAudioPlayer.setStorySource(context!!,Workspace.activePhase.getReferenceAudioFile(slideNum)) + + referenceAudioPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { + referencePlayButton!!.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) + referenceAudioPlayer.stopAudio() + }) + + //If it is the local credits slide, do not show the audio stuff at all. + val refPlaybackHolder: ConstraintLayout = rootView!!.findViewById(R.id.seek_bar) + if(Workspace.activeStory.slides[slideNum].slideType == SlideType.LOCALCREDITS){ + refPlaybackHolder.visibility = View.GONE + }else{ + refPlaybackSeekBar = rootView!!.findViewById(R.id.videoSeekBar) + mSeekBarTimer = Timer() + mSeekBarTimer.schedule(object : TimerTask() { + override fun run() { + activity!!.runOnUiThread{ + refPlaybackProgress = referenceAudioPlayer.currentPosition + refPlaybackSeekBar?.progress = refPlaybackProgress + } + } + },0,33) + + setSeekBarListener() + } + } + + private fun setSeekBarListener() { + refPlaybackDuration = referenceAudioPlayer.audioDurationInMilliseconds + refPlaybackSeekBar?.max = refPlaybackDuration + referenceAudioPlayer.currentPosition = refPlaybackProgress + refPlaybackSeekBar?.progress = refPlaybackProgress + refPlaybackSeekBar?.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onStopTrackingTouch(sBar: SeekBar) { + referenceAudioPlayer.currentPosition = refPlaybackProgress + if(wasAudioPlaying){ + referenceAudioPlayer.resumeAudio() + } + } + override fun onStartTrackingTouch(sBar: SeekBar) { + wasAudioPlaying = referenceAudioPlayer.isAudioPlaying + referenceAudioPlayer.pauseAudio() + referencePlayButton!!.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) + } + override fun onProgressChanged(sBar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + refPlaybackProgress = progress + } + } + }) + } + /** + * This function serves to stop the audio streams from continuing after the draft has been + * put on pause. + */ + override fun onPause() { + super.onPause() + refPlaybackProgress = referenceAudioPlayer.currentPosition + mSeekBarTimer.cancel() + referenceAudioPlayer.release() + } + + /** + * This function serves to handle page changes and stops the audio streams from + * continuing. + */ + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + referenceAudioPlayer.stopAudio() + referencePlayButton?.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) + } + + /** + * This function allows the picture to scale with the phone's screen size. + * + * @param slideImage The ImageView that will contain the picture. + */ + protected fun setPic(slideImage: ImageView) { + + (activity as PhaseBaseActivity).setPic(slideImage, slideNum) + //Set up the reference audio and slide number overlays + referencePlayButton = rootView?.findViewById(R.id.fragment_reference_audio_button) + setReferenceAudioButton() + + val slideNumberText = rootView?.findViewById(R.id.slide_number_text) + slideNumberText?.text = slideNum.toString() + } + + /** + * Sets the main text of the layout. + * + * @param textView The text view that will be filled with the verse's text. + */ + protected fun setScriptureText(textView: TextView) { + textView.text = slide.content + } + + /** + * This function sets the reference text. + * + * @param textView The view that will be populated with the reference text. + */ + protected fun setReferenceText(textView: TextView) { + val titleNamePriority = arrayOf(slide.reference, slide.subtitle, slide.title) + + for (title in titleNamePriority) { + if (title != "") { + textView.text = title + return + } + } + //There is no reference text. + textView.text = "" + } + + private fun setReferenceAudioButton() { + referencePlayButton!!.setOnClickListener { + if (!storyRelPathExists(context!!,Workspace.activePhase.getReferenceAudioFile(slideNum))) { + //TODO make "no audio" string work for all phases + Snackbar.make(rootView!!, R.string.draft_playback_no_lwc_audio, Snackbar.LENGTH_SHORT).show() + } else { + //stop other playback streams. + if (referenceAudioPlayer.isAudioPlaying) { + stopSlidePlayBack() + refPlaybackProgress = referenceAudioPlayer.currentPosition + refPlaybackSeekBar?.progress = refPlaybackProgress + } else { + stopSlidePlayBack() + onStartedSlidePlayBack() + referenceAudioPlayer.currentPosition = refPlaybackProgress + referenceAudioPlayer.resumeAudio() + + referencePlayButton!!.setBackgroundResource(R.drawable.ic_pause_white_48dp) + Toast.makeText(context, R.string.draft_playback_lwc_audio, Toast.LENGTH_SHORT).show() + when(Workspace.activePhase.phaseType){ + PhaseType.DRAFT -> saveLog(activity!!.getString(R.string.LWC_PLAYBACK)) + PhaseType.COMMUNITY_CHECK -> saveLog(activity!!.getString(R.string.DRAFT_PLAYBACK)) + else -> {} + } + } + } + } + } + + protected fun stopSlidePlayBack() { + referenceAudioPlayer.pauseAudio() + referencePlayButton!!.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) + } + + open fun onStartedSlidePlayBack() {} +} diff --git a/app/src/main/java/org/sil/storyproducer/controller/consultant/ConsultantCheckFrag.kt b/app/src/main/java/org/sil/storyproducer/controller/consultant/ConsultantCheckFrag.kt index 8b66285e..da8a3442 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/consultant/ConsultantCheckFrag.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/consultant/ConsultantCheckFrag.kt @@ -1,232 +1,232 @@ -package org.sil.storyproducer.controller.consultant - -import android.content.Context -import android.os.Bundle -import android.support.graphics.drawable.VectorDrawableCompat -import android.support.v7.app.AlertDialog -import android.support.v7.widget.Toolbar -import android.text.InputType -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import android.widget.* -import org.sil.storyproducer.R -import org.sil.storyproducer.controller.SlidePhaseFrag -import org.sil.storyproducer.controller.logging.LogListAdapter -import org.sil.storyproducer.controller.phase.PhaseBaseActivity -import org.sil.storyproducer.model.Phase -import org.sil.storyproducer.model.PhaseType -import org.sil.storyproducer.model.SlideType -import org.sil.storyproducer.model.Workspace - -/** - * The fragment for the Consultant check view. The consultant can check that the draft is ok - */ -class ConsultantCheckFrag : SlidePhaseFrag() { - - var logDialog: AlertDialog? = null - var greenCheckmark: VectorDrawableCompat ?= null - var grayCheckmark: VectorDrawableCompat ?= null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - - greenCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_green, null) - grayCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_gray, null) - - // The last two arguments ensure LayoutParams are inflated - // properly. - rootView = inflater.inflate(R.layout.fragment_consultant_check, container, false) - - setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) - - setScriptureText(rootView!!.findViewById(R.id.fragment_scripture_text) as TextView) - setReferenceText(rootView!!.findViewById(R.id.fragment_reference_text) as TextView) - setCheckmarkButton(rootView!!.findViewById(R.id.concheck_checkmark_button) as ImageButton) - setLogsButton(rootView!!.findViewById(R.id.concheck_logs_button) as ImageButton) - - return rootView - } - - /** - * This function serves to handle page changes and stops the audio streams from - * continuing. - * @param isVisibleToUser - */ - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - super.setUserVisibleHint(isVisibleToUser) - - // Make sure that we are currently visible - if (this.isVisible) { - // If we are becoming invisible, then... - if (!isVisibleToUser) { - referenceAudioPlayer.stopAudio() - } - } - } - - - /** - * Sets on click listener for consultant to check off the slide and approve - * @param button the check button - */ - private fun setCheckmarkButton(button: ImageButton) { - if (Workspace.activeStory.slides[slideNum].isChecked) { - button.background = greenCheckmark - } else { - button.background = grayCheckmark - } - button.setOnClickListener(View.OnClickListener { - if (Workspace.activeStory.isApproved) { - Toast.makeText(context, "Story already approved", Toast.LENGTH_SHORT).show() - return@OnClickListener - } - if (Workspace.activeStory.slides[slideNum].isChecked) { - button.background = grayCheckmark - Workspace.activeStory.slides[slideNum].isChecked = false - } else { - button.background = greenCheckmark - Workspace.activeStory.slides[slideNum].isChecked = true - if (checkAllMarked()) { - showConsultantPasswordDialog() - } - } - }) - } - - /** - * Set an on click listener to launch the interface to view the logs for that slide - * @param button the logs button - */ - private fun setLogsButton(button: ImageButton) { - //TODO: use non-deprecated method; currently used to support older devices - button.background = VectorDrawableCompat.create(resources, R.drawable.ic_logs_blue, null) - button.setOnClickListener { - makeLogView() - logDialog?.show() - } - } - - private fun makeLogView() { - val alertDialog = android.support.v7.app.AlertDialog.Builder(context!!) - val linf = context!!.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val dialogLayout = linf.inflate(R.layout.activity_log_view, null) - - val listView = dialogLayout!!.findViewById(R.id.log_list_view) - val lla = LogListAdapter(context!!, slideNum) - listView.adapter = lla - val tb = dialogLayout.findViewById(R.id.toolbar2) - //Note that user-facing slide number is 1-based while it is 0-based in code. - tb.title = context!!.getString(R.string.logging_slide_log_view_title, slideNum) - val exit = dialogLayout.findViewById(R.id.exitButton) - val learnCB = dialogLayout.findViewById(R.id.LearnCheckBox) - val draftCB = dialogLayout.findViewById(R.id.DraftCheckBox) - val comChkCB = dialogLayout.findViewById(R.id.CommunityCheckCheckBox) - learnCB.setOnCheckedChangeListener { _, checked -> lla.updateList(checked, draftCB.isChecked, comChkCB.isChecked) } - draftCB.setOnCheckedChangeListener { _, checked -> lla.updateList(learnCB.isChecked, checked, comChkCB.isChecked) } - comChkCB.setOnCheckedChangeListener { _, checked -> lla.updateList(learnCB.isChecked, draftCB.isChecked, checked) } - alertDialog.setView(dialogLayout) - logDialog = alertDialog.create() - exit.setOnClickListener { - logDialog?.dismiss() - } - } - - - /** - * Checks each slide of the story to see if all slides have been approved - * @return true if all approved, otherwise false - */ - private fun checkAllMarked(): Boolean { - for (slide in Workspace.activeStory.slides) { - if (!slide.isChecked && slide.slideType in - arrayOf(SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE,SlideType.LOCALSONG)) { - return false - } - } - return true - } - - /** - * Launches a dialog for the consultant to enter a password once all slides approved - */ - private fun showConsultantPasswordDialog() { - val password = EditText(context) - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - password.id = org.sil.storyproducer.R.id.password_text_field; - - // Programmatically set layout properties for edit text field - val params = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT) - // Apply layout properties - password.layoutParams = params - val passwordDialog = AlertDialog.Builder(context!!) - .setTitle(getString(R.string.consultant_password_title)) - .setMessage(getString(R.string.consultant_password_message)) - .setView(password) - .setNegativeButton(getString(R.string.cancel), null) - .setPositiveButton(getString(R.string.submit), null) - .create() - // This is set to dismiss the keyboard manually on dialog dismiss - passwordDialog.setOnDismissListener { toggleKeyboard(false, view) } - - // This manually sets the submit button listener so that the dialog doesn't always submit - // If the password is incorrect, we want to stay on the dialog and give an error message - passwordDialog.setOnShowListener { dialog -> - val button = (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) - button.setOnClickListener { - val passwordText = password.text.toString() - if (passwordText.contentEquals(PASSWORD)) { - saveConsultantApproval() - dialog.dismiss() - launchDramatizationPhase() - } else { - password.error = getString(R.string.consultant_incorrect_password_message) - } - } - } - - passwordDialog.show() - toggleKeyboard(true, password) - } - - /** - * Updates the shared preference file to mark the story as approved - */ - private fun saveConsultantApproval() { - Workspace.activeStory.isApproved = true - } - - /** - * Launches the dramatization phase for the story and starts back at first slide - * TODO: moving back to first slide is currently broken - */ - private fun launchDramatizationPhase() { - Toast.makeText(context, "Congrats!", Toast.LENGTH_SHORT).show() - //Move to dramatization, slide 0. - Workspace.activeSlideNum = 0 - (activity as PhaseBaseActivity).jumpToPhase(Phase(PhaseType.DRAMATIZATION)) - } - - /** - * This function toggles the soft input keyboard. Allowing the user to have the keyboard - * to open or close seamlessly alongside the rest UI. - * @param showKeyBoard The boolean to be passed in to determine if the keyboard show be shown. - * @param aView The view associated with the soft input keyboard. - */ - private fun toggleKeyboard(showKeyBoard: Boolean, aView: View?) { - val imm = activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - if (showKeyBoard) { - imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) - } else { - imm.hideSoftInputFromWindow(aView!!.windowToken, 0) - } - } - - companion object { - val CONSULTANT_PREFS = "Consultant_Checks" - val IS_CONSULTANT_APPROVED = "isApproved" - private val PASSWORD = "appr00ved" - } -} +package org.sil.storyproducer.controller.consultant + +import android.content.Context +import android.os.Bundle +import android.support.graphics.drawable.VectorDrawableCompat +import android.support.v7.app.AlertDialog +import android.support.v7.widget.Toolbar +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.* +import org.sil.storyproducer.R +import org.sil.storyproducer.controller.SlidePhaseFrag +import org.sil.storyproducer.controller.logging.LogListAdapter +import org.sil.storyproducer.controller.phase.PhaseBaseActivity +import org.sil.storyproducer.model.Phase +import org.sil.storyproducer.model.PhaseType +import org.sil.storyproducer.model.SlideType +import org.sil.storyproducer.model.Workspace + +/** + * The fragment for the Consultant check view. The consultant can check that the draft is ok + */ +class ConsultantCheckFrag : SlidePhaseFrag() { + + var logDialog: AlertDialog? = null + var greenCheckmark: VectorDrawableCompat ?= null + var grayCheckmark: VectorDrawableCompat ?= null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + + greenCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_green, null) + grayCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_gray, null) + + // The last two arguments ensure LayoutParams are inflated + // properly. + rootView = inflater.inflate(R.layout.fragment_consultant_check, container, false) + + setPic(rootView!!.findViewById(R.id.fragment_image_view) as ImageView) + + setScriptureText(rootView!!.findViewById(R.id.fragment_scripture_text) as TextView) + setReferenceText(rootView!!.findViewById(R.id.fragment_reference_text) as TextView) + setCheckmarkButton(rootView!!.findViewById(R.id.concheck_checkmark_button) as ImageButton) + setLogsButton(rootView!!.findViewById(R.id.concheck_logs_button) as ImageButton) + + return rootView + } + + /** + * This function serves to handle page changes and stops the audio streams from + * continuing. + * @param isVisibleToUser + */ + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + + // Make sure that we are currently visible + if (this.isVisible) { + // If we are becoming invisible, then... + if (!isVisibleToUser) { + referenceAudioPlayer.stopAudio() + } + } + } + + + /** + * Sets on click listener for consultant to check off the slide and approve + * @param button the check button + */ + private fun setCheckmarkButton(button: ImageButton) { + if (Workspace.activeStory.slides[slideNum].isChecked) { + button.background = greenCheckmark + } else { + button.background = grayCheckmark + } + button.setOnClickListener(View.OnClickListener { + if (Workspace.activeStory.isApproved) { + Toast.makeText(context, "Story already approved", Toast.LENGTH_SHORT).show() + return@OnClickListener + } + if (Workspace.activeStory.slides[slideNum].isChecked) { + button.background = grayCheckmark + Workspace.activeStory.slides[slideNum].isChecked = false + } else { + button.background = greenCheckmark + Workspace.activeStory.slides[slideNum].isChecked = true + if (checkAllMarked()) { + showConsultantPasswordDialog() + } + } + }) + } + + /** + * Set an on click listener to launch the interface to view the logs for that slide + * @param button the logs button + */ + private fun setLogsButton(button: ImageButton) { + //TODO: use non-deprecated method; currently used to support older devices + button.background = VectorDrawableCompat.create(resources, R.drawable.ic_logs_blue, null) + button.setOnClickListener { + makeLogView() + logDialog?.show() + } + } + + private fun makeLogView() { + val alertDialog = android.support.v7.app.AlertDialog.Builder(context!!) + val linf = context!!.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater + val dialogLayout = linf.inflate(R.layout.activity_log_view, null) + + val listView = dialogLayout!!.findViewById(R.id.log_list_view) + val lla = LogListAdapter(context!!, slideNum) + listView.adapter = lla + val tb = dialogLayout.findViewById(R.id.toolbar2) + //Note that user-facing slide number is 1-based while it is 0-based in code. + tb.title = context!!.getString(R.string.logging_slide_log_view_title, slideNum) + val exit = dialogLayout.findViewById(R.id.exitButton) + val learnCB = dialogLayout.findViewById(R.id.LearnCheckBox) + val draftCB = dialogLayout.findViewById(R.id.DraftCheckBox) + val comChkCB = dialogLayout.findViewById(R.id.CommunityCheckCheckBox) + learnCB.setOnCheckedChangeListener { _, checked -> lla.updateList(checked, draftCB.isChecked, comChkCB.isChecked) } + draftCB.setOnCheckedChangeListener { _, checked -> lla.updateList(learnCB.isChecked, checked, comChkCB.isChecked) } + comChkCB.setOnCheckedChangeListener { _, checked -> lla.updateList(learnCB.isChecked, draftCB.isChecked, checked) } + alertDialog.setView(dialogLayout) + logDialog = alertDialog.create() + exit.setOnClickListener { + logDialog?.dismiss() + } + } + + + /** + * Checks each slide of the story to see if all slides have been approved + * @return true if all approved, otherwise false + */ + private fun checkAllMarked(): Boolean { + for (slide in Workspace.activeStory.slides) { + if (!slide.isChecked && slide.slideType in + arrayOf(SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE,SlideType.LOCALSONG)) { + return false + } + } + return true + } + + /** + * Launches a dialog for the consultant to enter a password once all slides approved + */ + private fun showConsultantPasswordDialog() { + val password = EditText(context) + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + password.id = org.sil.storyproducer.R.id.password_text_field; + + // Programmatically set layout properties for edit text field + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT) + // Apply layout properties + password.layoutParams = params + val passwordDialog = AlertDialog.Builder(context!!) + .setTitle(getString(R.string.consultant_password_title)) + .setMessage(getString(R.string.consultant_password_message)) + .setView(password) + .setNegativeButton(getString(R.string.cancel), null) + .setPositiveButton(getString(R.string.submit), null) + .create() + // This is set to dismiss the keyboard manually on dialog dismiss + passwordDialog.setOnDismissListener { toggleKeyboard(false, view) } + + // This manually sets the submit button listener so that the dialog doesn't always submit + // If the password is incorrect, we want to stay on the dialog and give an error message + passwordDialog.setOnShowListener { dialog -> + val button = (dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE) + button.setOnClickListener { + val passwordText = password.text.toString() + if (passwordText.contentEquals(PASSWORD)) { + saveConsultantApproval() + dialog.dismiss() + launchDramatizationPhase() + } else { + password.error = getString(R.string.consultant_incorrect_password_message) + } + } + } + + passwordDialog.show() + toggleKeyboard(true, password) + } + + /** + * Updates the shared preference file to mark the story as approved + */ + private fun saveConsultantApproval() { + Workspace.activeStory.isApproved = true + } + + /** + * Launches the dramatization phase for the story and starts back at first slide + * TODO: moving back to first slide is currently broken + */ + private fun launchDramatizationPhase() { + Toast.makeText(context, "Congrats!", Toast.LENGTH_SHORT).show() + //Move to dramatization, slide 0. + Workspace.activeSlideNum = 0 + (activity as PhaseBaseActivity).jumpToPhase(Phase(PhaseType.DRAMATIZATION)) + } + + /** + * This function toggles the soft input keyboard. Allowing the user to have the keyboard + * to open or close seamlessly alongside the rest UI. + * @param showKeyBoard The boolean to be passed in to determine if the keyboard show be shown. + * @param aView The view associated with the soft input keyboard. + */ + private fun toggleKeyboard(showKeyBoard: Boolean, aView: View?) { + val imm = activity!!.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + if (showKeyBoard) { + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) + } else { + imm.hideSoftInputFromWindow(aView!!.windowToken, 0) + } + } + + companion object { + val CONSULTANT_PREFS = "Consultant_Checks" + val IS_CONSULTANT_APPROVED = "isApproved" + private val PASSWORD = "appr00ved" + } +} diff --git a/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt index 89ddb1b6..b0159ae1 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt @@ -1,286 +1,286 @@ -package org.sil.storyproducer.controller.learn - -import android.media.MediaPlayer -import android.os.Bundle -import android.support.design.widget.Snackbar -import android.support.v4.content.res.ResourcesCompat -import android.view.View -import android.widget.* -import android.widget.SeekBar.OnSeekBarChangeListener -import org.sil.storyproducer.R -import org.sil.storyproducer.controller.phase.PhaseBaseActivity -import org.sil.storyproducer.model.SLIDE_NUM -import org.sil.storyproducer.model.SlideType -import org.sil.storyproducer.model.Story -import org.sil.storyproducer.model.Workspace -import org.sil.storyproducer.model.logging.saveLearnLog -import org.sil.storyproducer.tools.file.getStoryUri -import org.sil.storyproducer.tools.file.storyRelPathExists -import org.sil.storyproducer.tools.media.AudioPlayer -import org.sil.storyproducer.tools.media.MediaHelper -import org.sil.storyproducer.tools.toolbar.PlayBackRecordingToolbar -import java.util.* -import kotlin.math.min - -class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMediaListener { - private var learnImageView: ImageView? = null - private var playButton: ImageButton? = null - private var videoSeekBar: SeekBar? = null - private var mSeekBarTimer = Timer() - - private var narrationPlayer: AudioPlayer = AudioPlayer() - - private var isVolumeOn = true - private var isWatchedOnce = false - - private var recordingToolbar: PlayBackRecordingToolbar = PlayBackRecordingToolbar() - - private var numOfSlides: Int = 0 - private var seekbarStartTime: Long = -1 - private var logStartTime: Long = -1 - private var curPos: Int = -1 //set to -1 so that the first slide will register as "different" - private val slideDurations: MutableList = ArrayList() - private val slideStartTimes: MutableList = ArrayList() - - private var isLogging = false - private var startPos = -1 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_learn) - - setToolbar() - - learnImageView = findViewById(R.id.fragment_image_view) - playButton = findViewById(R.id.fragment_reference_audio_button) - - //setup seek bar listenters - videoSeekBar = findViewById(R.id.videoSeekBar) - videoSeekBar!!.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { - override fun onStopTrackingTouch(sBar: SeekBar) {} - override fun onStartTrackingTouch(sBar: SeekBar) {} - override fun onProgressChanged(sBar: SeekBar, progress: Int, fromUser: Boolean) { - if (fromUser) { - if (recordingToolbar.isRecording || recordingToolbar.isAudioPlaying) { - //When recording, update the picture to the accurate location, preserving - seekbarStartTime = System.currentTimeMillis() - videoSeekBar!!.progress - setSlideFromSeekbar() - } else { - if (narrationPlayer.isAudioPlaying) { - pauseStoryAudio() - playStoryAudio() - } else { - setSlideFromSeekbar() - } - //always start at the beginning of the slide. - if (slideStartTimes.size > curPos) - videoSeekBar!!.progress = slideStartTimes[curPos] - } - } - } - }) - - //setup volume switch callbacks - val volumeSwitch = findViewById(R.id.volumeSwitch) - //set the volume switch change listener - volumeSwitch.isChecked = true - volumeSwitch.setOnCheckedChangeListener { _, isChecked -> - isVolumeOn = if (isChecked) { - narrationPlayer.setVolume(1.0f) - true - } else { - narrationPlayer.setVolume(0.0f) - false - } - } - - //has learn already been watched? - isWatchedOnce = storyRelPathExists(this,Workspace.activeStory.learnAudioFile) - - //get story audio duration - numOfSlides = 0 - slideStartTimes.add(0) - for (s in story.slides) { - //don't play the copyright slides. - if (s.slideType in arrayOf(SlideType.FRONTCOVER, SlideType.NUMBEREDPAGE)) { - numOfSlides++ - slideDurations.add((MediaHelper.getAudioDuration(this, - getStoryUri(Story.getFilename(s.narrationFile))!!) / 1000).toInt()) - slideStartTimes.add(slideStartTimes.last() + slideDurations.last()) - } else { - break - } - } - videoSeekBar?.max = slideStartTimes.last() - - invalidateOptionsMenu() - } - - public override fun onPause() { - super.onPause() - pauseStoryAudio() - narrationPlayer.release() - } - - public override fun onResume() { - super.onResume() - - narrationPlayer = AudioPlayer() - narrationPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { - if(narrationPlayer.isAudioPrepared){ - if(curPos >= numOfSlides-1){ //is it the last slide? - //at the end of video so special case - pauseStoryAudio() - showStartPracticeSnackBar() - } else { - //just play the next slide! - videoSeekBar?.progress = slideStartTimes[curPos+1] - playStoryAudio() - } - } - }) - - mSeekBarTimer = Timer() - mSeekBarTimer.schedule(object : TimerTask() { - override fun run() { - runOnUiThread{ - if(recordingToolbar.isRecording || recordingToolbar.isAudioPlaying){ - videoSeekBar?.progress = min((System.currentTimeMillis() - seekbarStartTime).toInt(),videoSeekBar!!.max) - setSlideFromSeekbar() - }else{ - if(curPos >= 0) videoSeekBar?.progress = slideStartTimes[curPos] + narrationPlayer.currentPosition - } - } - } - },0,33) - - setSlideFromSeekbar() - } - - private fun setSlideFromSeekbar() { - val time = videoSeekBar!!.progress - var i = 0 - for (d in slideStartTimes) { - if (time < d) { - if(i-1 != curPos){ - curPos = i-1 - setPic(learnImageView!!, curPos) - narrationPlayer.setStorySource(this, Workspace.activeStory.slides[curPos].narrationFile) - } - break - } - i++ - } - } - - - private fun setToolbar(){ - val bundle = Bundle() - bundle.putInt(SLIDE_NUM, 0) - recordingToolbar.arguments = bundle - supportFragmentManager?.beginTransaction()?.replace(R.id.toolbar_for_recording_toolbar, recordingToolbar)?.commit() - - recordingToolbar.keepToolbarVisible() - } - - override fun onStoppedToolbarRecording() { - makeLogIfNecessary(true) - - super.onStoppedToolbarRecording() - } - - override fun onStartedToolbarRecording() { - super.onStartedToolbarRecording() - - markLogStart() - } - - override fun onStoppedToolbarMedia() { - videoSeekBar!!.progress = 0 - setSlideFromSeekbar() - } - - override fun onStartedToolbarMedia() { - pauseStoryAudio() - videoSeekBar!!.progress = 0 - curPos = 0 - //This gets the progress bar to show the right time. - seekbarStartTime = System.currentTimeMillis() - } - - private fun markLogStart() { - if(!isLogging) { - startPos = curPos - logStartTime = System.currentTimeMillis() - } - isLogging = true - } - - private fun makeLogIfNecessary(isRecording: Boolean = false) { - if (isLogging) { - if (startPos != -1) { - val duration: Long = System.currentTimeMillis() - logStartTime - if(duration > 2000){ //you need 2 seconds to listen to anything - saveLearnLog(this, startPos,curPos, duration, isRecording) - } - startPos = -1 - } - } - isLogging = false - } - - /** - * Button action for playing/pausing the audio - * @param view button to set listeners for - */ - fun onClickPlayPauseButton(view: View) { - if (narrationPlayer.isAudioPlaying) { - pauseStoryAudio() - } else { - if (videoSeekBar!!.progress >= videoSeekBar!!.max-100) { - //reset the video to the beginning because they already finished it (within 100 ms) - videoSeekBar!!.progress = 0 - } - playStoryAudio() - } - } - - /** - * Plays the audio - */ - internal fun playStoryAudio() { - recordingToolbar.stopToolbarMedia() - setSlideFromSeekbar() - narrationPlayer.pauseAudio() - markLogStart() - seekbarStartTime = System.currentTimeMillis() - narrationPlayer.setVolume(if (isVolumeOn) 1.0f else 0.0f) //set the volume on or off based on the boolean - narrationPlayer.playAudio() - playButton!!.setImageResource(R.drawable.ic_pause_white_48dp) - } - - /** - * helper function for pausing the video - */ - private fun pauseStoryAudio() { - makeLogIfNecessary() - narrationPlayer.pauseAudio() - playButton!!.setImageResource(R.drawable.ic_play_arrow_white_48dp) - } - - /** - * Shows a snackbar at the bottom of the screen to notify the user that they should practice saying the story - */ - private fun showStartPracticeSnackBar() { - if (!isWatchedOnce) { - val snackbar = Snackbar.make(findViewById(R.id.drawer_layout), - R.string.learn_phase_practice, Snackbar.LENGTH_LONG) - val snackBarView = snackbar.view - snackBarView.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.lightWhite, null)) - val textView = snackBarView.findViewById(android.support.design.R.id.snackbar_text) - textView.setTextColor(ResourcesCompat.getColor(resources, R.color.darkGray, null)) - snackbar.show() - } - isWatchedOnce = true - } -} +package org.sil.storyproducer.controller.learn + +import android.media.MediaPlayer +import android.os.Bundle +import android.support.design.widget.Snackbar +import android.support.v4.content.res.ResourcesCompat +import android.view.View +import android.widget.* +import android.widget.SeekBar.OnSeekBarChangeListener +import org.sil.storyproducer.R +import org.sil.storyproducer.controller.phase.PhaseBaseActivity +import org.sil.storyproducer.model.SLIDE_NUM +import org.sil.storyproducer.model.SlideType +import org.sil.storyproducer.model.Story +import org.sil.storyproducer.model.Workspace +import org.sil.storyproducer.model.logging.saveLearnLog +import org.sil.storyproducer.tools.file.getStoryUri +import org.sil.storyproducer.tools.file.storyRelPathExists +import org.sil.storyproducer.tools.media.AudioPlayer +import org.sil.storyproducer.tools.media.MediaHelper +import org.sil.storyproducer.tools.toolbar.PlayBackRecordingToolbar +import java.util.* +import kotlin.math.min + +class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMediaListener { + private var learnImageView: ImageView? = null + private var playButton: ImageButton? = null + private var videoSeekBar: SeekBar? = null + private var mSeekBarTimer = Timer() + + private var narrationPlayer: AudioPlayer = AudioPlayer() + + private var isVolumeOn = true + private var isWatchedOnce = false + + private var recordingToolbar: PlayBackRecordingToolbar = PlayBackRecordingToolbar() + + private var numOfSlides: Int = 0 + private var seekbarStartTime: Long = -1 + private var logStartTime: Long = -1 + private var curPos: Int = -1 //set to -1 so that the first slide will register as "different" + private val slideDurations: MutableList = ArrayList() + private val slideStartTimes: MutableList = ArrayList() + + private var isLogging = false + private var startPos = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_learn) + + setToolbar() + + learnImageView = findViewById(R.id.fragment_image_view) + playButton = findViewById(R.id.fragment_reference_audio_button) + + //setup seek bar listenters + videoSeekBar = findViewById(R.id.videoSeekBar) + videoSeekBar!!.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onStopTrackingTouch(sBar: SeekBar) {} + override fun onStartTrackingTouch(sBar: SeekBar) {} + override fun onProgressChanged(sBar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + if (recordingToolbar.isRecording || recordingToolbar.isAudioPlaying) { + //When recording, update the picture to the accurate location, preserving + seekbarStartTime = System.currentTimeMillis() - videoSeekBar!!.progress + setSlideFromSeekbar() + } else { + if (narrationPlayer.isAudioPlaying) { + pauseStoryAudio() + playStoryAudio() + } else { + setSlideFromSeekbar() + } + //always start at the beginning of the slide. + if (slideStartTimes.size > curPos) + videoSeekBar!!.progress = slideStartTimes[curPos] + } + } + } + }) + + //setup volume switch callbacks + val volumeSwitch = findViewById(R.id.volumeSwitch) + //set the volume switch change listener + volumeSwitch.isChecked = true + volumeSwitch.setOnCheckedChangeListener { _, isChecked -> + isVolumeOn = if (isChecked) { + narrationPlayer.setVolume(1.0f) + true + } else { + narrationPlayer.setVolume(0.0f) + false + } + } + + //has learn already been watched? + isWatchedOnce = storyRelPathExists(this,Workspace.activeStory.learnAudioFile) + + //get story audio duration + numOfSlides = 0 + slideStartTimes.add(0) + for (s in story.slides) { + //don't play the copyright slides. + if (s.slideType in arrayOf(SlideType.FRONTCOVER, SlideType.NUMBEREDPAGE)) { + numOfSlides++ + slideDurations.add((MediaHelper.getAudioDuration(this, + getStoryUri(Story.getFilename(s.narrationFile))!!) / 1000).toInt()) + slideStartTimes.add(slideStartTimes.last() + slideDurations.last()) + } else { + break + } + } + videoSeekBar?.max = slideStartTimes.last() + + invalidateOptionsMenu() + } + + public override fun onPause() { + super.onPause() + pauseStoryAudio() + narrationPlayer.release() + } + + public override fun onResume() { + super.onResume() + + narrationPlayer = AudioPlayer() + narrationPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { + if(narrationPlayer.isAudioPrepared){ + if(curPos >= numOfSlides-1){ //is it the last slide? + //at the end of video so special case + pauseStoryAudio() + showStartPracticeSnackBar() + } else { + //just play the next slide! + videoSeekBar?.progress = slideStartTimes[curPos+1] + playStoryAudio() + } + } + }) + + mSeekBarTimer = Timer() + mSeekBarTimer.schedule(object : TimerTask() { + override fun run() { + runOnUiThread{ + if(recordingToolbar.isRecording || recordingToolbar.isAudioPlaying){ + videoSeekBar?.progress = min((System.currentTimeMillis() - seekbarStartTime).toInt(),videoSeekBar!!.max) + setSlideFromSeekbar() + }else{ + if(curPos >= 0) videoSeekBar?.progress = slideStartTimes[curPos] + narrationPlayer.currentPosition + } + } + } + },0,33) + + setSlideFromSeekbar() + } + + private fun setSlideFromSeekbar() { + val time = videoSeekBar!!.progress + var i = 0 + for (d in slideStartTimes) { + if (time < d) { + if(i-1 != curPos){ + curPos = i-1 + setPic(learnImageView!!, curPos) + narrationPlayer.setStorySource(this, Workspace.activeStory.slides[curPos].narrationFile) + } + break + } + i++ + } + } + + + private fun setToolbar(){ + val bundle = Bundle() + bundle.putInt(SLIDE_NUM, 0) + recordingToolbar.arguments = bundle + supportFragmentManager?.beginTransaction()?.replace(R.id.toolbar_for_recording_toolbar, recordingToolbar)?.commit() + + recordingToolbar.keepToolbarVisible() + } + + override fun onStoppedToolbarRecording() { + makeLogIfNecessary(true) + + super.onStoppedToolbarRecording() + } + + override fun onStartedToolbarRecording() { + super.onStartedToolbarRecording() + + markLogStart() + } + + override fun onStoppedToolbarMedia() { + videoSeekBar!!.progress = 0 + setSlideFromSeekbar() + } + + override fun onStartedToolbarMedia() { + pauseStoryAudio() + videoSeekBar!!.progress = 0 + curPos = 0 + //This gets the progress bar to show the right time. + seekbarStartTime = System.currentTimeMillis() + } + + private fun markLogStart() { + if(!isLogging) { + startPos = curPos + logStartTime = System.currentTimeMillis() + } + isLogging = true + } + + private fun makeLogIfNecessary(isRecording: Boolean = false) { + if (isLogging) { + if (startPos != -1) { + val duration: Long = System.currentTimeMillis() - logStartTime + if(duration > 2000){ //you need 2 seconds to listen to anything + saveLearnLog(this, startPos,curPos, duration, isRecording) + } + startPos = -1 + } + } + isLogging = false + } + + /** + * Button action for playing/pausing the audio + * @param view button to set listeners for + */ + fun onClickPlayPauseButton(view: View) { + if (narrationPlayer.isAudioPlaying) { + pauseStoryAudio() + } else { + if (videoSeekBar!!.progress >= videoSeekBar!!.max-100) { + //reset the video to the beginning because they already finished it (within 100 ms) + videoSeekBar!!.progress = 0 + } + playStoryAudio() + } + } + + /** + * Plays the audio + */ + internal fun playStoryAudio() { + recordingToolbar.stopToolbarMedia() + setSlideFromSeekbar() + narrationPlayer.pauseAudio() + markLogStart() + seekbarStartTime = System.currentTimeMillis() + narrationPlayer.setVolume(if (isVolumeOn) 1.0f else 0.0f) //set the volume on or off based on the boolean + narrationPlayer.playAudio() + playButton!!.setImageResource(R.drawable.ic_pause_white_48dp) + } + + /** + * helper function for pausing the video + */ + private fun pauseStoryAudio() { + makeLogIfNecessary() + narrationPlayer.pauseAudio() + playButton!!.setImageResource(R.drawable.ic_play_arrow_white_48dp) + } + + /** + * Shows a snackbar at the bottom of the screen to notify the user that they should practice saying the story + */ + private fun showStartPracticeSnackBar() { + if (!isWatchedOnce) { + val snackbar = Snackbar.make(findViewById(R.id.drawer_layout), + R.string.learn_phase_practice, Snackbar.LENGTH_LONG) + val snackBarView = snackbar.view + snackBarView.setBackgroundColor(ResourcesCompat.getColor(resources, R.color.lightWhite, null)) + val textView = snackBarView.findViewById(android.support.design.R.id.snackbar_text) + textView.setTextColor(ResourcesCompat.getColor(resources, R.color.darkGray, null)) + snackbar.show() + } + isWatchedOnce = true + } +} diff --git a/app/src/main/java/org/sil/storyproducer/controller/logging/LogListAdapter.kt b/app/src/main/java/org/sil/storyproducer/controller/logging/LogListAdapter.kt index 865b4306..17821568 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/logging/LogListAdapter.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/logging/LogListAdapter.kt @@ -1,74 +1,74 @@ -package org.sil.storyproducer.controller.logging - -import android.content.Context -import android.support.v4.content.ContextCompat -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.BaseAdapter -import android.widget.TextView - -import org.sil.storyproducer.R -import org.sil.storyproducer.model.PhaseType -import org.sil.storyproducer.model.Workspace -import org.sil.storyproducer.model.logging.LogEntry - -import java.util.ArrayList - -internal class LogListAdapter(private val context: Context, slide: Int) : BaseAdapter() { - - private val allEntries = ArrayList() - private var displayEntries = ArrayList() - - init { - for (le in Workspace.activeStory.activityLogs) { - if (le.appliesToSlideNum(slide)) { - allEntries.add(le) - } - } - displayEntries = allEntries - } - - fun updateList(learn: Boolean, draft: Boolean, comCheck: Boolean) { - displayEntries = ArrayList() - for (le in allEntries) { - when(le.phase.phaseType){ - PhaseType.LEARN -> if(learn) displayEntries.add(le) - PhaseType.DRAFT -> if(draft) displayEntries.add(le) - PhaseType.COMMUNITY_CHECK -> if(comCheck) displayEntries.add(le) - else -> {} - } - } - notifyDataSetChanged() - } - - override fun getCount(): Int { - return displayEntries.size - } - - override fun getItem(position: Int): LogEntry { - return displayEntries[position] - } - - override fun getItemId(position: Int): Long { - return position.toLong() - } - - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - var cView = convertView - if (cView == null) { - cView = LayoutInflater.from(context) - .inflate(R.layout.log_list_item, parent, false) - } - - val date = cView!!.findViewById(R.id.textView_logging_date) - val info = cView.findViewById(R.id.textView_logging_type) - - val entry = getItem(position) - date.text = entry.dateTimeString - info.text = "${entry.phase.getPrettyName()} - ${entry.description}" - cView.setBackgroundColor(ContextCompat.getColor(context, entry.phase.getColor())) - - return cView - } -} +package org.sil.storyproducer.controller.logging + +import android.content.Context +import android.support.v4.content.ContextCompat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import android.widget.TextView + +import org.sil.storyproducer.R +import org.sil.storyproducer.model.PhaseType +import org.sil.storyproducer.model.Workspace +import org.sil.storyproducer.model.logging.LogEntry + +import java.util.ArrayList + +internal class LogListAdapter(private val context: Context, slide: Int) : BaseAdapter() { + + private val allEntries = ArrayList() + private var displayEntries = ArrayList() + + init { + for (le in Workspace.activeStory.activityLogs) { + if (le.appliesToSlideNum(slide)) { + allEntries.add(le) + } + } + displayEntries = allEntries + } + + fun updateList(learn: Boolean, draft: Boolean, comCheck: Boolean) { + displayEntries = ArrayList() + for (le in allEntries) { + when(le.phase.phaseType){ + PhaseType.LEARN -> if(learn) displayEntries.add(le) + PhaseType.DRAFT -> if(draft) displayEntries.add(le) + PhaseType.COMMUNITY_CHECK -> if(comCheck) displayEntries.add(le) + else -> {} + } + } + notifyDataSetChanged() + } + + override fun getCount(): Int { + return displayEntries.size + } + + override fun getItem(position: Int): LogEntry { + return displayEntries[position] + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + var cView = convertView + if (cView == null) { + cView = LayoutInflater.from(context) + .inflate(R.layout.log_list_item, parent, false) + } + + val date = cView!!.findViewById(R.id.textView_logging_date) + val info = cView.findViewById(R.id.textView_logging_type) + + val entry = getItem(position) + date.text = entry.dateTimeString + info.text = "${entry.phase.getPrettyName()} - ${entry.description}" + cView.setBackgroundColor(ContextCompat.getColor(context, entry.phase.getColor())) + + return cView + } +} diff --git a/app/src/main/java/org/sil/storyproducer/model/Phase.kt b/app/src/main/java/org/sil/storyproducer/model/Phase.kt index 9acb2fe8..287fbae9 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Phase.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Phase.kt @@ -1,202 +1,202 @@ -package org.sil.storyproducer.model - -import org.sil.storyproducer.R -import org.sil.storyproducer.controller.MainActivity -import org.sil.storyproducer.controller.RegistrationActivity -import org.sil.storyproducer.controller.export.CreateActivity -import org.sil.storyproducer.controller.export.ShareActivity -import org.sil.storyproducer.controller.learn.LearnActivity -import org.sil.storyproducer.controller.pager.PagerBaseActivity -import org.sil.storyproducer.controller.remote.WholeStoryBackTranslationActivity - - -enum class PhaseType { - WORKSPACE, REGISTRATION, STORY_LIST, LEARN, DRAFT, COMMUNITY_CHECK, CONSULTANT_CHECK, DRAMATIZATION, CREATE, SHARE, BACKT, WHOLE_STORY, REMOTE_CHECK -} - -/** - * The business object for phases that are part of the story - */ -class Phase(val phaseType: PhaseType) { - - - fun getCombNames(slideNum:Int = Workspace.activeSlideNum) : MutableList?{ - return when (phaseType){ - PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].draftAudioFiles - PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].communityCheckAudioFiles - PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].dramatizationAudioFiles - PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].backTranslationAudioFiles - else -> null - } - } - - fun getIcon(phase: PhaseType = phaseType) : Int { - return when (phase){ - PhaseType.LEARN -> R.drawable.ic_ear_speak - PhaseType.DRAFT -> R.drawable.ic_mic_white_48dp - PhaseType.CREATE -> R.drawable.ic_video_call_white_48dp - PhaseType.SHARE -> R.drawable.ic_share_white_48dp - PhaseType.COMMUNITY_CHECK -> R.drawable.ic_people_white_48dp - PhaseType.CONSULTANT_CHECK -> R.drawable.ic_school_white_48dp - PhaseType.WHOLE_STORY -> R.drawable.ic_school_white_48dp - PhaseType.REMOTE_CHECK -> R.drawable.ic_school_white_48dp - PhaseType.BACKT -> R.drawable.ic_headset_mic_white_48dp - PhaseType.DRAMATIZATION -> R.drawable.ic_mic_box_48dp - else -> R.drawable.ic_mic_white_48dp - } - } - - fun getReferenceAudioFile(slideNum: Int = Workspace.activeSlideNum) : String { - val filename = when (phaseType){ - PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].narrationFile - PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].chosenDraftFile - PhaseType.CONSULTANT_CHECK -> Workspace.activeStory.slides[slideNum].chosenDraftFile - PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].chosenDraftFile - PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].chosenDraftFile - else -> "" - } - return Story.getFilename(filename) - } - - fun getPrettyName() : String { - return when (phaseType) { - PhaseType.LEARN -> "Learn" - PhaseType.DRAFT -> "Translate" - PhaseType.CREATE -> "Finalize" - PhaseType.SHARE -> "Share" - PhaseType.COMMUNITY_CHECK -> "Community Work" - PhaseType.CONSULTANT_CHECK -> "Accuracy Check" - PhaseType.WHOLE_STORY -> "Whole Story" - PhaseType.REMOTE_CHECK -> "Remote Check" - PhaseType.BACKT -> "Back Translation" - PhaseType.DRAMATIZATION -> "Voice Studio" - else -> phaseType.toString().toLowerCase() - } - } - - fun getDisplayName() : String { - return when (phaseType) { - PhaseType.DRAFT -> "Translation Draft" - PhaseType.COMMUNITY_CHECK -> "Comment" - PhaseType.CONSULTANT_CHECK -> "Accuracy" - PhaseType.WHOLE_STORY -> "Whole" - PhaseType.REMOTE_CHECK -> "Remote" - PhaseType.BACKT -> "BackTrans" - PhaseType.DRAMATIZATION -> "Studio Recording" - PhaseType.CREATE -> "Finalize" - else -> phaseType.toString().toLowerCase() - } - } - - fun getShortName() : String { - return when (phaseType) { - PhaseType.DRAFT -> "Translate" - PhaseType.COMMUNITY_CHECK -> "Community" - PhaseType.CONSULTANT_CHECK -> "Accuracy" - PhaseType.WHOLE_STORY -> "Whole" - PhaseType.REMOTE_CHECK -> "Remote" - PhaseType.BACKT -> "BackTrans" - PhaseType.DRAMATIZATION -> "VStudio" - PhaseType.CREATE -> "Finalize" - else -> phaseType.toString().toLowerCase() - } - } - /** - * get the color for the phase - * @return return the color - */ - fun getColor() : Int { - return when(phaseType){ - PhaseType.LEARN -> R.color.learn_phase - PhaseType.DRAFT -> R.color.draft_phase - PhaseType.COMMUNITY_CHECK -> R.color.comunity_check_phase - PhaseType.CONSULTANT_CHECK -> R.color.consultant_check_phase - PhaseType.DRAMATIZATION -> R.color.dramatization_phase - PhaseType.CREATE -> R.color.create_phase - PhaseType.SHARE -> R.color.share_phase - PhaseType.BACKT -> R.color.backT_phase - PhaseType.WHOLE_STORY -> R.color.whole_story_phase - PhaseType.REMOTE_CHECK -> R.color.remote_check_phase - else -> R.color.black - } - } - - fun getTheClass() : Class<*> { - return when(phaseType){ - PhaseType.WORKSPACE -> RegistrationActivity::class.java - PhaseType.REGISTRATION -> RegistrationActivity::class.java - PhaseType.STORY_LIST -> MainActivity::class.java - PhaseType.LEARN -> LearnActivity::class.java - PhaseType.DRAFT -> PagerBaseActivity::class.java - PhaseType.COMMUNITY_CHECK -> PagerBaseActivity::class.java - PhaseType.CONSULTANT_CHECK -> PagerBaseActivity::class.java - PhaseType.DRAMATIZATION -> PagerBaseActivity::class.java - PhaseType.CREATE -> CreateActivity::class.java - PhaseType.SHARE -> ShareActivity::class.java - PhaseType.BACKT -> PagerBaseActivity::class.java - PhaseType.WHOLE_STORY -> WholeStoryBackTranslationActivity::class.java - PhaseType.REMOTE_CHECK -> PagerBaseActivity::class.java - } - } - - fun getPhaseDisplaySlideCount() : Int { - var tempSlideNum = 0 - val validSlideTypes = when(phaseType){ - PhaseType.DRAMATIZATION -> arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, - SlideType.LOCALSONG,SlideType.LOCALCREDITS) - else -> arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, - SlideType.LOCALSONG) - } - for (s in Workspace.activeStory.slides) - if(s.slideType in validSlideTypes){ - tempSlideNum++ - }else{ - break - } - return tempSlideNum - } - - fun checkValidDisplaySlideNum(slideNum: Int) : Boolean { - val slideType = Workspace.activeStory.slides[slideNum].slideType - return when(phaseType){ - PhaseType.DRAMATIZATION -> slideType in arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, - SlideType.LOCALSONG,SlideType.LOCALCREDITS) - else -> slideType in arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, - SlideType.LOCALSONG) - } - } - - companion object { - fun getLocalPhases() : List { - return listOf( - Phase(PhaseType.LEARN), - Phase(PhaseType.DRAFT), - Phase(PhaseType.COMMUNITY_CHECK), - Phase(PhaseType.CONSULTANT_CHECK), - Phase(PhaseType.DRAMATIZATION), - Phase(PhaseType.CREATE), - Phase(PhaseType.SHARE)) - } - - fun getRemotePhases() : List { - return listOf( - Phase(PhaseType.LEARN), - Phase(PhaseType.DRAFT), - Phase(PhaseType.COMMUNITY_CHECK), - Phase(PhaseType.WHOLE_STORY), - Phase(PhaseType.BACKT), - Phase(PhaseType.REMOTE_CHECK), - Phase(PhaseType.DRAMATIZATION), - Phase(PhaseType.CREATE), - Phase(PhaseType.SHARE)) - } - - fun getHelpName(phase: PhaseType) : String { - return "${phase.name.toLowerCase()}.html" - } - } +package org.sil.storyproducer.model + +import org.sil.storyproducer.R +import org.sil.storyproducer.controller.MainActivity +import org.sil.storyproducer.controller.RegistrationActivity +import org.sil.storyproducer.controller.export.CreateActivity +import org.sil.storyproducer.controller.export.ShareActivity +import org.sil.storyproducer.controller.learn.LearnActivity +import org.sil.storyproducer.controller.pager.PagerBaseActivity +import org.sil.storyproducer.controller.remote.WholeStoryBackTranslationActivity + + +enum class PhaseType { + WORKSPACE, REGISTRATION, STORY_LIST, LEARN, DRAFT, COMMUNITY_CHECK, CONSULTANT_CHECK, DRAMATIZATION, CREATE, SHARE, BACKT, WHOLE_STORY, REMOTE_CHECK +} + +/** + * The business object for phases that are part of the story + */ +class Phase(val phaseType: PhaseType) { + + + fun getCombNames(slideNum:Int = Workspace.activeSlideNum) : MutableList?{ + return when (phaseType){ + PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].draftAudioFiles + PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].communityCheckAudioFiles + PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].dramatizationAudioFiles + PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].backTranslationAudioFiles + else -> null + } + } + + fun getIcon(phase: PhaseType = phaseType) : Int { + return when (phase){ + PhaseType.LEARN -> R.drawable.ic_ear_speak + PhaseType.DRAFT -> R.drawable.ic_mic_white_48dp + PhaseType.CREATE -> R.drawable.ic_video_call_white_48dp + PhaseType.SHARE -> R.drawable.ic_share_white_48dp + PhaseType.COMMUNITY_CHECK -> R.drawable.ic_people_white_48dp + PhaseType.CONSULTANT_CHECK -> R.drawable.ic_school_white_48dp + PhaseType.WHOLE_STORY -> R.drawable.ic_school_white_48dp + PhaseType.REMOTE_CHECK -> R.drawable.ic_school_white_48dp + PhaseType.BACKT -> R.drawable.ic_headset_mic_white_48dp + PhaseType.DRAMATIZATION -> R.drawable.ic_mic_box_48dp + else -> R.drawable.ic_mic_white_48dp + } + } + + fun getReferenceAudioFile(slideNum: Int = Workspace.activeSlideNum) : String { + val filename = when (phaseType){ + PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].narrationFile + PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].chosenDraftFile + PhaseType.CONSULTANT_CHECK -> Workspace.activeStory.slides[slideNum].chosenDraftFile + PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].chosenDraftFile + PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].chosenDraftFile + else -> "" + } + return Story.getFilename(filename) + } + + fun getPrettyName() : String { + return when (phaseType) { + PhaseType.LEARN -> "Learn" + PhaseType.DRAFT -> "Translate" + PhaseType.CREATE -> "Finalize" + PhaseType.SHARE -> "Share" + PhaseType.COMMUNITY_CHECK -> "Community Work" + PhaseType.CONSULTANT_CHECK -> "Accuracy Check" + PhaseType.WHOLE_STORY -> "Whole Story" + PhaseType.REMOTE_CHECK -> "Remote Check" + PhaseType.BACKT -> "Back Translation" + PhaseType.DRAMATIZATION -> "Voice Studio" + else -> phaseType.toString().toLowerCase() + } + } + + fun getDisplayName() : String { + return when (phaseType) { + PhaseType.DRAFT -> "Translation Draft" + PhaseType.COMMUNITY_CHECK -> "Comment" + PhaseType.CONSULTANT_CHECK -> "Accuracy" + PhaseType.WHOLE_STORY -> "Whole" + PhaseType.REMOTE_CHECK -> "Remote" + PhaseType.BACKT -> "BackTrans" + PhaseType.DRAMATIZATION -> "Studio Recording" + PhaseType.CREATE -> "Finalize" + else -> phaseType.toString().toLowerCase() + } + } + + fun getShortName() : String { + return when (phaseType) { + PhaseType.DRAFT -> "Translate" + PhaseType.COMMUNITY_CHECK -> "Community" + PhaseType.CONSULTANT_CHECK -> "Accuracy" + PhaseType.WHOLE_STORY -> "Whole" + PhaseType.REMOTE_CHECK -> "Remote" + PhaseType.BACKT -> "BackTrans" + PhaseType.DRAMATIZATION -> "VStudio" + PhaseType.CREATE -> "Finalize" + else -> phaseType.toString().toLowerCase() + } + } + /** + * get the color for the phase + * @return return the color + */ + fun getColor() : Int { + return when(phaseType){ + PhaseType.LEARN -> R.color.learn_phase + PhaseType.DRAFT -> R.color.draft_phase + PhaseType.COMMUNITY_CHECK -> R.color.comunity_check_phase + PhaseType.CONSULTANT_CHECK -> R.color.consultant_check_phase + PhaseType.DRAMATIZATION -> R.color.dramatization_phase + PhaseType.CREATE -> R.color.create_phase + PhaseType.SHARE -> R.color.share_phase + PhaseType.BACKT -> R.color.backT_phase + PhaseType.WHOLE_STORY -> R.color.whole_story_phase + PhaseType.REMOTE_CHECK -> R.color.remote_check_phase + else -> R.color.black + } + } + + fun getTheClass() : Class<*> { + return when(phaseType){ + PhaseType.WORKSPACE -> RegistrationActivity::class.java + PhaseType.REGISTRATION -> RegistrationActivity::class.java + PhaseType.STORY_LIST -> MainActivity::class.java + PhaseType.LEARN -> LearnActivity::class.java + PhaseType.DRAFT -> PagerBaseActivity::class.java + PhaseType.COMMUNITY_CHECK -> PagerBaseActivity::class.java + PhaseType.CONSULTANT_CHECK -> PagerBaseActivity::class.java + PhaseType.DRAMATIZATION -> PagerBaseActivity::class.java + PhaseType.CREATE -> CreateActivity::class.java + PhaseType.SHARE -> ShareActivity::class.java + PhaseType.BACKT -> PagerBaseActivity::class.java + PhaseType.WHOLE_STORY -> WholeStoryBackTranslationActivity::class.java + PhaseType.REMOTE_CHECK -> PagerBaseActivity::class.java + } + } + + fun getPhaseDisplaySlideCount() : Int { + var tempSlideNum = 0 + val validSlideTypes = when(phaseType){ + PhaseType.DRAMATIZATION -> arrayOf( + SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, + SlideType.LOCALSONG,SlideType.LOCALCREDITS) + else -> arrayOf( + SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, + SlideType.LOCALSONG) + } + for (s in Workspace.activeStory.slides) + if(s.slideType in validSlideTypes){ + tempSlideNum++ + }else{ + break + } + return tempSlideNum + } + + fun checkValidDisplaySlideNum(slideNum: Int) : Boolean { + val slideType = Workspace.activeStory.slides[slideNum].slideType + return when(phaseType){ + PhaseType.DRAMATIZATION -> slideType in arrayOf( + SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, + SlideType.LOCALSONG,SlideType.LOCALCREDITS) + else -> slideType in arrayOf( + SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, + SlideType.LOCALSONG) + } + } + + companion object { + fun getLocalPhases() : List { + return listOf( + Phase(PhaseType.LEARN), + Phase(PhaseType.DRAFT), + Phase(PhaseType.COMMUNITY_CHECK), + Phase(PhaseType.CONSULTANT_CHECK), + Phase(PhaseType.DRAMATIZATION), + Phase(PhaseType.CREATE), + Phase(PhaseType.SHARE)) + } + + fun getRemotePhases() : List { + return listOf( + Phase(PhaseType.LEARN), + Phase(PhaseType.DRAFT), + Phase(PhaseType.COMMUNITY_CHECK), + Phase(PhaseType.WHOLE_STORY), + Phase(PhaseType.BACKT), + Phase(PhaseType.REMOTE_CHECK), + Phase(PhaseType.DRAMATIZATION), + Phase(PhaseType.CREATE), + Phase(PhaseType.SHARE)) + } + + fun getHelpName(phase: PhaseType) : String { + return "${phase.name.toLowerCase()}.html" + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/sil/storyproducer/model/Story.kt b/app/src/main/java/org/sil/storyproducer/model/Story.kt index 0f714cdf..2ad9f813 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Story.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Story.kt @@ -1,66 +1,66 @@ -package org.sil.storyproducer.model - - -import com.squareup.moshi.JsonClass -import org.sil.storyproducer.model.logging.LogEntry -import java.util.* - -internal const val PROJECT_DIR = "project" -internal const val VIDEO_DIR = "videos" -internal const val PROJECT_FILE = "story.json" -internal val RE_TITLE_NUMBER = "([0-9]+[A-Za-z]?)?[_ -]*(.+)".toRegex() -internal val RE_DISPLAY_NAME = "([^|]+)[|.]".toRegex() -internal val RE_FILENAME = "([^|]+[|])?(.*)".toRegex() - -@JsonClass(generateAdapter = true) -class Story(var title: String, val slides: List){ - - var isApproved: Boolean = false - - var learnAudioFile = "" - var wholeStoryBackTAudioFile = "" - var activityLogs: MutableList = ArrayList() - var outputVideos: MutableList = ArrayList() - var lastPhaseType: PhaseType = PhaseType.LEARN - var lastSlideNum: Int = 0 - - val shortTitle: String get() { - val match = RE_TITLE_NUMBER.find(title) - return if(match != null){ - match.groupValues[2] - } else { - title - } - } - val titleNumber: String get() { - val match = RE_TITLE_NUMBER.find(title) - return if(match != null){ - match.groupValues[1] - } else { - "" - } - } - - fun addVideo(video: String){ - if(!(video in outputVideos)){ - outputVideos.add(video) - outputVideos.sort() - } - } - - companion object{ - fun getDisplayName(combName:String): String { - val match = RE_DISPLAY_NAME.find(combName) - return if(match != null){ match.groupValues[1] } else {""} - } - fun getFilename(combName:String): String { - val match = RE_FILENAME.find(combName) - return if(match != null){ match.groupValues[2] } else {""} - } - } - - -} - -fun emptyStory() : Story {return Story("",ArrayList())} - +package org.sil.storyproducer.model + + +import com.squareup.moshi.JsonClass +import org.sil.storyproducer.model.logging.LogEntry +import java.util.* + +internal const val PROJECT_DIR = "project" +internal const val VIDEO_DIR = "videos" +internal const val PROJECT_FILE = "story.json" +internal val RE_TITLE_NUMBER = "([0-9]+[A-Za-z]?)?[_ -]*(.+)".toRegex() +internal val RE_DISPLAY_NAME = "([^|]+)[|.]".toRegex() +internal val RE_FILENAME = "([^|]+[|])?(.*)".toRegex() + +@JsonClass(generateAdapter = true) +class Story(var title: String, val slides: List){ + + var isApproved: Boolean = false + + var learnAudioFile = "" + var wholeStoryBackTAudioFile = "" + var activityLogs: MutableList = ArrayList() + var outputVideos: MutableList = ArrayList() + var lastPhaseType: PhaseType = PhaseType.LEARN + var lastSlideNum: Int = 0 + + val shortTitle: String get() { + val match = RE_TITLE_NUMBER.find(title) + return if(match != null){ + match.groupValues[2] + } else { + title + } + } + val titleNumber: String get() { + val match = RE_TITLE_NUMBER.find(title) + return if(match != null){ + match.groupValues[1] + } else { + "" + } + } + + fun addVideo(video: String){ + if(!(video in outputVideos)){ + outputVideos.add(video) + outputVideos.sort() + } + } + + companion object{ + fun getDisplayName(combName:String): String { + val match = RE_DISPLAY_NAME.find(combName) + return if(match != null){ match.groupValues[1] } else {""} + } + fun getFilename(combName:String): String { + val match = RE_FILENAME.find(combName) + return if(match != null){ match.groupValues[2] } else {""} + } + } + + +} + +fun emptyStory() : Story {return Story("",ArrayList())} + diff --git a/app/src/main/java/org/sil/storyproducer/model/logging/LogEntry.kt b/app/src/main/java/org/sil/storyproducer/model/logging/LogEntry.kt index fe0f2c86..cbabf559 100644 --- a/app/src/main/java/org/sil/storyproducer/model/logging/LogEntry.kt +++ b/app/src/main/java/org/sil/storyproducer/model/logging/LogEntry.kt @@ -1,61 +1,61 @@ -package org.sil.storyproducer.model.logging - -import android.content.Context -import com.squareup.moshi.JsonClass -import org.sil.storyproducer.R -import org.sil.storyproducer.model.Phase -import org.sil.storyproducer.model.PhaseType -import org.sil.storyproducer.model.Workspace -import java.text.SimpleDateFormat -import java.util.* - -@JsonClass(generateAdapter = true) -class LogEntry(var dateTimeString: String, - var description: String, var phase: Phase, - var startSlideNum: Int = -1, var endSlideNum: Int = -1) { - - fun appliesToSlideNum(compareNum: Int): Boolean { - if (phase.phaseType == PhaseType.LEARN) - if(compareNum in startSlideNum..endSlideNum || - compareNum in endSlideNum..startSlideNum) - return true - if (startSlideNum == compareNum) return true - return false - } - -} - -fun saveLearnLog(context: Context, startSlide: Int, endSlide: Int, duration: Long, isRecording: Boolean = false){ - val mResources = context.resources - var ret = if(isRecording){"Record "}else{"Playback "} - - ret += if (startSlide == endSlide) { - mResources.getQuantityString(R.plurals.logging_numSlides, 1) + " " + (startSlide) - } else { - mResources.getQuantityString(R.plurals.logging_numSlides, 2) + " " + (startSlide) + "-" + (endSlide) - } - //format duration: - val secUnit = mResources.getString(R.string.SECONDS_ABBREVIATION) - val minUnit = mResources.getString(R.string.MINUTES_ABBREVIATION) - if (duration < 1000) { - ret += " (<1 $secUnit)" - }else { - val roundedSecs = (duration / 1000.0 + 0.5).toInt() - val mins = roundedSecs / 60 - var minString = "" - if (mins > 0) { - minString = mins.toString() + " " + minUnit + " " - } - ret += " (" + minString + roundedSecs % 60 + " " + secUnit + ")" - } - saveLog(ret,startSlide,endSlide) -} - -fun saveLog(description: String,startSlideNum: Int = Workspace.activeSlideNum, endSlideNum: Int = Workspace.activeSlideNum) { - val dateTimeString = SimpleDateFormat("EEE MMM dd yyyy h:mm a", Locale.US).format(GregorianCalendar().time) - val phase = Workspace.activePhase - - val le = LogEntry(dateTimeString, - description, phase, startSlideNum,endSlideNum) - Workspace.activeStory.activityLogs.add(le) +package org.sil.storyproducer.model.logging + +import android.content.Context +import com.squareup.moshi.JsonClass +import org.sil.storyproducer.R +import org.sil.storyproducer.model.Phase +import org.sil.storyproducer.model.PhaseType +import org.sil.storyproducer.model.Workspace +import java.text.SimpleDateFormat +import java.util.* + +@JsonClass(generateAdapter = true) +class LogEntry(var dateTimeString: String, + var description: String, var phase: Phase, + var startSlideNum: Int = -1, var endSlideNum: Int = -1) { + + fun appliesToSlideNum(compareNum: Int): Boolean { + if (phase.phaseType == PhaseType.LEARN) + if(compareNum in startSlideNum..endSlideNum || + compareNum in endSlideNum..startSlideNum) + return true + if (startSlideNum == compareNum) return true + return false + } + +} + +fun saveLearnLog(context: Context, startSlide: Int, endSlide: Int, duration: Long, isRecording: Boolean = false){ + val mResources = context.resources + var ret = if(isRecording){"Record "}else{"Playback "} + + ret += if (startSlide == endSlide) { + mResources.getQuantityString(R.plurals.logging_numSlides, 1) + " " + (startSlide) + } else { + mResources.getQuantityString(R.plurals.logging_numSlides, 2) + " " + (startSlide) + "-" + (endSlide) + } + //format duration: + val secUnit = mResources.getString(R.string.SECONDS_ABBREVIATION) + val minUnit = mResources.getString(R.string.MINUTES_ABBREVIATION) + if (duration < 1000) { + ret += " (<1 $secUnit)" + }else { + val roundedSecs = (duration / 1000.0 + 0.5).toInt() + val mins = roundedSecs / 60 + var minString = "" + if (mins > 0) { + minString = mins.toString() + " " + minUnit + " " + } + ret += " (" + minString + roundedSecs % 60 + " " + secUnit + ")" + } + saveLog(ret,startSlide,endSlide) +} + +fun saveLog(description: String,startSlideNum: Int = Workspace.activeSlideNum, endSlideNum: Int = Workspace.activeSlideNum) { + val dateTimeString = SimpleDateFormat("EEE MMM dd yyyy h:mm a", Locale.US).format(GregorianCalendar().time) + val phase = Workspace.activePhase + + val le = LogEntry(dateTimeString, + description, phase, startSlideNum,endSlideNum) + Workspace.activeStory.activityLogs.add(le) } \ No newline at end of file diff --git a/app/src/main/java/org/sil/storyproducer/tools/media/AudioRecorder.kt b/app/src/main/java/org/sil/storyproducer/tools/media/AudioRecorder.kt index 779c21f6..47f33926 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/media/AudioRecorder.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/media/AudioRecorder.kt @@ -1,144 +1,144 @@ -package org.sil.storyproducer.tools.media - -import android.Manifest -import android.app.Activity -import android.content.Context -import android.content.pm.PackageManager -import android.media.MediaMuxer -import android.media.MediaRecorder -import android.net.Uri -import android.support.v4.app.ActivityCompat -import android.support.v4.content.ContextCompat -import android.util.Log -import android.widget.Toast -import com.crashlytics.android.Crashlytics -import org.sil.storyproducer.R -import org.sil.storyproducer.model.Workspace -import org.sil.storyproducer.tools.file.copyToWorkspacePath -import org.sil.storyproducer.tools.file.getStoryFileDescriptor -import org.sil.storyproducer.tools.file.getStoryUri -import org.sil.storyproducer.tools.media.story.AutoStoryMaker -import org.sil.storyproducer.tools.media.story.StoryMaker -import org.sil.storyproducer.tools.media.story.StoryPage -import java.io.File -import java.io.IOException - - -//See https://developer.android.com/guide/topics/media/media-formats.html for supported formats. -internal val OUTPUT_FORMAT = MediaRecorder.OutputFormat.MPEG_4 -internal val AUDIO_ENCODER = MediaRecorder.AudioEncoder.AAC -internal val SAMPLE_RATE = 44100 -internal val BIT_DEPTH = 16 -internal val AUDIO_CHANNELS = 1 -//Set bit rate to exact spec of Android doc or to SAMPLE_RATE * BIT_DEPTH. -internal val BIT_RATE = SAMPLE_RATE * BIT_DEPTH - -/** - * Thin wrapper for [MediaRecorder] which provides some default behavior for recorder. - */ - -private const val AUDIO_RECORDER = "audio_recorder" - -abstract class AudioRecorder(val activity: Activity) { - var isRecording = false - protected set - - init { - if (ContextCompat.checkSelfPermission(activity, - Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { - - ActivityCompat.requestPermissions(activity, - arrayOf(Manifest.permission.RECORD_AUDIO), 1) - } - } - - abstract fun startNewRecording(relPath: String) - - abstract fun stop() - - companion object { - /** - * This class is used to concatenate two Wav files together. - *

    - * Assumes the header of the Wav file resembles Microsoft's RIFF specification.

    - * A specification can be found [here](http://soundfile.sapp.org/doc/WaveFormat/). - */ - - fun concatenateAudioFiles(context: Context, orgAudioRelPath: String, appendAudioRelPath: String) { - - val tempDestPath = "${context.filesDir}/temp.mp4" - - - val outputFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4 - val audioFormat = AutoStoryMaker.generateAudioFormat() - val pages: MutableList = mutableListOf() - - var duration = MediaHelper.getAudioDuration(context, getStoryUri(orgAudioRelPath)!!) - pages.add(StoryPage("",orgAudioRelPath,duration,null,null)) - duration = MediaHelper.getAudioDuration(context, getStoryUri(appendAudioRelPath)!!) - pages.add(StoryPage("",appendAudioRelPath,duration,null,null)) - - //If pages weren't generated, exit. - val mStoryMaker = StoryMaker(context, File(tempDestPath), outputFormat, null, audioFormat, - pages.toTypedArray(), 10000, 10000) - - mStoryMaker.churn() - mStoryMaker.close() - - copyToWorkspacePath(context, Uri.fromFile(File(tempDestPath)), - "${Workspace.activeDirRoot}/$orgAudioRelPath") - File(tempDestPath).delete() - } - } -} - - -class AudioRecorderMP4(activity: Activity) : AudioRecorder(activity) { - - private var mRecorder = MediaRecorder() - - private fun initRecorder(){ - mRecorder.release() - mRecorder = MediaRecorder() - mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) - mRecorder.setOutputFormat(OUTPUT_FORMAT) - mRecorder.setAudioEncoder(AUDIO_ENCODER) - mRecorder.setAudioEncodingBitRate(BIT_RATE) - mRecorder.setAudioSamplingRate(SAMPLE_RATE) - mRecorder.setAudioChannels(AUDIO_CHANNELS) - } - - override fun startNewRecording(relPath: String){ - initRecorder() - mRecorder.setOutputFile(getStoryFileDescriptor(activity, relPath,"","w")) - isRecording = true - try{ - mRecorder.prepare() - mRecorder.start() - } - catch (e: IllegalStateException) { - Toast.makeText(activity, "IllegalStateException!", Toast.LENGTH_SHORT).show() - Crashlytics.logException(e) - } - catch (e: IOException) { - Toast.makeText(activity, "IOException!", Toast.LENGTH_SHORT).show() - Crashlytics.logException(e) - } - } - - override fun stop() { - if(!isRecording) return - try { - mRecorder.stop() - mRecorder.reset() - mRecorder.release() - isRecording = false - } catch (stopException: RuntimeException) { - Toast.makeText(activity, R.string.recording_toolbar_error_recording, Toast.LENGTH_SHORT).show() - Crashlytics.logException(stopException) - } catch (e: InterruptedException) { - Log.e(AUDIO_RECORDER, "Voice recorder interrupted!", e) - } - } -} - +package org.sil.storyproducer.tools.media + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.media.MediaMuxer +import android.media.MediaRecorder +import android.net.Uri +import android.support.v4.app.ActivityCompat +import android.support.v4.content.ContextCompat +import android.util.Log +import android.widget.Toast +import com.crashlytics.android.Crashlytics +import org.sil.storyproducer.R +import org.sil.storyproducer.model.Workspace +import org.sil.storyproducer.tools.file.copyToWorkspacePath +import org.sil.storyproducer.tools.file.getStoryFileDescriptor +import org.sil.storyproducer.tools.file.getStoryUri +import org.sil.storyproducer.tools.media.story.AutoStoryMaker +import org.sil.storyproducer.tools.media.story.StoryMaker +import org.sil.storyproducer.tools.media.story.StoryPage +import java.io.File +import java.io.IOException + + +//See https://developer.android.com/guide/topics/media/media-formats.html for supported formats. +internal val OUTPUT_FORMAT = MediaRecorder.OutputFormat.MPEG_4 +internal val AUDIO_ENCODER = MediaRecorder.AudioEncoder.AAC +internal val SAMPLE_RATE = 44100 +internal val BIT_DEPTH = 16 +internal val AUDIO_CHANNELS = 1 +//Set bit rate to exact spec of Android doc or to SAMPLE_RATE * BIT_DEPTH. +internal val BIT_RATE = SAMPLE_RATE * BIT_DEPTH + +/** + * Thin wrapper for [MediaRecorder] which provides some default behavior for recorder. + */ + +private const val AUDIO_RECORDER = "audio_recorder" + +abstract class AudioRecorder(val activity: Activity) { + var isRecording = false + protected set + + init { + if (ContextCompat.checkSelfPermission(activity, + Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + + ActivityCompat.requestPermissions(activity, + arrayOf(Manifest.permission.RECORD_AUDIO), 1) + } + } + + abstract fun startNewRecording(relPath: String) + + abstract fun stop() + + companion object { + /** + * This class is used to concatenate two Wav files together. + *

    + * Assumes the header of the Wav file resembles Microsoft's RIFF specification.

    + * A specification can be found [here](http://soundfile.sapp.org/doc/WaveFormat/). + */ + + fun concatenateAudioFiles(context: Context, orgAudioRelPath: String, appendAudioRelPath: String) { + + val tempDestPath = "${context.filesDir}/temp.mp4" + + + val outputFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4 + val audioFormat = AutoStoryMaker.generateAudioFormat() + val pages: MutableList = mutableListOf() + + var duration = MediaHelper.getAudioDuration(context, getStoryUri(orgAudioRelPath)!!) + pages.add(StoryPage("",orgAudioRelPath,duration,null,null)) + duration = MediaHelper.getAudioDuration(context, getStoryUri(appendAudioRelPath)!!) + pages.add(StoryPage("",appendAudioRelPath,duration,null,null)) + + //If pages weren't generated, exit. + val mStoryMaker = StoryMaker(context, File(tempDestPath), outputFormat, null, audioFormat, + pages.toTypedArray(), 10000, 10000) + + mStoryMaker.churn() + mStoryMaker.close() + + copyToWorkspacePath(context, Uri.fromFile(File(tempDestPath)), + "${Workspace.activeDirRoot}/$orgAudioRelPath") + File(tempDestPath).delete() + } + } +} + + +class AudioRecorderMP4(activity: Activity) : AudioRecorder(activity) { + + private var mRecorder = MediaRecorder() + + private fun initRecorder(){ + mRecorder.release() + mRecorder = MediaRecorder() + mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) + mRecorder.setOutputFormat(OUTPUT_FORMAT) + mRecorder.setAudioEncoder(AUDIO_ENCODER) + mRecorder.setAudioEncodingBitRate(BIT_RATE) + mRecorder.setAudioSamplingRate(SAMPLE_RATE) + mRecorder.setAudioChannels(AUDIO_CHANNELS) + } + + override fun startNewRecording(relPath: String){ + initRecorder() + mRecorder.setOutputFile(getStoryFileDescriptor(activity, relPath,"","w")) + isRecording = true + try{ + mRecorder.prepare() + mRecorder.start() + } + catch (e: IllegalStateException) { + Toast.makeText(activity, "IllegalStateException!", Toast.LENGTH_SHORT).show() + Crashlytics.logException(e) + } + catch (e: IOException) { + Toast.makeText(activity, "IOException!", Toast.LENGTH_SHORT).show() + Crashlytics.logException(e) + } + } + + override fun stop() { + if(!isRecording) return + try { + mRecorder.stop() + mRecorder.reset() + mRecorder.release() + isRecording = false + } catch (stopException: RuntimeException) { + Toast.makeText(activity, R.string.recording_toolbar_error_recording, Toast.LENGTH_SHORT).show() + Crashlytics.logException(stopException) + } catch (e: InterruptedException) { + Log.e(AUDIO_RECORDER, "Voice recorder interrupted!", e) + } + } +} + diff --git a/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryFrameDrawer.kt b/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryFrameDrawer.kt index c9099cfb..c1e26ad9 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryFrameDrawer.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryFrameDrawer.kt @@ -1,198 +1,198 @@ -package org.sil.storyproducer.tools.media.story - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.media.MediaFormat -import android.util.Log -import org.sil.storyproducer.tools.BitmapScaler -import org.sil.storyproducer.tools.file.getDownsample -import org.sil.storyproducer.tools.file.getStoryImage -import org.sil.storyproducer.tools.media.MediaHelper -import org.sil.storyproducer.tools.media.pipe.PipedVideoSurfaceEncoder - -/** - * This class knows how to draw the frames provided to it by [StoryMaker]. - */ -internal class StoryFrameDrawer(private val context: Context, private val mVideoFormat: MediaFormat, private val mPages: Array, private val mAudioTransitionUs: Long, slideCrossFadeUs: Long) : PipedVideoSurfaceEncoder.Source { - private val xTime: Long //transition (cross fade) time - - private val mFrameRate: Int - - private val mWidth: Int - private val mHeight: Int - - private val mBitmapPaint: Paint - - private var slideIndex = -1 //starts at -1 to allow initial transition - private var slideAudioStart: Long = 0 - private var slideAudioEnd: Long = 0 - private var nSlideAudioEnd: Long = 0 - private val slideVisStart: Long - get() {return if(slideIndex<=0){slideAudioStart} else {slideAudioStart-xTime/2}} - private val slideXStart: Long //beginning of the next transition - get() {return if(slideIndex>=mPages.size-1){slideAudioEnd} else {slideAudioEnd-xTime/2}} - private val slideXEnd: Long //end of the next transition - get() {return if(slideIndex>=mPages.size-1){slideAudioEnd} else {slideAudioEnd+xTime/2}} - private val nSlideXEnd: Long //end of the next transition - get() {return if(slideIndex>=mPages.size-2){nSlideAudioEnd} else {nSlideAudioEnd+xTime/2}} - private val slideVisDur: Long // the visible duration of the slide - get() {return slideXEnd - slideVisStart} - private val nSlideVisDur: Long // the visible duration of the next slide - get() {return nSlideXEnd - slideXStart} - - private var mCurrentFrame = 0 - - private var mIsVideoDone = false - - private var bitmaps: MutableMap = mutableMapOf() - private var downsamples: MutableMap = mutableMapOf() - - init { - - var correctedSlideTransitionUs = slideCrossFadeUs - - //mSlideTransition must never exceed the length of slides in terms of audio. - //Pre-process pages and clip the slide transition time to fit in all cases. - for (page in mPages) { - val totalPageUs = page.audioDuration + mAudioTransitionUs - if (correctedSlideTransitionUs > totalPageUs) { - correctedSlideTransitionUs = totalPageUs - Log.d(TAG, "Corrected slide transition from $slideCrossFadeUs to $correctedSlideTransitionUs") - } - } - - xTime = correctedSlideTransitionUs - - mFrameRate = mVideoFormat.getInteger(MediaFormat.KEY_FRAME_RATE) - - mWidth = mVideoFormat.getInteger(MediaFormat.KEY_WIDTH) - mHeight = mVideoFormat.getInteger(MediaFormat.KEY_HEIGHT) - - mBitmapPaint = Paint() - mBitmapPaint.isAntiAlias = true - mBitmapPaint.isFilterBitmap = true - mBitmapPaint.isDither = true - } - - override fun getMediaType(): MediaHelper.MediaType { - return MediaHelper.MediaType.VIDEO - } - - override fun getOutputFormat(): MediaFormat { - return mVideoFormat - } - - override fun isDone(): Boolean { - return mIsVideoDone - } - - override fun setup() {} - - override fun fillCanvas(canv: Canvas): Long { - - //[-|-page-1-|-| ] - // [ |-|-page-2-|-| ] - // [ |-|-page-last-|-] - // | | | (two bars) = transition time (xtime) - // | | (one bar) = 1/2 xtime - // --- (dash) sound playing from slide - // Exclusive time + xtime/2 for first and last slide - // "current page" is the page until it ends - // "Next page" is growing in intensity for "xtime" - // Visible time - - //Each time this is called, go forward 1/30 of a second. - val cTime = MediaHelper.getTimeFromIndex(mFrameRate.toLong(), mCurrentFrame) - - if(cTime > slideXEnd){ - //go to the next slide - slideIndex++ - - if (slideIndex >= mPages.size) { - mIsVideoDone = true - } else { - slideAudioStart = slideAudioEnd - slideAudioEnd += mPages[slideIndex].getDuration(mAudioTransitionUs) - - if (slideIndex + 1 < mPages.size) { - nSlideAudioEnd = slideAudioEnd + mPages[slideIndex + 1].getDuration(mAudioTransitionUs) - } - } - } - - drawFrame(canv, slideIndex, cTime - slideVisStart, slideVisDur, - 1f) - - if (cTime >= slideXStart) { - var alpha = (cTime - slideXStart) / xTime.toFloat() - if(cTime < xTime.toFloat()/2) - alpha = 1.0f - drawFrame(canv, slideIndex + 1, cTime - slideXStart, nSlideVisDur, - alpha) - } - - //clear image cache to save memory. - if(slideIndex >= 1 && slideIndex < mPages.size) { - if (bitmaps.containsKey(mPages[slideIndex - 1].imRelPath) && - mPages[slideIndex - 1].imRelPath != mPages[slideIndex].imRelPath) { - bitmaps.remove(mPages[slideIndex - 1].imRelPath) - } - } - - mCurrentFrame++ - - return cTime - } - - private fun drawFrame(canv: Canvas, pageIndex: Int, timeOffsetUs: Long, imgDurationUs: Long, - alpha: Float) { - //In edge cases, draw a black frame with alpha value. - if (pageIndex < 0 || pageIndex >= mPages.size) { - canv.drawARGB((alpha * 255).toInt(), 0, 0, 0) - return - } - - val page = mPages[pageIndex] - if(!bitmaps.containsKey(page.imRelPath)){ - val ds = getDownsample(context,page.imRelPath,mWidth*2, mHeight*2) - downsamples[page.imRelPath] = ds - bitmaps[page.imRelPath] = getStoryImage(context,page.imRelPath,ds,true) - } - val bitmap = bitmaps[page.imRelPath] - val downSample = downsamples[page.imRelPath]!! - - if (bitmap != null) { - val position = (timeOffsetUs / imgDurationUs.toDouble()).toFloat() - - //If ken burns, then interpolate - val drawRect = page.kenBurnsEffect?. - revInterpolate(position,mWidth,mHeight,bitmap.width,bitmap.height,downSample*1f) ?: - //else, fit to crop the height and width to show everything. - BitmapScaler.centerCropRectF( - bitmap.height, bitmap.width, mHeight, mWidth) - - mBitmapPaint.alpha = (alpha * 255).toInt() - - canv.drawBitmap(bitmap, null, drawRect, mBitmapPaint) - } else { - //If there is no picture, draw black background for text overlay. - canv.drawARGB((alpha * 255).toInt(), 0, 0, 0) - } - - val tOverlay = page.textOverlay - if (tOverlay != null) { - tOverlay.setAlpha(alpha) - tOverlay.draw(canv) - } - } - - override fun close() { - bitmaps.clear() - } - - companion object { - private val TAG = "StoryFrameDrawer" - } -} +package org.sil.storyproducer.tools.media.story + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.media.MediaFormat +import android.util.Log +import org.sil.storyproducer.tools.BitmapScaler +import org.sil.storyproducer.tools.file.getDownsample +import org.sil.storyproducer.tools.file.getStoryImage +import org.sil.storyproducer.tools.media.MediaHelper +import org.sil.storyproducer.tools.media.pipe.PipedVideoSurfaceEncoder + +/** + * This class knows how to draw the frames provided to it by [StoryMaker]. + */ +internal class StoryFrameDrawer(private val context: Context, private val mVideoFormat: MediaFormat, private val mPages: Array, private val mAudioTransitionUs: Long, slideCrossFadeUs: Long) : PipedVideoSurfaceEncoder.Source { + private val xTime: Long //transition (cross fade) time + + private val mFrameRate: Int + + private val mWidth: Int + private val mHeight: Int + + private val mBitmapPaint: Paint + + private var slideIndex = -1 //starts at -1 to allow initial transition + private var slideAudioStart: Long = 0 + private var slideAudioEnd: Long = 0 + private var nSlideAudioEnd: Long = 0 + private val slideVisStart: Long + get() {return if(slideIndex<=0){slideAudioStart} else {slideAudioStart-xTime/2}} + private val slideXStart: Long //beginning of the next transition + get() {return if(slideIndex>=mPages.size-1){slideAudioEnd} else {slideAudioEnd-xTime/2}} + private val slideXEnd: Long //end of the next transition + get() {return if(slideIndex>=mPages.size-1){slideAudioEnd} else {slideAudioEnd+xTime/2}} + private val nSlideXEnd: Long //end of the next transition + get() {return if(slideIndex>=mPages.size-2){nSlideAudioEnd} else {nSlideAudioEnd+xTime/2}} + private val slideVisDur: Long // the visible duration of the slide + get() {return slideXEnd - slideVisStart} + private val nSlideVisDur: Long // the visible duration of the next slide + get() {return nSlideXEnd - slideXStart} + + private var mCurrentFrame = 0 + + private var mIsVideoDone = false + + private var bitmaps: MutableMap = mutableMapOf() + private var downsamples: MutableMap = mutableMapOf() + + init { + + var correctedSlideTransitionUs = slideCrossFadeUs + + //mSlideTransition must never exceed the length of slides in terms of audio. + //Pre-process pages and clip the slide transition time to fit in all cases. + for (page in mPages) { + val totalPageUs = page.audioDuration + mAudioTransitionUs + if (correctedSlideTransitionUs > totalPageUs) { + correctedSlideTransitionUs = totalPageUs + Log.d(TAG, "Corrected slide transition from $slideCrossFadeUs to $correctedSlideTransitionUs") + } + } + + xTime = correctedSlideTransitionUs + + mFrameRate = mVideoFormat.getInteger(MediaFormat.KEY_FRAME_RATE) + + mWidth = mVideoFormat.getInteger(MediaFormat.KEY_WIDTH) + mHeight = mVideoFormat.getInteger(MediaFormat.KEY_HEIGHT) + + mBitmapPaint = Paint() + mBitmapPaint.isAntiAlias = true + mBitmapPaint.isFilterBitmap = true + mBitmapPaint.isDither = true + } + + override fun getMediaType(): MediaHelper.MediaType { + return MediaHelper.MediaType.VIDEO + } + + override fun getOutputFormat(): MediaFormat { + return mVideoFormat + } + + override fun isDone(): Boolean { + return mIsVideoDone + } + + override fun setup() {} + + override fun fillCanvas(canv: Canvas): Long { + + //[-|-page-1-|-| ] + // [ |-|-page-2-|-| ] + // [ |-|-page-last-|-] + // | | | (two bars) = transition time (xtime) + // | | (one bar) = 1/2 xtime + // --- (dash) sound playing from slide + // Exclusive time + xtime/2 for first and last slide + // "current page" is the page until it ends + // "Next page" is growing in intensity for "xtime" + // Visible time + + //Each time this is called, go forward 1/30 of a second. + val cTime = MediaHelper.getTimeFromIndex(mFrameRate.toLong(), mCurrentFrame) + + if(cTime > slideXEnd){ + //go to the next slide + slideIndex++ + + if (slideIndex >= mPages.size) { + mIsVideoDone = true + } else { + slideAudioStart = slideAudioEnd + slideAudioEnd += mPages[slideIndex].getDuration(mAudioTransitionUs) + + if (slideIndex + 1 < mPages.size) { + nSlideAudioEnd = slideAudioEnd + mPages[slideIndex + 1].getDuration(mAudioTransitionUs) + } + } + } + + drawFrame(canv, slideIndex, cTime - slideVisStart, slideVisDur, + 1f) + + if (cTime >= slideXStart) { + var alpha = (cTime - slideXStart) / xTime.toFloat() + if(cTime < xTime.toFloat()/2) + alpha = 1.0f + drawFrame(canv, slideIndex + 1, cTime - slideXStart, nSlideVisDur, + alpha) + } + + //clear image cache to save memory. + if(slideIndex >= 1 && slideIndex < mPages.size) { + if (bitmaps.containsKey(mPages[slideIndex - 1].imRelPath) && + mPages[slideIndex - 1].imRelPath != mPages[slideIndex].imRelPath) { + bitmaps.remove(mPages[slideIndex - 1].imRelPath) + } + } + + mCurrentFrame++ + + return cTime + } + + private fun drawFrame(canv: Canvas, pageIndex: Int, timeOffsetUs: Long, imgDurationUs: Long, + alpha: Float) { + //In edge cases, draw a black frame with alpha value. + if (pageIndex < 0 || pageIndex >= mPages.size) { + canv.drawARGB((alpha * 255).toInt(), 0, 0, 0) + return + } + + val page = mPages[pageIndex] + if(!bitmaps.containsKey(page.imRelPath)){ + val ds = getDownsample(context,page.imRelPath,mWidth*2, mHeight*2) + downsamples[page.imRelPath] = ds + bitmaps[page.imRelPath] = getStoryImage(context,page.imRelPath,ds,true) + } + val bitmap = bitmaps[page.imRelPath] + val downSample = downsamples[page.imRelPath]!! + + if (bitmap != null) { + val position = (timeOffsetUs / imgDurationUs.toDouble()).toFloat() + + //If ken burns, then interpolate + val drawRect = page.kenBurnsEffect?. + revInterpolate(position,mWidth,mHeight,bitmap.width,bitmap.height,downSample*1f) ?: + //else, fit to crop the height and width to show everything. + BitmapScaler.centerCropRectF( + bitmap.height, bitmap.width, mHeight, mWidth) + + mBitmapPaint.alpha = (alpha * 255).toInt() + + canv.drawBitmap(bitmap, null, drawRect, mBitmapPaint) + } else { + //If there is no picture, draw black background for text overlay. + canv.drawARGB((alpha * 255).toInt(), 0, 0, 0) + } + + val tOverlay = page.textOverlay + if (tOverlay != null) { + tOverlay.setAlpha(alpha) + tOverlay.draw(canv) + } + } + + override fun close() { + bitmaps.clear() + } + + companion object { + private val TAG = "StoryFrameDrawer" + } +} From a85e06ba7a6f4a171a0169c4f6f3bbb6bc998925 Mon Sep 17 00:00:00 2001 From: Philip White Date: Tue, 22 Oct 2019 10:39:04 -0400 Subject: [PATCH 06/64] Fixup fomatting of PhaseType enum --- .../java/org/sil/storyproducer/model/Phase.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/sil/storyproducer/model/Phase.kt b/app/src/main/java/org/sil/storyproducer/model/Phase.kt index 287fbae9..2905b118 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Phase.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Phase.kt @@ -11,7 +11,19 @@ import org.sil.storyproducer.controller.remote.WholeStoryBackTranslationActivity enum class PhaseType { - WORKSPACE, REGISTRATION, STORY_LIST, LEARN, DRAFT, COMMUNITY_CHECK, CONSULTANT_CHECK, DRAMATIZATION, CREATE, SHARE, BACKT, WHOLE_STORY, REMOTE_CHECK + WORKSPACE, + REGISTRATION, + STORY_LIST, + LEARN, + DRAFT, + COMMUNITY_CHECK, + CONSULTANT_CHECK, + DRAMATIZATION, + CREATE, + SHARE, + BACKT, + WHOLE_STORY, + REMOTE_CHECK } /** @@ -199,4 +211,4 @@ class Phase(val phaseType: PhaseType) { return "${phase.name.toLowerCase()}.html" } } -} \ No newline at end of file +} From fd4bfc9140d8e6e4cc9f6dcb482e569450529049 Mon Sep 17 00:00:00 2001 From: Philip White Date: Sun, 3 Nov 2019 16:15:25 +0000 Subject: [PATCH 07/64] Add whole story back translation activity and clean up some random implementation things --- .../controller/SlidePhaseFrag.kt | 2 + .../controller/learn/LearnActivity.kt | 20 +- .../controller/pager/PagerAdapter.kt | 8 +- .../controller/phase/PhaseBaseActivity.kt | 42 +- .../WholeStoryBackTranslationActivity.java | 449 ------------------ .../WholeStoryBackTranslationActivity.kt | 248 ++++++++++ .../java/org/sil/storyproducer/model/Phase.kt | 3 + .../org/sil/storyproducer/model/Workspace.kt | 9 +- .../storyproducer/tools/file/AudioFiles.kt | 3 +- .../sil/storyproducer/tools/file/FileIO.kt | 4 +- .../storyproducer/tools/media/AudioPlayer.kt | 91 ++-- .../tools/toolbar/PlayBackRecordingToolbar.kt | 42 +- .../main/res/layout/activity_whole_story.xml | 86 ++++ 13 files changed, 452 insertions(+), 555 deletions(-) delete mode 100644 app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.java create mode 100644 app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt create mode 100644 app/src/main/res/layout/activity_whole_story.xml diff --git a/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt b/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt index fc19dee1..0c1066e7 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt @@ -7,6 +7,7 @@ import android.support.design.widget.Snackbar import android.support.v4.app.Fragment import android.view.* import android.widget.* +import android.util.Log; import org.sil.storyproducer.R import org.sil.storyproducer.controller.phase.PhaseBaseActivity import org.sil.storyproducer.model.* @@ -39,6 +40,7 @@ abstract class SlidePhaseFrag : Fragment() { slideNum = this.arguments!!.getInt(SLIDE_NUM) slide = Workspace.activeStory.slides[slideNum] setHasOptionsMenu(true) + Log.e("@pwhite", "file: ${Workspace.activePhase.getReferenceAudioFile(slideNum)}") } override fun onCreateView(inflater: LayoutInflater, diff --git a/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt index b0159ae1..fe86bb5f 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt @@ -49,7 +49,12 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia super.onCreate(savedInstanceState) setContentView(R.layout.activity_learn) - setToolbar() + // Insert toolbar fragment into UI + val bundle = Bundle() + bundle.putInt(SLIDE_NUM, 0) + recordingToolbar.arguments = bundle + supportFragmentManager?.beginTransaction()?.replace(R.id.toolbar_for_recording_toolbar, recordingToolbar)?.commit() + recordingToolbar.keepToolbarVisible() learnImageView = findViewById(R.id.fragment_image_view) playButton = findViewById(R.id.fragment_reference_audio_button) @@ -147,8 +152,10 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia if(recordingToolbar.isRecording || recordingToolbar.isAudioPlaying){ videoSeekBar?.progress = min((System.currentTimeMillis() - seekbarStartTime).toInt(),videoSeekBar!!.max) setSlideFromSeekbar() - }else{ + } else if (narrationPlayer.isAudioPrepared) { if(curPos >= 0) videoSeekBar?.progress = slideStartTimes[curPos] + narrationPlayer.currentPosition + } else { + videoSeekBar?.progress = 0 } } } @@ -174,15 +181,6 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia } - private fun setToolbar(){ - val bundle = Bundle() - bundle.putInt(SLIDE_NUM, 0) - recordingToolbar.arguments = bundle - supportFragmentManager?.beginTransaction()?.replace(R.id.toolbar_for_recording_toolbar, recordingToolbar)?.commit() - - recordingToolbar.keepToolbarVisible() - } - override fun onStoppedToolbarRecording() { makeLogIfNecessary(true) diff --git a/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt b/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt index cb50b8b3..051dec10 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.support.v4.app.Fragment import android.support.v4.app.FragmentManager import android.support.v4.app.FragmentStatePagerAdapter +import android.util.Log; import org.sil.storyproducer.controller.community.CommunityCheckFrag import org.sil.storyproducer.controller.consultant.ConsultantCheckFrag import org.sil.storyproducer.controller.draft.DraftFrag @@ -26,6 +27,7 @@ class PagerAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm) { override fun getItem(i: Int): Fragment { val fragment: Fragment val passedArgs = Bundle() + Log.e("@pwhite", "PagerAdapter.getItem(): phase type is ${Workspace.activePhase.phaseType}"); when (Workspace.activePhase.phaseType) { PhaseType.DRAFT -> { fragment = DraftFrag() @@ -39,9 +41,9 @@ class PagerAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm) { PhaseType.DRAMATIZATION -> { fragment = DramatizationFrag() } -// PhaseType.BACKT -> { -// fragment = BackTranslationFrag() -// } + //PhaseType.BACKT -> { + //fragment = BackTranslationFrag() + //} PhaseType.REMOTE_CHECK -> { fragment = RemoteCheckFrag() } diff --git a/app/src/main/java/org/sil/storyproducer/controller/phase/PhaseBaseActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/phase/PhaseBaseActivity.kt index 16bf4e24..52001c0d 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/phase/PhaseBaseActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/phase/PhaseBaseActivity.kt @@ -17,6 +17,7 @@ import android.support.v4.widget.DrawerLayout import android.support.v7.app.ActionBarDrawerToggle import android.support.v7.app.AppCompatActivity import android.support.v7.widget.Toolbar +import android.util.Log import android.view.* import android.webkit.WebView import android.widget.* @@ -220,6 +221,8 @@ abstract class PhaseBaseActivity : AppCompatActivity(), AdapterView.OnItemSelect fun jumpToPhase(newPhase: Phase) { if(newPhase.phaseType == phase.phaseType) return Workspace.activePhase = newPhase + val c = newPhase.getTheClass() + Log.e("@pwhite", "sending intent to class $c") val intent = Intent(this.applicationContext, newPhase.getTheClass()) intent.putExtra("storyname", Workspace.activeStory.title) startActivity(intent) @@ -265,28 +268,23 @@ abstract class PhaseBaseActivity : AppCompatActivity(), AdapterView.OnItemSelect val downSample = 2 var slidePicture: Bitmap = getStoryImage(this, slideNum, downSample) - //scale down image to not crash phone from memory error from displaying too large an image - //Get the height of the phone. - val phoneProperties = this.resources.displayMetrics - var height = phoneProperties.heightPixels - val scalingFactor = 0.4 - height = (height * scalingFactor).toInt() - val width = phoneProperties.widthPixels - - //scale bitmap - slidePicture = BitmapScaler.centerCrop(slidePicture, height, width) - - //draw the text overlay - slidePicture = slidePicture.copy(Bitmap.Config.RGB_565, true) - val canvas = Canvas(slidePicture) - //only show the untranslated title in the Learn phase. - val tOverlay = if (Workspace.activePhase.phaseType == PhaseType.LEARN) - Workspace.activeStory.slides[slideNum].getOverlayText(false, true) - else Workspace.activeStory.slides[slideNum].getOverlayText(false, false) - //if overlay is null, it will not write the text. - tOverlay?.setPadding(max(20, 20 + (canvas.width - phoneProperties.widthPixels) / 2)) - tOverlay?.draw(canvas) - + if (slideNum < Workspace.activeStory.slides.size) { + //scale down image to not crash phone from memory error from displaying too large an image + //Get the height of the phone. + val scalingFactor = 0.4 + var height = (resources.displayMetrics.heightPixels * scalingFactor).toInt() + val width = resources.displayMetrics.widthPixels + + slidePicture = BitmapScaler.centerCrop(slidePicture, height, width) + slidePicture = slidePicture.copy(Bitmap.Config.RGB_565, true) + val canvas = Canvas(slidePicture) + //only show the untranslated title in the Learn phase. + val tOverlay = Workspace.activeStory.slides[slideNum] + .getOverlayText(false, Workspace.activePhase.phaseType == PhaseType.LEARN) + //if overlay is null, it will not write the text. + tOverlay?.setPadding(max(20, 20 + (canvas.width - width) / 2)) + tOverlay?.draw(canvas) + } //Set the height of the image view slideImage.requestLayout() diff --git a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.java b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.java deleted file mode 100644 index 04e6197a..00000000 --- a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.java +++ /dev/null @@ -1,449 +0,0 @@ -package org.sil.storyproducer.controller.remote; - -import android.media.MediaPlayer; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.CompoundButton; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.RelativeLayout; -import android.widget.SeekBar; -import android.widget.Switch; - -import org.sil.storyproducer.R; -import org.sil.storyproducer.controller.phase.PhaseBaseActivity; -import org.sil.storyproducer.tools.media.AudioPlayer; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - -/** - * Created by annmcostantino on 1/14/2018. - */ - -public class WholeStoryBackTranslationActivity extends PhaseBaseActivity { - - private final static float BACKGROUND_VOLUME = 0.0f; //makes for no background music but still keeps the functionality in there if we decide to change it later - - private RelativeLayout rootView; - private ImageView wStoryImageView; - private ImageButton playButton; - private SeekBar videoSeekBar; - private AudioPlayer narrationPlayer; - private AudioPlayer backgroundPlayer; - private boolean backgroundAudioExists; - - private int slideNumber = 0; - private int CONTENT_SLIDE_COUNT = 0; - private String storyName; - private boolean isVolumeOn = true; - private List backgroundAudioJumps; - - //recording toolbar vars - private String recordFilePath; - //private RecordingToolbar recordingToolbar; - - private boolean isFirstTime = true; //used to know if it is the first time the activity is started up for playing the vid - private int startPos = -1; - private long startTime = -1; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - /*FIXME - setContentView(R.layout.activity_whole_story); - rootView = findViewById(R.id.phase_frame); - - //get the story name - storyName = StoryState.getStoryName(); - CONTENT_SLIDE_COUNT = FileSystem.getContentSlideAmount(storyName); - - //get the ui - wStoryImageView = findViewById(R.id.wholeStoryImageView); - playButton = findViewById(R.id.playButton); - videoSeekBar = findViewById(R.id.videoSeekBar); - - setBackgroundAudioJumps(); - - setSeekBarListener(); - - setPic(wStoryImageView); //set the first image to show - - //set the recording toolbar stuffs - recordFilePath = AudioFiles.INSTANCE.getWholeStory(StoryState.getStoryName()).getPath(); - View rootViewToolbar = getLayoutInflater().inflate(R.layout.toolbar_for_recording, rootView, false); - setToolbar(rootViewToolbar); - invalidateOptionsMenu(); - setVolumeSwitchAndFloatingButtonVisible(); - recordingToolbar.keepToolbarVisible(); - */ - } - - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - MenuItem item = menu.getItem(0); - item.setIcon(R.drawable.ic_school_white_48dp); - return true; - } - - /** - * Starts the background music player - */ - private void playBackgroundMusic() { - if (backgroundAudioExists) { - backgroundPlayer.playAudio(); - } - } - - /** - * Sets the array list for all the jump points that the background music has to make - */ - private void setBackgroundAudioJumps() { - int audioStartValue = 0; - backgroundAudioJumps = new ArrayList<>(); - backgroundAudioJumps.add(0, audioStartValue); - for(int k = 0; k < CONTENT_SLIDE_COUNT; k++) { - //FIXME - //String lwcPath = AudioFiles.getNarration(storyName, k).getPath(); - //audioStartValue += MediaHelper.getAudioDuration(lwcPath) / 1000; - backgroundAudioJumps.add(k, audioStartValue); - } - backgroundAudioJumps.add(audioStartValue); //this last one is just added for the copyrights slide - } - - public void onStart() { - super.onStart(); - //create audio players - narrationPlayer = new AudioPlayer(); - narrationPlayer.onPlayBackStop(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer mp) { - slideNumber++; //move to the next slide - if(slideNumber < CONTENT_SLIDE_COUNT) { //not at the end of video - playVideo(); - } else { //at the end of video so special case - //makeLogIfNecessary(true); - - videoSeekBar.setProgress(CONTENT_SLIDE_COUNT); - playButton.setImageResource(R.drawable.ic_play_circle_outline_white_36dp); - setPic(wStoryImageView); //sets the pic to the end image - } - } - }); - //recordingToolbar.hideFloatingActionButton(); - - backgroundPlayer = new AudioPlayer(); - backgroundPlayer.setVolume(BACKGROUND_VOLUME); - //FIXME -/* - File backgroundAudioFile = AudioFiles.INSTANCE.getSoundtrack(StoryState.getStoryName()); - if (backgroundAudioFile.exists()) { - backgroundAudioExists = true; - backgroundPlayer.setSource(backgroundAudioFile.getPath()); - } else { - backgroundAudioExists = false; - } -*/ - } - - /*private void markLogStart() { - startPos = slideNumber; - startTime = System.currentTimeMillis(); - } - - private void makeLogIfNecessary(){ - makeLogIfNecessary(false); - } - - private void makeLogIfNecessary(boolean request){ - if(narrationPlayer.isAudioPlaying() || backgroundPlayer.isAudioPlaying() - || request){ - if(startPos!=-1) { - WholeStoryEntry.saveFilteredLogEntry(startPos, slideNumber, - System.currentTimeMillis() - startTime); - startPos=-1; - } - } - }*/ - - @Override - public void onPause() { - super.onPause(); - pauseVideo(); -// if (recordingToolbar != null) { -// recordingToolbar.onPause(); -// //FIXME -// //recordingToolbar.closeToolbar(); -// } - } - - @Override - public void onResume() { - super.onResume(); - //recordingToolbar.hideFloatingActionButton(); - } - - @Override - public void onStop() { - super.onStop(); - narrationPlayer.release(); - backgroundPlayer.release(); -// if (recordingToolbar != null) { -// recordingToolbar.onPause(); -// recordingToolbar.close(); -// } - - } - - /** - * Plays the video and runs every time the audio is completed - */ - void playVideo() { - setPic(wStoryImageView); //set the next image - //Fixme get draft audio -// File audioFile = AudioFiles.INSTANCE.getDraft(storyName, slideNumber); -// //set the next audio -// if (audioFile.exists()) { -// narrationPlayer.setVolume((isVolumeOn)? 1.0f : 0.0f); //set the volume on or off based on the boolean -// //FIXME -// //narrationPlayer.setSource(audioFile.getPath()); -// narrationPlayer.playStoryAudio(); -// } - - videoSeekBar.setProgress(slideNumber); - } - - /** - * Button action for playing/pausing the audio - * @param view button to set listeners for - */ - public void onClickPlayPauseButton(View view) { - /* FIXME - if(narrationPlayer.isAudioPlaying()) { - pauseVideo(); - - } - //if no draft audio exists - else if(!AudioFiles.INSTANCE.allDraftsComplete(storyName,FileSystem.getContentSlideAmount(storyName))){ - AlertDialog dialog = new AlertDialog.Builder(this) - .setTitle(this.getString(R.string.wsbt_alert_title)) - .setMessage(this.getString(R.string.wsbt_alert_text)) - .setPositiveButton(this.getString(R.string.ok), new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - - } - }).create(); - dialog.show(); - } else { - //markLogStart(); - - playButton.setImageResource(R.drawable.ic_pause_gray); - - if(slideNumber >= CONTENT_SLIDE_COUNT) { //reset the video to the beginning because they already finished it - videoSeekBar.setProgress(0); - slideNumber = 0; - playBackgroundMusic(); - playStoryAudio(); - } else { - resumeVideo(); - } - } - */ - } - - /** - * helper function for pausing the video - */ - private void pauseVideo() { - //makeLogIfNecessary(); - narrationPlayer.pauseAudio(); - backgroundPlayer.pauseAudio(); - playButton.setImageResource(R.drawable.ic_play_circle_outline_white_36dp); - } - - /** - * helper function for resuming the video - */ - private void resumeVideo() { - if(isFirstTime) { //actually start playing the video if playStoryAudio() has never been called - playVideo(); - isFirstTime = false; - } else { - narrationPlayer.resumeAudio(); - if (backgroundAudioExists) { - backgroundPlayer.resumeAudio(); - } - } - } - - /** - * Sets the seekBar listener for the video seek bar - */ - private void setSeekBarListener() { - videoSeekBar.setMax(CONTENT_SLIDE_COUNT); //set the progress bar to have as many markers as images - videoSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onStopTrackingTouch(SeekBar sBar){ - } - @Override - public void onStartTrackingTouch(SeekBar sBar){ - } - @Override - public void onProgressChanged(SeekBar sBar, int progress, boolean fromUser) { - if(fromUser) { - //makeLogIfNecessary(); - - slideNumber = progress; - narrationPlayer.stopAudio(); - if(backgroundAudioExists) { - backgroundPlayer.seekTo(backgroundAudioJumps.get(slideNumber)); - if (!backgroundPlayer.isAudioPlaying()) { - backgroundPlayer.resumeAudio(); - } - } - if(slideNumber == CONTENT_SLIDE_COUNT) { - playButton.setImageResource(R.drawable.ic_play_circle_outline_white_36dp); - setPic(wStoryImageView); //sets the pic to the end image - } else { - //markLogStart(); - playVideo(); - playButton.setImageResource(R.drawable.ic_pause_circle_outline_white_36dp); - } - - } - } - }); - } - - /** - * helper function that resets the video to the beginning and turns off the sound - */ - private void resetVideoWithSoundOff() { - /* - videoSeekBar.setProgress(0); - slideNumber = 0; - narrationPlayer.setVolume(0.0f); - Switch volumeSwitch = findViewById(R.id.volumeSwitch); - backgroundPlayer.stopAudio(); - volumeSwitch.setChecked(false); - backgroundPlayer.stopAudio(); - backgroundPlayer.setVolume(0.0f); - playBackgroundMusic(); - isVolumeOn = false; - - //markLogStart(); - if(AudioFiles.INSTANCE.allDraftsComplete(storyName,FileSystem.getContentSlideAmount(storyName))){ - playButton.setImageResource(R.drawable.ic_pause_gray); - playStoryAudio(); - } - */ - } - - /** - * Makes the volume switch visible so it can be used - */ - private void setVolumeSwitchAndFloatingButtonVisible() { - //make the floating button visible - //recordingToolbar.showFloatingActionButton(); - //make the sounds stuff visible - /* - ImageView soundOff = findViewById(R.id.soundOff); - ImageView soundOn = findViewById(R.id.soundOn); - Switch volumeSwitch = findViewById(R.id.volumeSwitch); - soundOff.setVisibility(View.VISIBLE); - soundOn.setVisibility(View.VISIBLE); - volumeSwitch.setVisibility(View.VISIBLE); - //set the volume switch change listener - volumeSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - if(isChecked) { - narrationPlayer.setVolume(1.0f); - backgroundPlayer.setVolume(BACKGROUND_VOLUME); - isVolumeOn = true; - } else { - narrationPlayer.setVolume(0.0f); - backgroundPlayer.setVolume(0.0f); - isVolumeOn = false; - } - } - }); - */ - } - - /** - * This function allows the picture to scale with the phone's screen size. - * - * @param aView The ImageView that will contain the picture. - */ - private void setPic(View aView) { - /*FIXME - if (aView == null || !(aView instanceof ImageView)) { - return; - } - - ImageView slideImage = (ImageView) aView; - Bitmap slidePicture = ImageFiles.getBitmap(storyName, slideNumber); - if(slideNumber == CONTENT_SLIDE_COUNT) { //gets the end image if we are at the end of the story - slidePicture = ImageFiles.getBitmap(storyName, ImageFiles.COPYRIGHT); - } - - if(slidePicture == null){ - Snackbar.make(rootView, "Could Not Find Picture", Snackbar.LENGTH_SHORT).show(); - } - - //Get the height of the phone. - DisplayMetrics phoneProperties = getResources().getDisplayMetrics(); - int height = phoneProperties.heightPixels; - double scalingFactor = 0.4; - height = (int)(height * scalingFactor); - - //scale bitmap - slidePicture = BitmapScaler.scaleToFitHeight(slidePicture, height); - - //Set the height of the image view - slideImage.getLayoutParams().height = height; - slideImage.requestLayout(); - - slideImage.setImageBitmap(slidePicture); - */ - } - - /** - * Initializes the toolbar and toolbar buttons. - */ - private void setToolbar(View toolbar){ - //FIXME -/* - recordingToolbar = new RecordingToolbar(this, toolbar, rootView, true, false, false, true, recordFilePath, recordFilePath, null, new RecordingToolbar.RecordingListener() { - @Override - public void onStoppedRecordingOrPlayback() { - - } - @Override - public void onStartedRecordingOrPlayback(boolean isRecording) { - resetVideoWithSoundOff(); - } - }); -*/ - //recordingToolbar.hideFloatingActionButton(); - //The following allows for a touch from user to close the toolbar and make the fab visible. - //This does not stop the recording - /*RelativeLayout dummyView = (RelativeLayout) rootView.findViewById(R.id.activity_wholestorybacktranslation); - dummyView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - if (recordingToolbar != null && recordingToolbar.isOpen() && !recordingToolbar.isRecording()) { - recordingToolbar.keepToolbarVisible(); - recordingToolbar.hideFloatingActionButton(); - } - } - });*/ - } - - - -} - diff --git a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt new file mode 100644 index 00000000..125e68ae --- /dev/null +++ b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt @@ -0,0 +1,248 @@ +// TODO @pwhite: WholeStoryBackTranslationActivity and LearnActivity are +// extremely similar. The latter allows viewing of a *template*, and the former +// allows viewing of a *story*, which is essentially a translated template. The +// major difference is that the backtranslation should also allow uploading, +// but this is does not prevent us from extracting the common functionality. +package org.sil.storyproducer.controller.remote + +import android.media.MediaPlayer +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.View +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.SeekBar +import android.widget.Switch + +import org.sil.storyproducer.R +import org.sil.storyproducer.controller.phase.PhaseBaseActivity +import org.sil.storyproducer.model.SLIDE_NUM +import org.sil.storyproducer.model.SlideType +import org.sil.storyproducer.model.Story +import org.sil.storyproducer.tools.file.* +import org.sil.storyproducer.tools.media.AudioPlayer +import org.sil.storyproducer.tools.media.MediaHelper +import org.sil.storyproducer.tools.toolbar.PlayBackRecordingToolbar +import java.util.* +import kotlin.collections.ArrayList + +/** + * Created by annmcostantino on 1/14/2018. + * + * An interface for doing back translations on the whole story. There is an image and a seekbar + * which provide a UI for watching the video with both slides and audio. There is also a recording + * toolbar for recording and uploading audio. It is us + */ + +class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMediaListener { + + class DraftSlide(slideNum: Int, duration: Int, startTime: Int, filename: String) { + val slideNum: Int = slideNum + val duration: Int = duration + val startTime: Int = startTime + val filename: String = filename + } + + private lateinit var wholeStoryImageView: ImageView + private lateinit var playButton: ImageButton + private lateinit var seekBar: SeekBar + + private var mSeekBarTimer = Timer() + private var draftPlayer: AudioPlayer = AudioPlayer() + private var seekbarStartTime: Long = -1 + + private var isVolumeOn = true + + private var recordingToolbar: PlayBackRecordingToolbar = PlayBackRecordingToolbar() + + private var currentSlideIndex: Int = -1 + private val translatedSlides: MutableList = ArrayList() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_learn) + + val bundle = Bundle() + bundle.putInt(SLIDE_NUM, 0) + recordingToolbar.arguments = bundle + supportFragmentManager?.beginTransaction()?.replace(R.id.toolbar_for_recording_toolbar, recordingToolbar)?.commit() + recordingToolbar.keepToolbarVisible() + + wholeStoryImageView = findViewById(R.id.fragment_image_view) + playButton = findViewById(R.id.fragment_reference_audio_button) + seekBar = findViewById(R.id.videoSeekBar) + + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + var wasPlayingBeforeTouch = false + override fun onStopTrackingTouch(sBar: SeekBar) { + if (wasPlayingBeforeTouch) { + // Always start at the beginning of the slide. + if (currentSlideIndex < translatedSlides.size) { + seekBar.progress = translatedSlides[currentSlideIndex].startTime + } + playStoryAudio() + } + } + + override fun onStartTrackingTouch(sBar: SeekBar) { + wasPlayingBeforeTouch = draftPlayer.isAudioPlaying + } + + override fun onProgressChanged(sBar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + Log.e("@pwhite", "progress changed to $progress") + } + setSlideFromSeekbar() + } + }) + + val volumeSwitch = findViewById(R.id.volumeSwitch) + volumeSwitch.isChecked = true + volumeSwitch.setOnCheckedChangeListener { _, isChecked -> + isVolumeOn = if (isChecked) { + draftPlayer.setVolume(1.0f) + true + } else { + draftPlayer.setVolume(0.0f) + false + } + } + + //get story audio duration + var lastEndTime = 0 + story.slides.forEachIndexed { slideNum, slide -> + // Don't play the copyright translatedSlides. + if (slide.slideType == SlideType.FRONTCOVER || slide.slideType == SlideType.NUMBEREDPAGE) { + val filename = Story.getFilename(slide.chosenDraftFile) + if (storyRelPathExists(this, filename)) { + val duration = (MediaHelper.getAudioDuration(this, getStoryUri(filename)!!) / 1000).toInt() + val startTime = lastEndTime + lastEndTime = startTime + duration + translatedSlides.add(DraftSlide(slideNum, duration, startTime, filename)) + } + } + } + + seekBar.max = translatedSlides.last().startTime + seekBar.progress = 0 + setSlideFromSeekbar() + + invalidateOptionsMenu() + } + + public override fun onPause() { + super.onPause() + pauseStoryAudio() + draftPlayer.release() + } + + public override fun onResume() { + super.onResume() + draftPlayer = AudioPlayer() + draftPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { + Log.e("@pwhite", "curSlide is $currentSlideIndex") + if (draftPlayer.isAudioPrepared) { + if (currentSlideIndex >= translatedSlides.size - 1) { //is it the last slide? + //at the end of video so special case + pauseStoryAudio() + } else { + //just play the next slide! + seekBar.progress = translatedSlides[currentSlideIndex + 1].startTime + playStoryAudio() + } + } + }) + + mSeekBarTimer = Timer() + mSeekBarTimer.schedule(object : TimerTask() { + override fun run() { + runOnUiThread { + if (recordingToolbar.isRecording || recordingToolbar.isAudioPlaying) { + seekBar.progress = minOf((System.currentTimeMillis() - seekbarStartTime).toInt(), seekBar.max) + } else if (draftPlayer.isAudioPrepared) { + seekBar.progress = translatedSlides[currentSlideIndex].startTime + draftPlayer.currentPosition + } else { + seekBar.progress = 0 + } + } + } + }, 0, 33) + + setSlideFromSeekbar() + } + + private fun setSlideFromSeekbar() { + val time = seekBar.progress + Log.e("@pwhite", "setSlideFromSeekbar: progress is ${seekBar.progress}, max is ${seekBar.max}") + var slideIndexBeforeSeekBar = translatedSlides.indexOfLast { it.startTime <= time } + if (slideIndexBeforeSeekBar != currentSlideIndex || !draftPlayer.isAudioPrepared) { + currentSlideIndex = slideIndexBeforeSeekBar + val slide = translatedSlides[currentSlideIndex] + setPic(wholeStoryImageView, slide.slideNum) + draftPlayer.setStorySource(this, slide.filename) + Log.e("@pwhite", "setSlideFromSeekbar: ${slide.filename} ${draftPlayer.isAudioPrepared}") + } else { + Log.e("@pwhite", "setSlideFromSeekbar: skipping setStorySource $slideIndexBeforeSeekBar $currentSlideIndex ${draftPlayer.isAudioPrepared}") + } + } + + override fun onStoppedToolbarMedia() { + seekBar.progress = 0 + setSlideFromSeekbar() + } + + override fun onStartedToolbarMedia() { + pauseStoryAudio() + seekBar.progress = 0 + currentSlideIndex = 0 + //This gets the progress bar to show the right time. + seekbarStartTime = System.currentTimeMillis() + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + val item = menu.getItem(0) + item.setIcon(R.drawable.ic_school_white_48dp) + return true + } + + /** + * Button action for playing/pausing the audio + * @param view button to set listeners for + */ + fun onClickPlayPauseButton(view: View) { + if (draftPlayer.isAudioPlaying) { + pauseStoryAudio() + } else { + if (seekBar.progress >= seekBar.max - 100) { + //reset the video to the beginning because they already finished it (within 100 ms) + seekBar.progress = 0 + } + playStoryAudio() + } + } + + /** + * Plays the audio + */ + internal fun playStoryAudio() { + recordingToolbar.stopToolbarMedia() + setSlideFromSeekbar() + draftPlayer.pauseAudio() + seekbarStartTime = System.currentTimeMillis() + draftPlayer.setVolume(if (isVolumeOn) 1.0f else 0.0f) //set the volume on or off based on the boolean + Log.e("@pwhite:", "playStoryAudio() here 1") + draftPlayer.playAudio() + Log.e("@pwhite:", "playStoryAudio() here 2") + playButton.setImageResource(R.drawable.ic_pause_white_48dp) + } + + /** + * helper function for pausing the video + */ + private fun pauseStoryAudio() { + draftPlayer.pauseAudio() + playButton.setImageResource(R.drawable.ic_play_arrow_white_48dp) + } +} + diff --git a/app/src/main/java/org/sil/storyproducer/model/Phase.kt b/app/src/main/java/org/sil/storyproducer/model/Phase.kt index 2905b118..cfa10ca2 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Phase.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Phase.kt @@ -1,5 +1,6 @@ package org.sil.storyproducer.model +import android.util.Log; import org.sil.storyproducer.R import org.sil.storyproducer.controller.MainActivity import org.sil.storyproducer.controller.RegistrationActivity @@ -134,6 +135,7 @@ class Phase(val phaseType: PhaseType) { } fun getTheClass() : Class<*> { + Log.e("@pwhite", "getTheClass(): the phase type is $phaseType"); return when(phaseType){ PhaseType.WORKSPACE -> RegistrationActivity::class.java PhaseType.REGISTRATION -> RegistrationActivity::class.java @@ -171,6 +173,7 @@ class Phase(val phaseType: PhaseType) { } fun checkValidDisplaySlideNum(slideNum: Int) : Boolean { + // TODO @pwhite: This is a pretty pointless function; would it be possible to remove it? val slideType = Workspace.activeStory.slides[slideNum].slideType return when(phaseType){ PhaseType.DRAMATIZATION -> slideType in arrayOf( diff --git a/app/src/main/java/org/sil/storyproducer/model/Workspace.kt b/app/src/main/java/org/sil/storyproducer/model/Workspace.kt index 6bc1d1c9..69e35424 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Workspace.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Workspace.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.os.Bundle import android.provider.Settings.Secure import android.support.v4.provider.DocumentFile +import android.util.Log; import com.google.firebase.analytics.FirebaseAnalytics import org.sil.storyproducer.R import org.sil.storyproducer.tools.file.deleteWorkspaceFile @@ -55,7 +56,7 @@ object Workspace{ return "${activePhase.getShortName()}${ Workspace.activeSlideNum }" } - var activeSlideNum: Int = -1 + var activeSlideNum: Int = 0 set(value){ field = 0 if(value >= 0 && value < activeStory.slides.size){ @@ -126,10 +127,13 @@ object Workspace{ //sort by title. Stories.sortBy{it.title} //update phases based upon registration selection + Log.e("@pwhite", "updateStories(): updating...phases = ${phases.size}"); + Log.e("@pwhite", "updateStories(): updating...reg = ${registration.getString("consultant_location_type")}"); phases = when(registration.getString("consultant_location_type")) { - "remote" -> Phase.getRemotePhases() + "Remote" -> Phase.getRemotePhases() else -> Phase.getLocalPhases() } + Log.e("@pwhite", "updateStories(): updating...phases = ${phases.size}"); activePhaseIndex = 0 updateStoryLocalCredits(context) storiesUpdated = true @@ -181,6 +185,7 @@ object Workspace{ activePhaseIndex = phases.size - 1 return false } + Log.e("@pwhite", "goToNextPhase(): phases = ${phases.size}"); activePhaseIndex++ activePhase = phases[activePhaseIndex] //there was a successful phase change! diff --git a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt index c043b934..21531fc7 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt @@ -37,7 +37,8 @@ fun getChosenCombName(slideNum: Int = Workspace.activeSlideNum): String { PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].chosenDraftFile PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].chosenDramatizationFile PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].chosenBackTranslationFile - else -> "" + PhaseType.WHOLE_STORY -> Workspace.activeStory.wholeStoryBackTAudioFile + else -> throw Exception("Unsupported stage to get the audio file for") } } diff --git a/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt b/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt index 5ee1b955..91f71c6a 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt @@ -44,7 +44,9 @@ fun copyToWorkspacePath(context: Context, sourceUri: Uri, destRelPath: String){ } fun getStoryImage(context: Context, slideNum: Int = Workspace.activeSlideNum, sampleSize: Int = 1, story: Story = Workspace.activeStory): Bitmap { - if(story.title == "") return genDefaultImage() + if(story.title == "" || slideNum == story.slides.size) { + return genDefaultImage() + } return getStoryImage(context,story.slides[slideNum].imageFile,sampleSize,false,story) } diff --git a/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt b/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt index 70c2c6ce..2d63d699 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt @@ -3,6 +3,7 @@ package org.sil.storyproducer.tools.media import android.content.Context import android.media.MediaPlayer import android.net.Uri +import android.util.Log import org.sil.storyproducer.model.Workspace import org.sil.storyproducer.tools.file.getStoryUri import java.io.IOException @@ -14,13 +15,15 @@ class AudioPlayer { private var onCompletionListenerPersist: MediaPlayer.OnCompletionListener? = null var currentPosition: Int - get() = - try{ mPlayer.currentPosition - } catch (e : Exception){ 0 } + get() = if (isAudioPrepared) { + mPlayer.currentPosition + } else { + 0 + } set(value) { - try { + if (isAudioPrepared) { mPlayer.seekTo(value) - } catch (e : Exception) {} + } } /** @@ -28,21 +31,18 @@ class AudioPlayer { * @return the duration of the audio as an int */ val audioDurationInMilliseconds: Int - get() = mPlayer.duration + get() = if (isAudioPrepared) { + mPlayer.duration + } else { + 0 + } /** * returns if the audio is being played or not * @return true or false based on if the audio is being played */ val isAudioPlaying: Boolean - get() { - try { - return mPlayer.isPlaying - } catch (e: IllegalStateException) { - return false - } - - } + get() = isAudioPrepared && mPlayer.isPlaying var isAudioPrepared: Boolean = false private set @@ -52,34 +52,38 @@ class AudioPlayer { */ init { mPlayer = MediaPlayer() + mPlayer.setOnErrorListener { _, what, extra -> + Log.e("@pwhite", "media player error what = $what, extra = $extra") + false + } fileExists = false } - fun setSource(context: Context, uri: Uri) : Boolean { - try { - mPlayer.release() - mPlayer = MediaPlayer() - mPlayer.setOnCompletionListener(onCompletionListenerPersist) - mPlayer.setDataSource(context, uri) - fileExists = true - isAudioPrepared = true - mPlayer.prepare() - currentPosition = 0 - } catch (e: Exception) { - //TODO maybe do something with this exception - fileExists = false - isAudioPrepared = false + fun setSource(context: Context, uri: Uri): Boolean { + mPlayer.release() + mPlayer = MediaPlayer() + mPlayer.setOnCompletionListener(onCompletionListenerPersist) + Log.e("@pwhite", "setting source and error listener") + mPlayer.setOnErrorListener { _, what, extra -> + Log.e("@pwhite", "media player error what = $what, extra = $extra") + false } + mPlayer.setDataSource(context, uri) + fileExists = true + isAudioPrepared = true + mPlayer.prepare() + currentPosition = 0 return fileExists } + /** * set the audio file from the worskspace data * @return true if the file exists, false if it does not. */ fun setStorySource(context: Context, relPath: String, - storyName: String = Workspace.activeStory.title) : Boolean { - val uri: Uri = getStoryUri(relPath,storyName) ?: return false + storyName: String = Workspace.activeStory.title): Boolean { + val uri: Uri = getStoryUri(relPath, storyName) ?: return false return setSource(context, uri) } @@ -92,21 +96,18 @@ class AudioPlayer { * Pauses the audio if it is currently being played */ fun pauseAudio() { - try { - if(mPlayer.isPlaying) - mPlayer.pause() - } catch (e: Exception) {} + if (mPlayer.isPlaying) { + mPlayer.pause() + } } /** * Resumes the audio from where it was last paused */ fun resumeAudio() { - try { - if(fileExists) { - mPlayer.start() - } - } catch (e: Exception) { } + if (fileExists) { + mPlayer.start() + } } @@ -114,10 +115,8 @@ class AudioPlayer { * Stops the audio if it is currently being played */ fun stopAudio() { - try { - if(mPlayer.isPlaying) mPlayer.pause() - if(currentPosition != 0) currentPosition = 0 - } catch (e: Exception) {} + if (mPlayer.isPlaying) mPlayer.pause() + if (currentPosition != 0) currentPosition = 0 } /** @@ -125,9 +124,7 @@ class AudioPlayer { */ fun release() { isAudioPrepared = false - try { - mPlayer.release() - } catch (e : Exception) {} + mPlayer.release() } /** @@ -135,7 +132,7 @@ class AudioPlayer { * @param msec milliseconds for where to seek to in the audio */ fun seekTo(msec: Int) { - if(!fileExists) return + if (!fileExists) return mPlayer.seekTo(msec) } diff --git a/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt b/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt index e00adfbc..612ecada 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageButton import android.widget.Toast +import android.util.Log import org.sil.storyproducer.R import org.sil.storyproducer.model.PhaseType import org.sil.storyproducer.model.SLIDE_NUM @@ -23,22 +24,23 @@ import org.sil.storyproducer.tools.media.AudioPlayer * This class extends the recording functionality of its base class. A playback button is added to * the UI in addition to the recording button. */ -open class PlayBackRecordingToolbar: RecordingToolbar() { +open class PlayBackRecordingToolbar : RecordingToolbar() { private lateinit var playButton: ImageButton override lateinit var toolbarMediaListener: RecordingToolbar.ToolbarMediaListener private var audioPlayer: AudioPlayer = AudioPlayer() - val isAudioPlaying : Boolean - get() {return audioPlayer.isAudioPlaying} - private var slideNum : Int = 0 + val isAudioPlaying: Boolean + get() { + return audioPlayer.isAudioPlaying + } + private var slideNum: Int = 0 override fun onAttach(context: Context?) { super.onAttach(context) toolbarMediaListener = try { context as ToolbarMediaListener - } - catch (e : ClassCastException){ + } catch (e: ClassCastException) { parentFragment as ToolbarMediaListener } } @@ -62,11 +64,12 @@ open class PlayBackRecordingToolbar: RecordingToolbar() { super.onPause() } - interface ToolbarMediaListener: RecordingToolbar.ToolbarMediaListener{ - fun onStoppedToolbarPlayBack(){ + interface ToolbarMediaListener : RecordingToolbar.ToolbarMediaListener { + fun onStoppedToolbarPlayBack() { onStoppedToolbarMedia() } - fun onStartedToolbarPlayBack(){ + + fun onStartedToolbarPlayBack() { onStartedToolbarMedia() } } @@ -79,7 +82,7 @@ open class PlayBackRecordingToolbar: RecordingToolbar() { } } - private fun stopToolbarAudioPlaying() { + private fun stopToolbarAudioPlaying() { audioPlayer.stopAudio() playButton.setBackgroundResource(R.drawable.ic_play_arrow_white_48dp) @@ -92,7 +95,7 @@ open class PlayBackRecordingToolbar: RecordingToolbar() { playButton = toolbarButton(R.drawable.ic_play_arrow_white_48dp, R.id.play_recording_button) rootView?.addView(playButton) - + rootView?.addView(toolbarButtonSpace()) } @@ -100,12 +103,11 @@ open class PlayBackRecordingToolbar: RecordingToolbar() { * This function sets the visibility of any inherited buttons based on the existence of * a playback file. */ - override fun updateInheritedToolbarButtonVisibility(){ + override fun updateInheritedToolbarButtonVisibility() { val playBackFileExist = storyRelPathExists(activity!!, getChosenFilename(slideNum)) - if(playBackFileExist){ + if (playBackFileExist) { showInheritedToolbarButtons() - } - else{ + } else { hideInheritedToolbarButtons() } } @@ -137,16 +139,18 @@ open class PlayBackRecordingToolbar: RecordingToolbar() { if (!wasPlaying) { (toolbarMediaListener as ToolbarMediaListener).onStartedToolbarPlayBack() + Log.e("@pwhite", "playButtonOnClickListener with filename ${getChosenFilename()}") if (audioPlayer.setStorySource(this.appContext, getChosenFilename())) { audioPlayer.playAudio() playButton.setBackgroundResource(R.drawable.ic_stop_white_48dp) - + //TODO: make this logging more robust and encapsulated - when (Workspace.activePhase.phaseType){ + when (Workspace.activePhase.phaseType) { PhaseType.DRAFT -> saveLog(appContext.getString(R.string.DRAFT_PLAYBACK)) - PhaseType.COMMUNITY_CHECK-> saveLog(appContext.getString(R.string.COMMENT_PLAYBACK)) - else ->{} + PhaseType.COMMUNITY_CHECK -> saveLog(appContext.getString(R.string.COMMENT_PLAYBACK)) + else -> { + } } } else { Toast.makeText(appContext, R.string.recording_toolbar_no_recording, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/res/layout/activity_whole_story.xml b/app/src/main/res/layout/activity_whole_story.xml new file mode 100644 index 00000000..482d089e --- /dev/null +++ b/app/src/main/res/layout/activity_whole_story.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + From f65e1fd68b04a7ba17218c794262842dbd6c3d30 Mon Sep 17 00:00:00 2001 From: Philip White Date: Sun, 3 Nov 2019 20:08:32 +0000 Subject: [PATCH 08/64] Restructure recording data and do formatting I abstracted the displayName-fileName pairs into a Recording type. In addition; collections of files that have a selected file have been abstracted into a RecordingList. There were some files which I formatted while editing them, so this also added a large diff to this commit. --- app/src/main/AndroidManifest.xml | 1 + .../controller/SlidePhaseFrag.kt | 11 +- .../adapter/RecordingsListAdapter.kt | 68 +++---- .../DramatizationRecordingToolbar.kt | 4 +- .../controller/learn/LearnActivity.kt | 191 ++++++++++-------- .../WholeStoryBackTranslationActivity.kt | 136 ++++++++++--- .../org/sil/storyproducer/model/ParseBloom.kt | 4 +- .../storyproducer/model/ParsePhotoStory.kt | 58 +++--- .../java/org/sil/storyproducer/model/Phase.kt | 94 +++++---- .../org/sil/storyproducer/model/Recording.kt | 3 + .../sil/storyproducer/model/RecordingList.kt | 25 +++ .../java/org/sil/storyproducer/model/Slide.kt | 23 +-- .../java/org/sil/storyproducer/model/Story.kt | 70 ++++--- .../org/sil/storyproducer/model/StoryIO.kt | 1 + .../org/sil/storyproducer/model/Workspace.kt | 113 +++++------ .../storyproducer/tools/file/AudioFiles.kt | 139 +++++-------- .../sil/storyproducer/tools/file/FileIO.kt | 2 + .../storyproducer/tools/media/AudioPlayer.kt | 2 +- .../tools/media/story/AutoStoryMaker.kt | 57 +++--- .../tools/media/story/StoryPage.kt | 2 +- .../tools/toolbar/PlayBackRecordingToolbar.kt | 5 +- .../main/res/layout/activity_whole_story.xml | 155 ++++++++------ gradle.properties | 2 +- 23 files changed, 655 insertions(+), 511 deletions(-) create mode 100644 app/src/main/java/org/sil/storyproducer/model/Recording.kt create mode 100644 app/src/main/java/org/sil/storyproducer/model/RecordingList.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b030777d..b27a888f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ android:theme="@style/AppTheme" android:largeHeap="true" android:fullBackupContent="false" + android:usesCleartextTraffic="true" tools:replace="android:icon,android:roundIcon" > diff --git a/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt b/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt index 0c1066e7..d025b131 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/SlidePhaseFrag.kt @@ -12,7 +12,6 @@ import org.sil.storyproducer.R import org.sil.storyproducer.controller.phase.PhaseBaseActivity import org.sil.storyproducer.model.* import org.sil.storyproducer.model.logging.saveLog -import org.sil.storyproducer.tools.file.storyRelPathExists import org.sil.storyproducer.tools.media.AudioPlayer import java.util.* @@ -40,7 +39,7 @@ abstract class SlidePhaseFrag : Fragment() { slideNum = this.arguments!!.getInt(SLIDE_NUM) slide = Workspace.activeStory.slides[slideNum] setHasOptionsMenu(true) - Log.e("@pwhite", "file: ${Workspace.activePhase.getReferenceAudioFile(slideNum)}") + Log.e("@pwhite", "file: ${Workspace.activePhase.getReferenceRecording(slideNum)}") } override fun onCreateView(inflater: LayoutInflater, @@ -65,7 +64,10 @@ abstract class SlidePhaseFrag : Fragment() { super.onResume() referenceAudioPlayer = AudioPlayer() - referenceAudioPlayer.setStorySource(context!!,Workspace.activePhase.getReferenceAudioFile(slideNum)) + val referenceRecording = Workspace.activePhase.getReferenceRecording(slideNum) + if (referenceRecording != null) { + referenceAudioPlayer.setStorySource(context!!, referenceRecording.fileName) + } referenceAudioPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { referencePlayButton!!.setBackgroundResource(R.drawable.ic_play_arrow_white_36dp) @@ -183,7 +185,8 @@ abstract class SlidePhaseFrag : Fragment() { private fun setReferenceAudioButton() { referencePlayButton!!.setOnClickListener { - if (!storyRelPathExists(context!!,Workspace.activePhase.getReferenceAudioFile(slideNum))) { + val referenceRecording = Workspace.activePhase.getReferenceRecording(slideNum) + if (referenceRecording == null) { //TODO make "no audio" string work for all phases Snackbar.make(rootView!!, R.string.draft_playback_no_lwc_audio, Snackbar.LENGTH_SHORT).show() } else { diff --git a/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt b/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt index af9092b4..7473bbfd 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt @@ -19,13 +19,14 @@ import android.widget.Toast import org.sil.storyproducer.R import org.sil.storyproducer.controller.Modal import org.sil.storyproducer.model.PhaseType +import org.sil.storyproducer.model.RecordingList import org.sil.storyproducer.model.Workspace import org.sil.storyproducer.model.logging.saveLog import org.sil.storyproducer.tools.file.* import org.sil.storyproducer.tools.media.AudioPlayer import org.sil.storyproducer.tools.toolbar.RecordingToolbar -class RecordingsListAdapter(val values: MutableList?, private val listeners: ClickListeners) : RecyclerView.Adapter() { +class RecordingsListAdapter(private val recordings: RecordingList, private val listeners: ClickListeners) : RecyclerView.Adapter() { interface ClickListeners { fun onRowClick(pos: Int) @@ -43,22 +44,18 @@ class RecordingsListAdapter(val values: MutableList?, private val listen return ViewHolder(individualAudio) } - override fun getItemCount(): Int = values?.size ?: 0 + override fun getItemCount(): Int = recordings.getFiles().size override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val audioText = values?.get(position) - if (audioText != null) { - if (getChosenDisplayName().contains(audioText)) { - val color = ContextCompat.getColor(holder.itemView.context, R.color.primary) - holder.itemView.setBackgroundColor(color) - selectedPos = holder.adapterPosition - } - else{ - val color = ContextCompat.getColor(holder.itemView.context, R.color.black) - holder.itemView.setBackgroundColor(color) - } - holder.bindView(audioText) + if (recordings.selectedIndex == position) { + val color = ContextCompat.getColor(holder.itemView.context, R.color.primary) + holder.itemView.setBackgroundColor(color) + selectedPos = holder.adapterPosition + } else { + val color = ContextCompat.getColor(holder.itemView.context, R.color.black) + holder.itemView.setBackgroundColor(color) } + holder.bindView(recordings.getFiles()!![position].displayName) } inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { @@ -134,7 +131,7 @@ class RecordingsListAdapter(val values: MutableList?, private val listen class RecordingsListModal(private val context: Context, private val toolbar: RecordingToolbar?) : ClickListeners, Modal { private var rootView: ViewGroup? = null private var dialog: AlertDialog? = null - private var displayNames: MutableList = mutableListOf() + private var displayNames = RecordingList() internal var recyclerView: RecyclerView? = null private val audioPlayer: AudioPlayer = AudioPlayer() private var currentPlayingButton: ImageButton? = null @@ -143,18 +140,17 @@ class RecordingsListAdapter(val values: MutableList?, private val listen private var playbackListener: RecordingToolbar.ToolbarMediaListener? = null private var slideNum: Int = Workspace.activeSlideNum - fun setSlideNum(mSlideNum:Int){ + fun setSlideNum(mSlideNum: Int) { slideNum = mSlideNum } - fun setParentFragment(parentFragment: Fragment){ - try{ + fun setParentFragment(parentFragment: Fragment) { + try { playbackListener = parentFragment as RecordingToolbar.ToolbarMediaListener - } - catch (e : ClassCastException){ + } catch (e: ClassCastException) { playbackListener = context as RecordingToolbar.ToolbarMediaListener + } catch (e: Exception) { } - catch (e:Exception){} } fun embedList(view: ViewGroup) { @@ -201,15 +197,12 @@ class RecordingsListAdapter(val values: MutableList?, private val listen */ fun resetRecordingList() { //only update if there was a change. - val newNames = getRecordedDisplayNames(slideNum) ?: mutableListOf() - if(!displayNames.equals(newNames)) { - displayNames = newNames - recyclerView?.adapter = RecordingsListAdapter(displayNames, this) - } + displayNames = Workspace.activePhase.getRecordings() + recyclerView?.adapter = RecordingsListAdapter(displayNames, this) } override fun onRowClick(pos: Int) { - setChosenFileIndex(pos) + displayNames.selectedIndex = pos } override fun onPlayClick(pos: Int, buttonClickedNow: ImageButton) { @@ -246,26 +239,23 @@ class RecordingsListAdapter(val values: MutableList?, private val listen } } - override fun onDeleteClick(name: String, pos: Int){ - deleteAudioFileFromList(context,pos) - displayNames.removeAt(pos) + override fun onDeleteClick(name: String, pos: Int) { + deleteAudioFileFromList(context, pos) recyclerView?.adapter!!.notifyDataSetChanged() if ("${Workspace.activeDir}/$name" == getChosenDisplayName()) { - if (displayNames.size > 0) { - onRowClick(displayNames.size-1) - } - else { - setChosenFileIndex(-1) + if (displayNames.getFiles().isNotEmpty()) { + onRowClick(displayNames.getFiles().size - 1) + } else { + displayNames.selectedIndex = -1 toolbar?.updateInheritedToolbarButtonVisibility() dialog?.dismiss() } } } - override fun onRenameClick(position: Int, newName: String) { - updateDisplayName(position, newName) - setChosenFileIndex(position) - displayNames[position] = newName + override fun onRenameClick(pos: Int, newName: String) { + updateDisplayName(pos, newName) + displayNames.selectedIndex = pos recyclerView?.adapter!!.notifyDataSetChanged() } diff --git a/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt b/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt index a168042c..9f9efe48 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt @@ -90,7 +90,7 @@ class DramatizationRecordingToolbar: MultiRecordRecordingToolbar() { if (wasRecording) { if (isAppendingOn) { try { - AudioRecorder.concatenateAudioFiles(appContext, getChosenFilename(), audioTempName) + AudioRecorder.concatenateAudioFiles(appContext, getChosenFilename()!!, audioTempName) } catch (e: FileNotFoundException) { Crashlytics.logException(e) } @@ -118,7 +118,7 @@ class DramatizationRecordingToolbar: MultiRecordRecordingToolbar() { if (isAppendingOn && (voiceRecorder?.isRecording == true)) { stopToolbarMedia() try { - AudioRecorder.concatenateAudioFiles(appContext, getChosenFilename(), audioTempName) + AudioRecorder.concatenateAudioFiles(appContext, getChosenFilename()!!, audioTempName) } catch (e: FileNotFoundException) { Crashlytics.logException(e) } diff --git a/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt index fe86bb5f..19360db7 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/learn/LearnActivity.kt @@ -23,27 +23,32 @@ import java.util.* import kotlin.math.min class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMediaListener { - private var learnImageView: ImageView? = null - private var playButton: ImageButton? = null - private var videoSeekBar: SeekBar? = null - private var mSeekBarTimer = Timer() + class DraftSlide(slideNum: Int, duration: Int, startTime: Int, filename: String) { + val slideNum: Int = slideNum + val duration: Int = duration + val startTime: Int = startTime + val filename: String = filename + } + + private lateinit var learnImageView: ImageView + private lateinit var playButton: ImageButton + private lateinit var seekBar: SeekBar + + private var mSeekBarTimer = Timer() private var narrationPlayer: AudioPlayer = AudioPlayer() + private var seekbarStartTime: Long = -1 private var isVolumeOn = true private var isWatchedOnce = false private var recordingToolbar: PlayBackRecordingToolbar = PlayBackRecordingToolbar() - private var numOfSlides: Int = 0 - private var seekbarStartTime: Long = -1 - private var logStartTime: Long = -1 - private var curPos: Int = -1 //set to -1 so that the first slide will register as "different" - private val slideDurations: MutableList = ArrayList() - private val slideStartTimes: MutableList = ArrayList() + private var currentSlideIndex: Int = 0 + private val slides: MutableList = ArrayList() + private var logStartTime: Long = -1 private var isLogging = false - private var startPos = -1 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -58,36 +63,47 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia learnImageView = findViewById(R.id.fragment_image_view) playButton = findViewById(R.id.fragment_reference_audio_button) - - //setup seek bar listenters - videoSeekBar = findViewById(R.id.videoSeekBar) - videoSeekBar!!.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { - override fun onStopTrackingTouch(sBar: SeekBar) {} - override fun onStartTrackingTouch(sBar: SeekBar) {} - override fun onProgressChanged(sBar: SeekBar, progress: Int, fromUser: Boolean) { - if (fromUser) { - if (recordingToolbar.isRecording || recordingToolbar.isAudioPlaying) { - //When recording, update the picture to the accurate location, preserving - seekbarStartTime = System.currentTimeMillis() - videoSeekBar!!.progress - setSlideFromSeekbar() - } else { - if (narrationPlayer.isAudioPlaying) { - pauseStoryAudio() - playStoryAudio() - } else { - setSlideFromSeekbar() - } - //always start at the beginning of the slide. - if (slideStartTimes.size > curPos) - videoSeekBar!!.progress = slideStartTimes[curPos] + seekBar = findViewById(R.id.videoSeekBar) + + seekBar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + var wasPlayingBeforeTouch = false + override fun onStopTrackingTouch(sBar: SeekBar) { + if (wasPlayingBeforeTouch) { + // Always start at the beginning of the slide. + if (currentSlideIndex < slides.size) { + seekBar.progress = slides[currentSlideIndex].startTime } + playStoryAudio() } } + + override fun onStartTrackingTouch(sBar: SeekBar) { + wasPlayingBeforeTouch = narrationPlayer.isAudioPlaying + } + + override fun onProgressChanged(sBar: SeekBar, progress: Int, fromUser: Boolean) { + setSlideFromSeekbar() + //if (fromUser) { + // if (recordingToolbar.isRecording || recordingToolbar.isAudioPlaying) { + // //When recording, update the picture to the accurate location, preserving + // seekbarStartTime = System.currentTimeMillis() - videoSeekBar!!.progress + // setSlideFromSeekbar() + // } else { + // if (narrationPlayer.isAudioPlaying) { + // pauseStoryAudio() + // playStoryAudio() + // } else { + // setSlideFromSeekbar() + // } + // //always start at the beginning of the slide. + // if (slideStartTimes.size > curPos) + // videoSeekBar!!.progress = slideStartTimes[curPos] + // } + //} + } }) - //setup volume switch callbacks val volumeSwitch = findViewById(R.id.volumeSwitch) - //set the volume switch change listener volumeSwitch.isChecked = true volumeSwitch.setOnCheckedChangeListener { _, isChecked -> isVolumeOn = if (isChecked) { @@ -100,23 +116,32 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia } //has learn already been watched? - isWatchedOnce = storyRelPathExists(this,Workspace.activeStory.learnAudioFile) + isWatchedOnce = Workspace.activeStory.learnAudioFile != null //get story audio duration - numOfSlides = 0 - slideStartTimes.add(0) - for (s in story.slides) { - //don't play the copyright slides. - if (s.slideType in arrayOf(SlideType.FRONTCOVER, SlideType.NUMBEREDPAGE)) { - numOfSlides++ - slideDurations.add((MediaHelper.getAudioDuration(this, - getStoryUri(Story.getFilename(s.narrationFile))!!) / 1000).toInt()) - slideStartTimes.add(slideStartTimes.last() + slideDurations.last()) - } else { - break + var lastEndTime = 0 + story.slides.forEachIndexed { slideNum, slide -> + // Don't play the copyright slides. + if (slide.slideType == SlideType.FRONTCOVER || slide.slideType == SlideType.NUMBEREDPAGE) { + val filename = slide.narration?.fileName + if (filename != null) { + val duration = (MediaHelper.getAudioDuration(this, getStoryUri(filename)!!) / 1000).toInt() + val startTime = lastEndTime + lastEndTime = startTime + duration + slides.add(DraftSlide(slideNum, duration, startTime, filename)) + } } } - videoSeekBar?.max = slideStartTimes.last() + + seekBar.max = if (slides.isNotEmpty()) { + val lastSlide = slides.last() + lastSlide.startTime + lastSlide.duration + slides.last().startTime + } else { + 0 + } + seekBar.progress = 0 + setSlideFromSeekbar() invalidateOptionsMenu() } @@ -132,14 +157,13 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia narrationPlayer = AudioPlayer() narrationPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { - if(narrationPlayer.isAudioPrepared){ - if(curPos >= numOfSlides-1){ //is it the last slide? + if (narrationPlayer.isAudioPrepared) { + if (currentSlideIndex >= slides.size - 1) { //is it the last slide? //at the end of video so special case pauseStoryAudio() - showStartPracticeSnackBar() } else { //just play the next slide! - videoSeekBar?.progress = slideStartTimes[curPos+1] + seekBar.progress = slides[currentSlideIndex + 1].startTime playStoryAudio() } } @@ -148,35 +172,31 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia mSeekBarTimer = Timer() mSeekBarTimer.schedule(object : TimerTask() { override fun run() { - runOnUiThread{ - if(recordingToolbar.isRecording || recordingToolbar.isAudioPlaying){ - videoSeekBar?.progress = min((System.currentTimeMillis() - seekbarStartTime).toInt(),videoSeekBar!!.max) - setSlideFromSeekbar() + runOnUiThread { + if (recordingToolbar.isRecording || recordingToolbar.isAudioPlaying) { + seekBar.progress = minOf((System.currentTimeMillis() - seekbarStartTime).toInt(), seekBar.max) } else if (narrationPlayer.isAudioPrepared) { - if(curPos >= 0) videoSeekBar?.progress = slideStartTimes[curPos] + narrationPlayer.currentPosition + seekBar.progress = slides[currentSlideIndex].startTime + narrationPlayer.currentPosition } else { - videoSeekBar?.progress = 0 + seekBar.progress = 0 } } } - },0,33) + }, 0, 33) setSlideFromSeekbar() } private fun setSlideFromSeekbar() { - val time = videoSeekBar!!.progress - var i = 0 - for (d in slideStartTimes) { - if (time < d) { - if(i-1 != curPos){ - curPos = i-1 - setPic(learnImageView!!, curPos) - narrationPlayer.setStorySource(this, Workspace.activeStory.slides[curPos].narrationFile) - } - break + if (slides.isNotEmpty()) { + val time = seekBar.progress + var slideIndexBeforeSeekBar = slides.indexOfLast { it.startTime <= time } + if (slideIndexBeforeSeekBar != currentSlideIndex || !narrationPlayer.isAudioPrepared) { + currentSlideIndex = slideIndexBeforeSeekBar + val slide = slides[currentSlideIndex] + setPic(learnImageView, slide.slideNum) + narrationPlayer.setStorySource(this, slide.filename) } - i++ } } @@ -194,21 +214,22 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia } override fun onStoppedToolbarMedia() { - videoSeekBar!!.progress = 0 + seekBar.progress = 0 setSlideFromSeekbar() } override fun onStartedToolbarMedia() { pauseStoryAudio() - videoSeekBar!!.progress = 0 - curPos = 0 + seekBar.progress = 0 + currentSlideIndex = 0 + //This gets the progress bar to show the right time. seekbarStartTime = System.currentTimeMillis() } private fun markLogStart() { - if(!isLogging) { - startPos = curPos + if (!isLogging) { + //startPos = curPos logStartTime = System.currentTimeMillis() } isLogging = true @@ -216,15 +237,15 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia private fun makeLogIfNecessary(isRecording: Boolean = false) { if (isLogging) { - if (startPos != -1) { +// if (startPos != -1) { val duration: Long = System.currentTimeMillis() - logStartTime - if(duration > 2000){ //you need 2 seconds to listen to anything - saveLearnLog(this, startPos,curPos, duration, isRecording) + if (duration > 2000) { //you need 2 seconds to listen to anything + //saveLearnLog(this, startPos, curPos, duration, isRecording) } - startPos = -1 - } + //startPos = -1 +// } } - isLogging = false + //isLogging = false } /** @@ -235,9 +256,9 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia if (narrationPlayer.isAudioPlaying) { pauseStoryAudio() } else { - if (videoSeekBar!!.progress >= videoSeekBar!!.max-100) { + if (seekBar.progress >= seekBar.max - 100) { //reset the video to the beginning because they already finished it (within 100 ms) - videoSeekBar!!.progress = 0 + seekBar.progress = 0 } playStoryAudio() } @@ -254,7 +275,7 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia seekbarStartTime = System.currentTimeMillis() narrationPlayer.setVolume(if (isVolumeOn) 1.0f else 0.0f) //set the volume on or off based on the boolean narrationPlayer.playAudio() - playButton!!.setImageResource(R.drawable.ic_pause_white_48dp) + playButton.setImageResource(R.drawable.ic_pause_white_48dp) } /** @@ -263,7 +284,7 @@ class LearnActivity : PhaseBaseActivity(), PlayBackRecordingToolbar.ToolbarMedia private fun pauseStoryAudio() { makeLogIfNecessary() narrationPlayer.pauseAudio() - playButton!!.setImageResource(R.drawable.ic_play_arrow_white_48dp) + playButton.setImageResource(R.drawable.ic_play_arrow_white_48dp) } /** diff --git a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt index 125e68ae..4d1eb22d 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt @@ -7,19 +7,25 @@ package org.sil.storyproducer.controller.remote import android.media.MediaPlayer import android.os.Bundle +import android.provider.Settings +import android.support.graphics.drawable.VectorDrawableCompat import android.util.Log import android.view.Menu import android.view.View -import android.widget.ImageButton -import android.widget.ImageView -import android.widget.SeekBar -import android.widget.Switch +import android.widget.* +import com.android.volley.Request +import org.apache.commons.io.IOUtils +import org.sil.storyproducer.BuildConfig import org.sil.storyproducer.R import org.sil.storyproducer.controller.phase.PhaseBaseActivity import org.sil.storyproducer.model.SLIDE_NUM import org.sil.storyproducer.model.SlideType -import org.sil.storyproducer.model.Story +import org.sil.storyproducer.model.UploadState +import org.sil.storyproducer.model.Workspace +import org.sil.storyproducer.tools.Network.BackTranslationUpload.js +import org.sil.storyproducer.tools.Network.VolleySingleton +import org.sil.storyproducer.tools.Network.paramStringRequest import org.sil.storyproducer.tools.file.* import org.sil.storyproducer.tools.media.AudioPlayer import org.sil.storyproducer.tools.media.MediaHelper @@ -47,6 +53,11 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording private lateinit var wholeStoryImageView: ImageView private lateinit var playButton: ImageButton private lateinit var seekBar: SeekBar + private lateinit var uploadButton: ImageButton + + private lateinit var greenCheckmark: VectorDrawableCompat + private lateinit var grayCheckmark: VectorDrawableCompat + private lateinit var yellowCheckmark: VectorDrawableCompat private var mSeekBarTimer = Timer() private var draftPlayer: AudioPlayer = AudioPlayer() @@ -56,12 +67,12 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording private var recordingToolbar: PlayBackRecordingToolbar = PlayBackRecordingToolbar() - private var currentSlideIndex: Int = -1 + private var currentSlideIndex: Int = 0 private val translatedSlides: MutableList = ArrayList() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_learn) + setContentView(R.layout.activity_whole_story) val bundle = Bundle() bundle.putInt(SLIDE_NUM, 0) @@ -72,6 +83,82 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording wholeStoryImageView = findViewById(R.id.fragment_image_view) playButton = findViewById(R.id.fragment_reference_audio_button) seekBar = findViewById(R.id.videoSeekBar) + uploadButton = findViewById(R.id.upload_audio_botton) + + greenCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_green, null)!! + grayCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_gray, null)!! + yellowCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_yellow, null)!! + uploadButton.background = when (Workspace.activeStory.wholeStoryBackTranslationUploadState) { + UploadState.UPLOADED -> greenCheckmark + UploadState.NOT_UPLOADED -> grayCheckmark + UploadState.UPLOADING -> yellowCheckmark + } + + uploadButton.setOnClickListener { + when (Workspace.activeStory.wholeStoryBackTranslationUploadState) { + UploadState.UPLOADED -> Toast.makeText(this, "Selected recording already uploaded", Toast.LENGTH_SHORT).show() + UploadState.NOT_UPLOADED -> { + Workspace.activeStory.wholeStoryBackTranslationUploadState = UploadState.UPLOADING + uploadButton.background = yellowCheckmark + val audioRecording = Workspace.activeStory.wholeStoryBackTAudioFile + if (audioRecording != null) { + + Toast.makeText(this, "Uploading audio", Toast.LENGTH_SHORT).show() + val input = getStoryChildInputStream(this, audioRecording.fileName) + val audioBytes = IOUtils.toByteArray(input) + val byteString = android.util.Base64.encodeToString(audioBytes, android.util.Base64.DEFAULT) + val phoneID = Settings.Secure.getString(applicationContext.contentResolver, Settings.Secure.ANDROID_ID) + val js = HashMap() + js["Key"] = getString(R.string.api_token) + js["PhoneId"] = phoneID + js["TemplateTitle"] = Workspace.activeStory.title + js["SlideNumber"] = Workspace.activeSlideNum.toString() + js["Data"] = byteString + val url = BuildConfig.ROCC_URL_PREFIX + getString(R.string.url_upload_audio) + val req = object : paramStringRequest(Method.POST, url, js, { + Log.i("LOG_VOLLEY_RESP_UPL", it) + Toast.makeText(applicationContext, R.string.audio_Sent, Toast.LENGTH_SHORT).show() + Workspace.activeStory.wholeStoryBackTranslationUploadState = UploadState.UPLOADED + uploadButton.background = greenCheckmark + }, { + Log.e("LOG_VOLLEY_ERR_UPL", it.toString()) + Log.e("LOG_VOLLEY", "HIT ERROR") + Toast.makeText(applicationContext, R.string.audio_Send_Failed, Toast.LENGTH_SHORT).show() + Workspace.activeStory.wholeStoryBackTranslationUploadState = UploadState.NOT_UPLOADED + uploadButton.background = grayCheckmark + }) { + override fun getParams(): Map { + return this.mParams + } + } + VolleySingleton.getInstance(applicationContext).addToRequestQueue(req) + } + + + } + UploadState.UPLOADING -> { + uploadButton.background = yellowCheckmark + Toast.makeText(this, "Upload already in progress", Toast.LENGTH_SHORT).show() + } + } + } + + uploadButton.setOnLongClickListener { + when (Workspace.activeStory.wholeStoryBackTranslationUploadState) { + UploadState.UPLOADING -> { + Workspace.activeStory.wholeStoryBackTranslationUploadState = UploadState.NOT_UPLOADED + Toast.makeText(this, "Cancelling upload", Toast.LENGTH_SHORT).show() + uploadButton.background = grayCheckmark + } + UploadState.UPLOADED -> { + Workspace.activeStory.wholeStoryBackTranslationUploadState = UploadState.NOT_UPLOADED + Toast.makeText(this, "Ignoring previous upload", Toast.LENGTH_SHORT).show() + uploadButton.background = grayCheckmark + } + UploadState.NOT_UPLOADED -> Toast.makeText(this, "There have been no uploads yet", Toast.LENGTH_SHORT).show() + } + true + } seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { var wasPlayingBeforeTouch = false @@ -114,8 +201,8 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording story.slides.forEachIndexed { slideNum, slide -> // Don't play the copyright translatedSlides. if (slide.slideType == SlideType.FRONTCOVER || slide.slideType == SlideType.NUMBEREDPAGE) { - val filename = Story.getFilename(slide.chosenDraftFile) - if (storyRelPathExists(this, filename)) { + val filename = slide.draftRecordings.selectedFile?.fileName + if (filename != null) { val duration = (MediaHelper.getAudioDuration(this, getStoryUri(filename)!!) / 1000).toInt() val startTime = lastEndTime lastEndTime = startTime + duration @@ -124,7 +211,13 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording } } - seekBar.max = translatedSlides.last().startTime + seekBar.max = if (translatedSlides.isNotEmpty()) { + val lastSlide = translatedSlides.last() + lastSlide.startTime + lastSlide.duration + translatedSlides.last().startTime + } else { + 0 + } seekBar.progress = 0 setSlideFromSeekbar() @@ -141,7 +234,6 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording super.onResume() draftPlayer = AudioPlayer() draftPlayer.onPlayBackStop(MediaPlayer.OnCompletionListener { - Log.e("@pwhite", "curSlide is $currentSlideIndex") if (draftPlayer.isAudioPrepared) { if (currentSlideIndex >= translatedSlides.size - 1) { //is it the last slide? //at the end of video so special case @@ -173,17 +265,15 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording } private fun setSlideFromSeekbar() { - val time = seekBar.progress - Log.e("@pwhite", "setSlideFromSeekbar: progress is ${seekBar.progress}, max is ${seekBar.max}") - var slideIndexBeforeSeekBar = translatedSlides.indexOfLast { it.startTime <= time } - if (slideIndexBeforeSeekBar != currentSlideIndex || !draftPlayer.isAudioPrepared) { - currentSlideIndex = slideIndexBeforeSeekBar - val slide = translatedSlides[currentSlideIndex] - setPic(wholeStoryImageView, slide.slideNum) - draftPlayer.setStorySource(this, slide.filename) - Log.e("@pwhite", "setSlideFromSeekbar: ${slide.filename} ${draftPlayer.isAudioPrepared}") - } else { - Log.e("@pwhite", "setSlideFromSeekbar: skipping setStorySource $slideIndexBeforeSeekBar $currentSlideIndex ${draftPlayer.isAudioPrepared}") + if (translatedSlides.isNotEmpty()) { + val time = seekBar.progress + var slideIndexBeforeSeekBar = translatedSlides.indexOfLast { it.startTime <= time } + if (slideIndexBeforeSeekBar != currentSlideIndex || !draftPlayer.isAudioPrepared) { + currentSlideIndex = slideIndexBeforeSeekBar + val slide = translatedSlides[currentSlideIndex] + setPic(wholeStoryImageView, slide.slideNum) + draftPlayer.setStorySource(this, slide.filename) + } } } @@ -208,7 +298,7 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording /** * Button action for playing/pausing the audio - * @param view button to set listeners for + * @param view uploadButton to set listeners for */ fun onClickPlayPauseButton(view: View) { if (draftPlayer.isAudioPlaying) { diff --git a/app/src/main/java/org/sil/storyproducer/model/ParseBloom.kt b/app/src/main/java/org/sil/storyproducer/model/ParseBloom.kt index c58e4c6c..74a201bf 100644 --- a/app/src/main/java/org/sil/storyproducer/model/ParseBloom.kt +++ b/app/src/main/java/org/sil/storyproducer/model/ParseBloom.kt @@ -45,7 +45,7 @@ fun parseBloomHTML(context: Context, storyPath: DocumentFile): Story? { var mNarration = reNarration.matcher(pageTextList[0]) if(mNarration.find()) { slide.slideType = SlideType.FRONTCOVER - slide.narrationFile = "audio/narration${mNarration.group(1)}.mp3" + slide.narration = Recording("audio/narration${mNarration.group(1)}.mp3", "") slide.content = mNarration.group(2) slide.title = slide.content slides.add(slide) @@ -68,7 +68,7 @@ fun parseBloomHTML(context: Context, storyPath: DocumentFile): Story? { //narration mNarration = reNarration.matcher(t) if(mNarration.find()){ - slide.narrationFile = "audio/narration${mNarration.group(1)}.mp3" + slide.narration = Recording("audio/narration${mNarration.group(1)}.mp3", "") slide.content = mNarration.group(2) if(i==1) slide.title = slide.content //first slide title } diff --git a/app/src/main/java/org/sil/storyproducer/model/ParsePhotoStory.kt b/app/src/main/java/org/sil/storyproducer/model/ParsePhotoStory.kt index f0ca0caa..7e29ffa3 100644 --- a/app/src/main/java/org/sil/storyproducer/model/ParsePhotoStory.kt +++ b/app/src/main/java/org/sil/storyproducer/model/ParsePhotoStory.kt @@ -3,23 +3,27 @@ package org.sil.storyproducer.model import android.content.Context import android.graphics.Rect import android.support.v4.provider.DocumentFile +import android.util.Log import android.util.Xml import org.sil.storyproducer.R import org.sil.storyproducer.tools.file.getStoryChildInputStream import org.sil.storyproducer.tools.file.getStoryText +import org.sil.storyproducer.tools.file.storyRelPathExists import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import java.io.IOException import java.util.ArrayList fun parsePhotoStoryXML(context: Context, storyPath: DocumentFile): Story? { + Log.e("@pwhite", "Parsing photo story xml ${storyPath.name}") //See if there is an xml photostory file there - val xmlContents = getStoryChildInputStream(context,"project.xml",storyPath.name!!) ?: return null + val xmlContents = getStoryChildInputStream(context, "project.xml", storyPath.name!!) + ?: return null //The file "project.xml" is there, it is a photostory project. Parse it. val slides: MutableList = ArrayList() val parser = Xml.newPullParser() parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - parser.setInput(xmlContents,null) + parser.setInput(xmlContents, null) parser.nextTag() parser.require(XmlPullParser.START_TAG, null, "MSPhotoStoryProject") parser.next() @@ -33,17 +37,17 @@ fun parsePhotoStoryXML(context: Context, storyPath: DocumentFile): Story? { when (tag) { "VisualUnit" -> { - val slide = parseSlideXML(parser) + val slide = parseSlideXML(context, parser, storyPath) //open up text file that has title, ext. They are called 0.txt, 1.txt, etc. - val textFile = getStoryText(context,slide.textFile,storyPath.name!!) - if(textFile != null){ + val textFile = getStoryText(context, slide.textFile, storyPath.name!!) + if (textFile != null) { val textList = textFile.split("~") if (textList.size > 0) slide.title = textList[0].removePrefix(" ").removeSuffix(" ") - if (textList.size > 1) slide.subtitle= textList[1].removePrefix(" ").removeSuffix(" ") + if (textList.size > 1) slide.subtitle = textList[1].removePrefix(" ").removeSuffix(" ") if (textList.size > 2) slide.reference = textList[2].removePrefix(" ").removeSuffix(" ") if (textList.size > 3) slide.content = textList[3].trim() } - if(firstSlide) { + if (firstSlide) { slide.slideType = SlideType.FRONTCOVER firstSlide = false } @@ -72,7 +76,7 @@ fun parsePhotoStoryXML(context: Context, storyPath: DocumentFile): Story? { slide.content = context.getString(R.string.LS_prompt) slide.musicFile = MUSIC_NONE //add as second last slide - slides.add(slides.size-1,slide) + slides.add(slides.size - 1, slide) //Add the Local credits slide slide = Slide() @@ -80,13 +84,14 @@ fun parsePhotoStoryXML(context: Context, storyPath: DocumentFile): Story? { slide.content = context.getString(R.string.LC_prompt) slide.musicFile = MUSIC_NONE //add as second last slide - slides.add(slides.size-1,slide) + slides.add(slides.size - 1, slide) - return Story(storyPath.name!!,slides) + return Story(storyPath.name!!, slides) } @Throws(XmlPullParserException::class, IOException::class) -private fun parseSlideXML(parser: XmlPullParser): Slide { +private fun parseSlideXML(context: Context, parser: XmlPullParser, storyPath: DocumentFile): Slide { + Log.e("@pwhite", "parsing a slide") val slide = Slide() parser.require(XmlPullParser.START_TAG, null, "VisualUnit") @@ -96,30 +101,27 @@ private fun parseSlideXML(parser: XmlPullParser): Slide { parser.next() continue } - val tag = parser.name - when (tag) { + when (parser.name) { "Narration" -> { - parseNarration(slide, parser) - } - "Image" -> { - parseImage(slide, parser) - - } - else -> { - skipToNextTag(parser) + Log.e("@pwhite", "Found a narration") + val path = parser.getAttributeValue(null, "path") + slide.narration = if (storyRelPathExists(context, path, storyPath.name!!)) { + Recording(path, "narration") + } else { + null + } + Log.e("@pwhite", "Found path in xml file - path = $path, narration = ${slide.narration}") + parser.nextTag() + parser.require(XmlPullParser.END_TAG, null, "Narration") } + "Image" -> parseImage(slide, parser) + else -> skipToNextTag(parser) } parser.next() } return slide } -private fun parseNarration(slide: Slide, parser: XmlPullParser) { - slide.narrationFile = parser.getAttributeValue(null, "path") - parser.nextTag() - parser.require(XmlPullParser.END_TAG, null, "Narration") -} - private fun parseImage(slide: Slide, parser: XmlPullParser) { slide.imageFile = parser.getAttributeValue(null, "path") slide.textFile = slide.imageFile.replace(Regex("""\..+$"""), ".txt") @@ -172,7 +174,7 @@ private fun parseMusicTrack(slide: Slide, parser: XmlPullParser) { slide.volume = normalizedVolume parser.nextTag() - if(parser.name == "SoundTrack"){ + if (parser.name == "SoundTrack") { parser.require(XmlPullParser.START_TAG, null, "SoundTrack") slide.musicFile = parser.getAttributeValue(null, "path") diff --git a/app/src/main/java/org/sil/storyproducer/model/Phase.kt b/app/src/main/java/org/sil/storyproducer/model/Phase.kt index cfa10ca2..e846e617 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Phase.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Phase.kt @@ -32,19 +32,30 @@ enum class PhaseType { */ class Phase(val phaseType: PhaseType) { - - fun getCombNames(slideNum:Int = Workspace.activeSlideNum) : MutableList?{ - return when (phaseType){ - PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].draftAudioFiles - PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].communityCheckAudioFiles - PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].dramatizationAudioFiles - PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].backTranslationAudioFiles - else -> null + fun getRecordings(slideNum: Int = Workspace.activeSlideNum): RecordingList { + return when (phaseType) { + PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].draftRecordings + PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].communityCheckRecordings + PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].dramatizationRecordings + PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].backTranslationRecordings + else -> throw Exception("Unsupported phase to get a recordings list from") } } - fun getIcon(phase: PhaseType = phaseType) : Int { - return when (phase){ + fun getCombNames(slideNum: Int = Workspace.activeSlideNum): List { + val recordings = + when (phaseType) { + PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].draftRecordings + PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].communityCheckRecordings + PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].dramatizationRecordings + PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].backTranslationRecordings + else -> throw Exception("Unsupported phase to get a list of recordings from") + } + return recordings.getFiles() + } + + fun getIcon(phase: PhaseType = phaseType): Int { + return when (phase) { PhaseType.LEARN -> R.drawable.ic_ear_speak PhaseType.DRAFT -> R.drawable.ic_mic_white_48dp PhaseType.CREATE -> R.drawable.ic_video_call_white_48dp @@ -59,19 +70,19 @@ class Phase(val phaseType: PhaseType) { } } - fun getReferenceAudioFile(slideNum: Int = Workspace.activeSlideNum) : String { - val filename = when (phaseType){ - PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].narrationFile - PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].chosenDraftFile - PhaseType.CONSULTANT_CHECK -> Workspace.activeStory.slides[slideNum].chosenDraftFile - PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].chosenDraftFile - PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].chosenDraftFile - else -> "" + fun getReferenceRecording(slideNum: Int = Workspace.activeSlideNum): Recording? { + val slide = Workspace.activeStory.slides[slideNum] + return when (phaseType) { + PhaseType.DRAFT -> slide.narration + PhaseType.COMMUNITY_CHECK -> slide.communityCheckRecordings.selectedFile + PhaseType.CONSULTANT_CHECK -> slide.draftRecordings.selectedFile + PhaseType.DRAMATIZATION -> slide.draftRecordings.selectedFile + PhaseType.BACKT -> slide.draftRecordings.selectedFile + else -> throw Exception("Unsupported stage to get a reference audio file for") } - return Story.getFilename(filename) } - fun getPrettyName() : String { + fun getPrettyName(): String { return when (phaseType) { PhaseType.LEARN -> "Learn" PhaseType.DRAFT -> "Translate" @@ -87,7 +98,7 @@ class Phase(val phaseType: PhaseType) { } } - fun getDisplayName() : String { + fun getDisplayName(): String { return when (phaseType) { PhaseType.DRAFT -> "Translation Draft" PhaseType.COMMUNITY_CHECK -> "Comment" @@ -101,7 +112,7 @@ class Phase(val phaseType: PhaseType) { } } - fun getShortName() : String { + fun getShortName(): String { return when (phaseType) { PhaseType.DRAFT -> "Translate" PhaseType.COMMUNITY_CHECK -> "Community" @@ -114,12 +125,13 @@ class Phase(val phaseType: PhaseType) { else -> phaseType.toString().toLowerCase() } } + /** * get the color for the phase * @return return the color */ - fun getColor() : Int { - return when(phaseType){ + fun getColor(): Int { + return when (phaseType) { PhaseType.LEARN -> R.color.learn_phase PhaseType.DRAFT -> R.color.draft_phase PhaseType.COMMUNITY_CHECK -> R.color.comunity_check_phase @@ -134,9 +146,9 @@ class Phase(val phaseType: PhaseType) { } } - fun getTheClass() : Class<*> { + fun getTheClass(): Class<*> { Log.e("@pwhite", "getTheClass(): the phase type is $phaseType"); - return when(phaseType){ + return when (phaseType) { PhaseType.WORKSPACE -> RegistrationActivity::class.java PhaseType.REGISTRATION -> RegistrationActivity::class.java PhaseType.STORY_LIST -> MainActivity::class.java @@ -153,40 +165,40 @@ class Phase(val phaseType: PhaseType) { } } - fun getPhaseDisplaySlideCount() : Int { + fun getPhaseDisplaySlideCount(): Int { var tempSlideNum = 0 - val validSlideTypes = when(phaseType){ + val validSlideTypes = when (phaseType) { PhaseType.DRAMATIZATION -> arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, - SlideType.LOCALSONG,SlideType.LOCALCREDITS) + SlideType.FRONTCOVER, SlideType.NUMBEREDPAGE, + SlideType.LOCALSONG, SlideType.LOCALCREDITS) else -> arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, + SlideType.FRONTCOVER, SlideType.NUMBEREDPAGE, SlideType.LOCALSONG) } for (s in Workspace.activeStory.slides) - if(s.slideType in validSlideTypes){ + if (s.slideType in validSlideTypes) { tempSlideNum++ - }else{ + } else { break } return tempSlideNum } - fun checkValidDisplaySlideNum(slideNum: Int) : Boolean { + fun checkValidDisplaySlideNum(slideNum: Int): Boolean { // TODO @pwhite: This is a pretty pointless function; would it be possible to remove it? val slideType = Workspace.activeStory.slides[slideNum].slideType - return when(phaseType){ + return when (phaseType) { PhaseType.DRAMATIZATION -> slideType in arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, - SlideType.LOCALSONG,SlideType.LOCALCREDITS) + SlideType.FRONTCOVER, SlideType.NUMBEREDPAGE, + SlideType.LOCALSONG, SlideType.LOCALCREDITS) else -> slideType in arrayOf( - SlideType.FRONTCOVER,SlideType.NUMBEREDPAGE, + SlideType.FRONTCOVER, SlideType.NUMBEREDPAGE, SlideType.LOCALSONG) } } companion object { - fun getLocalPhases() : List { + fun getLocalPhases(): List { return listOf( Phase(PhaseType.LEARN), Phase(PhaseType.DRAFT), @@ -197,7 +209,7 @@ class Phase(val phaseType: PhaseType) { Phase(PhaseType.SHARE)) } - fun getRemotePhases() : List { + fun getRemotePhases(): List { return listOf( Phase(PhaseType.LEARN), Phase(PhaseType.DRAFT), @@ -210,7 +222,7 @@ class Phase(val phaseType: PhaseType) { Phase(PhaseType.SHARE)) } - fun getHelpName(phase: PhaseType) : String { + fun getHelpName(phase: PhaseType): String { return "${phase.name.toLowerCase()}.html" } } diff --git a/app/src/main/java/org/sil/storyproducer/model/Recording.kt b/app/src/main/java/org/sil/storyproducer/model/Recording.kt new file mode 100644 index 00000000..cde8cecc --- /dev/null +++ b/app/src/main/java/org/sil/storyproducer/model/Recording.kt @@ -0,0 +1,3 @@ +package org.sil.storyproducer.model + +class Recording(val fileName: String, var displayName: String) \ No newline at end of file diff --git a/app/src/main/java/org/sil/storyproducer/model/RecordingList.kt b/app/src/main/java/org/sil/storyproducer/model/RecordingList.kt new file mode 100644 index 00000000..7b220268 --- /dev/null +++ b/app/src/main/java/org/sil/storyproducer/model/RecordingList.kt @@ -0,0 +1,25 @@ +package org.sil.storyproducer.model + +class RecordingList { + val size: Int + get() = files.size + + val selectedFile: Recording? + get() = files.getOrNull(selectedIndex) ?: files.getOrNull(0) + + private var files: MutableList = ArrayList() + var selectedIndex: Int = 0 + + fun add(recording: Recording) { + files.add(recording) + } + + fun removeAt(index: Int) { + files.removeAt(index) + } + + fun getFiles() : List { + return files + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/sil/storyproducer/model/Slide.kt b/app/src/main/java/org/sil/storyproducer/model/Slide.kt index f294ad58..19ed41d5 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Slide.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Slide.kt @@ -6,19 +6,15 @@ import android.text.Layout import com.squareup.moshi.FromJson import com.squareup.moshi.JsonClass import com.squareup.moshi.ToJson +import org.sil.storyproducer.tools.file.storyRelPathExists import org.sil.storyproducer.tools.media.graphics.TextOverlay import java.text.SimpleDateFormat import java.util.* - -/** - * This class contains metadata pertinent to a given slide from a story template. - */ -@JsonClass(generateAdapter = true) class Slide{ // template information var slideType: SlideType = SlideType.NUMBEREDPAGE - var narrationFile = "" + var narration: Recording? = null var title = "" var subtitle = "" var reference = "" @@ -54,14 +50,11 @@ class Slide{ var translatedContent: String = "" //recorded audio files - var draftAudioFiles: MutableList = ArrayList() - var chosenDraftFile = "" - var communityCheckAudioFiles: MutableList = ArrayList() - var consultantCheckAudioFiles: MutableList = ArrayList() - var dramatizationAudioFiles: MutableList = ArrayList() - var chosenDramatizationFile = "" - var backTranslationAudioFiles: MutableList = ArrayList() - var chosenBackTranslationFile = "" + var draftRecordings = RecordingList() + var communityCheckRecordings = RecordingList() + var consultantCheckRecordings = RecordingList() + var dramatizationRecordings = RecordingList() + var backTranslationRecordings = RecordingList() //consultant approval var isChecked: Boolean = false @@ -124,4 +117,4 @@ class UriAdapter { @ToJson fun toJson(uri: Uri): String { return uri.toString() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/sil/storyproducer/model/Story.kt b/app/src/main/java/org/sil/storyproducer/model/Story.kt index 2ad9f813..928c009f 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Story.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Story.kt @@ -12,55 +12,75 @@ internal val RE_TITLE_NUMBER = "([0-9]+[A-Za-z]?)?[_ -]*(.+)".toRegex() internal val RE_DISPLAY_NAME = "([^|]+)[|.]".toRegex() internal val RE_FILENAME = "([^|]+[|])?(.*)".toRegex() +enum class UploadState { + UPLOADING, + NOT_UPLOADED, + UPLOADED +} + @JsonClass(generateAdapter = true) -class Story(var title: String, val slides: List){ +class Story(var title: String, val slides: List) { var isApproved: Boolean = false + var wholeStoryBackTranslationUploadState = UploadState.NOT_UPLOADED - var learnAudioFile = "" - var wholeStoryBackTAudioFile = "" + var learnAudioFile: Recording? = null + var wholeStoryBackTAudioFile: Recording? = null var activityLogs: MutableList = ArrayList() var outputVideos: MutableList = ArrayList() var lastPhaseType: PhaseType = PhaseType.LEARN var lastSlideNum: Int = 0 - val shortTitle: String get() { - val match = RE_TITLE_NUMBER.find(title) - return if(match != null){ - match.groupValues[2] - } else { - title + val shortTitle: String + get() { + val match = RE_TITLE_NUMBER.find(title) + return if (match != null) { + match.groupValues[2] + } else { + title + } } - } - val titleNumber: String get() { - val match = RE_TITLE_NUMBER.find(title) - return if(match != null){ - match.groupValues[1] - } else { - "" + val titleNumber: String + get() { + val match = RE_TITLE_NUMBER.find(title) + return if (match != null) { + match.groupValues[1] + } else { + "" + } } - } - fun addVideo(video: String){ - if(!(video in outputVideos)){ + fun addVideo(video: String) { + if (!(video in outputVideos)) { outputVideos.add(video) outputVideos.sort() } } - companion object{ - fun getDisplayName(combName:String): String { + companion object { + fun getDisplayName(combName: String): String { val match = RE_DISPLAY_NAME.find(combName) - return if(match != null){ match.groupValues[1] } else {""} + return if (match != null) { + match.groupValues[1] + } else { + "" + } } - fun getFilename(combName:String): String { + + fun getFilename(combName: String): String { val match = RE_FILENAME.find(combName) - return if(match != null){ match.groupValues[2] } else {""} + return if (match != null) { + match.groupValues[2] + } else { + "" + } } } } -fun emptyStory() : Story {return Story("",ArrayList())} +fun emptyStory(): Story { + return Story("", ArrayList()) +} diff --git a/app/src/main/java/org/sil/storyproducer/model/StoryIO.kt b/app/src/main/java/org/sil/storyproducer/model/StoryIO.kt index 87496e9c..0ac75881 100644 --- a/app/src/main/java/org/sil/storyproducer/model/StoryIO.kt +++ b/app/src/main/java/org/sil/storyproducer/model/StoryIO.kt @@ -2,6 +2,7 @@ package org.sil.storyproducer.model import android.content.Context import android.support.v4.provider.DocumentFile +import android.util.Log import com.crashlytics.android.Crashlytics import com.squareup.moshi.Moshi import org.sil.storyproducer.tools.file.getStoryChildOutputStream diff --git a/app/src/main/java/org/sil/storyproducer/model/Workspace.kt b/app/src/main/java/org/sil/storyproducer/model/Workspace.kt index 69e35424..4797f0d6 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Workspace.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Workspace.kt @@ -16,7 +16,7 @@ import kotlin.math.max internal const val SLIDE_NUM = "CurrentSlideNum" -object Workspace{ +object Workspace { var workspace: DocumentFile = DocumentFile.fromFile(File("")) set(value) { field = value @@ -33,42 +33,44 @@ object Workspace{ var prefs: SharedPreferences? = null var activeStory: Story = emptyStory() - set(value){ - field = value - //You are switching the active story. Recall the last phase and slide. - activePhase = Phase(value.lastPhaseType) - activeSlideNum = value.lastSlideNum - } + set(value) { + field = value + //You are switching the active story. Recall the last phase and slide. + activePhase = Phase(value.lastPhaseType) + activeSlideNum = value.lastSlideNum + } var activePhase: Phase = Phase(PhaseType.LEARN) - set(value){ + set(value) { field = value activePhaseIndex = -1 - for((i,p) in phases.withIndex()){ - if(p.phaseType == value.phaseType) activePhaseIndex = i + for ((i, p) in phases.withIndex()) { + if (p.phaseType == value.phaseType) activePhaseIndex = i } } val activeDirRoot: String - get(){return activeStory.title } + get() { + return activeStory.title + } val activeDir: String = PROJECT_DIR val activeFilenameRoot: String - get() { - return "${activePhase.getShortName()}${ Workspace.activeSlideNum }" - } + get() { + return "${activePhase.getShortName()}${Workspace.activeSlideNum}" + } var activeSlideNum: Int = 0 - set(value){ - field = 0 - if(value >= 0 && value < activeStory.slides.size){ - if(activePhase.checkValidDisplaySlideNum(value)) - field = value + set(value) { + field = 0 + if (value >= 0 && value < activeStory.slides.size) { + if (activePhase.checkValidDisplaySlideNum(value)) + field = value + } } - } val activeSlide: Slide? - get(){ - if(activeStory.title == "") return null - return activeStory.slides[activeSlideNum] - } + get() { + if (activeStory.title == "") return null + return activeStory.slides[activeSlideNum] + } private lateinit var firebaseAnalytics: FirebaseAnalytics @@ -77,12 +79,12 @@ object Workspace{ fun initializeWorskpace(context: Context) { //first, see if there is already a workspace in shared preferences prefs = context.getSharedPreferences(WORKSPACE_KEY, Context.MODE_PRIVATE) - setupWorkspacePath(context,Uri.parse(prefs!!.getString("workspace",""))) + setupWorkspacePath(context, Uri.parse(prefs!!.getString("workspace", ""))) isInitialized = true firebaseAnalytics = FirebaseAnalytics.getInstance(context) } - fun logEvent(context: Context, eventName: String, params: Bundle = Bundle()){ + fun logEvent(context: Context, eventName: String, params: Bundle = Bundle()) { params.putString("phone_id", Secure.getString(context.contentResolver, Secure.ANDROID_ID)) params.putString("story_number", activeStory.titleNumber) @@ -94,15 +96,16 @@ object Workspace{ firebaseAnalytics.logEvent(eventName, params) } - fun setupWorkspacePath(context: Context, uri: Uri){ + fun setupWorkspacePath(context: Context, uri: Uri) { try { workspace = DocumentFile.fromTreeUri(context, uri)!! registration.load(context) - } catch ( e : Exception) {} + } catch (e: Exception) { + } updateStories(context) } - fun clearWorkspace(){ + fun clearWorkspace() { workspace = DocumentFile.fromFile(File("")) } @@ -110,14 +113,14 @@ object Workspace{ private fun updateStories(context: Context) { //Iterate external files directories. //for all files in the workspace, see if they are folders that have templates. - if(storiesUpdated) return - if(workspace.isDirectory){ + if (storiesUpdated) return + if (workspace.isDirectory) { //find all stories Stories.removeAll(Stories) for (storyPath in workspace.listFiles()) { //TODO - check storyPath.name against titles. if (storyPath.isDirectory) { - val story = parseStoryIfPresent(context,storyPath) + val story = parseStoryIfPresent(context, storyPath) if (story != null) { Stories.add(story) } @@ -125,11 +128,11 @@ object Workspace{ } } //sort by title. - Stories.sortBy{it.title} + Stories.sortBy { it.title } //update phases based upon registration selection Log.e("@pwhite", "updateStories(): updating...phases = ${phases.size}"); Log.e("@pwhite", "updateStories(): updating...reg = ${registration.getString("consultant_location_type")}"); - phases = when(registration.getString("consultant_location_type")) { + phases = when (registration.getString("consultant_location_type")) { "Remote" -> Phase.getRemotePhases() else -> Phase.getLocalPhases() } @@ -139,16 +142,16 @@ object Workspace{ storiesUpdated = true } - fun deleteVideo(context: Context, path: String){ + fun deleteVideo(context: Context, path: String) { activeStory.outputVideos.remove(path) deleteWorkspaceFile(context, "$VIDEO_DIR/$path") } fun updateStoryLocalCredits(context: Context) { - for(story in Stories){ - for(slide in story.slides){ - if(slide.slideType == SlideType.LOCALCREDITS) { //local credits - if(slide.translatedContent == ""){ + for (story in Stories) { + for (slide in story.slides) { + if (slide.slideType == SlideType.LOCALCREDITS) { //local credits + if (slide.translatedContent == "") { slide.translatedContent = context.getString(R.string.LC_starting_text) } } @@ -156,12 +159,12 @@ object Workspace{ } } - fun isLocalCreditsChanged(context: Context) : Boolean { + fun isLocalCreditsChanged(context: Context): Boolean { var isChanged = false val orgLCText = context.getString(R.string.LC_starting_text) - for(slide in activeStory.slides){ - if(slide.slideType == SlideType.LOCALCREDITS) { //local credits - if(slide.translatedContent != orgLCText){ + for (slide in activeStory.slides) { + if (slide.slideType == SlideType.LOCALCREDITS) { //local credits + if (slide.translatedContent != orgLCText) { isChanged = true } } @@ -169,19 +172,15 @@ object Workspace{ return isChanged } - fun getSongFilename() : String{ - for (s in activeStory.slides){ - if(s.slideType == SlideType.LOCALSONG){ - if(s.chosenDramatizationFile != "") return s.chosenDramatizationFile - if(s.chosenDraftFile != "") return s.chosenDraftFile - } - } - return "" + fun getSongFilename(): String? { + val songSlide = activeStory.slides.firstOrNull { it.slideType == SlideType.LOCALSONG } + return (songSlide?.dramatizationRecordings?.selectedFile + ?: songSlide?.draftRecordings?.selectedFile)?.fileName } - fun goToNextPhase() : Boolean { - if(activePhaseIndex == -1) return false //phases not initizialized - if(activePhaseIndex >= phases.size - 1) { + fun goToNextPhase(): Boolean { + if (activePhaseIndex == -1) return false //phases not initizialized + if (activePhaseIndex >= phases.size - 1) { activePhaseIndex = phases.size - 1 return false } @@ -192,9 +191,9 @@ object Workspace{ return true } - fun goToPreviousPhase() : Boolean { - if(activePhaseIndex == -1) return false //phases not initizialized - if(activePhaseIndex <= 0) { + fun goToPreviousPhase(): Boolean { + if (activePhaseIndex == -1) return false //phases not initizialized + if (activePhaseIndex <= 0) { activePhaseIndex = 0 return false } diff --git a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt index 21531fc7..557227e2 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt @@ -3,10 +3,8 @@ package org.sil.storyproducer.tools.file import android.content.Context import com.crashlytics.android.Crashlytics +import org.sil.storyproducer.model.* import org.sil.storyproducer.model.PROJECT_DIR -import org.sil.storyproducer.model.PhaseType -import org.sil.storyproducer.model.Story -import org.sil.storyproducer.model.Workspace import java.util.* import kotlin.math.max @@ -23,98 +21,65 @@ internal const val AUDIO_EXT = ".m4a" * @return the path generated, or an empty string if there is a failure. */ -fun getChosenFilename(slideNum: Int = Workspace.activeSlideNum): String { - return Story.getFilename(getChosenCombName(slideNum)) +fun getChosenFilename(slideNum: Int = Workspace.activeSlideNum): String? { + return getChosenRecording(slideNum)?.fileName } -fun getChosenDisplayName(slideNum: Int = Workspace.activeSlideNum): String { - return Story.getDisplayName(getChosenCombName(slideNum)) +fun getChosenDisplayName(slideNum: Int = Workspace.activeSlideNum): String? { + return getChosenRecording(slideNum)?.displayName } -fun getChosenCombName(slideNum: Int = Workspace.activeSlideNum): String { +fun getChosenRecording(slideNum: Int = Workspace.activeSlideNum): Recording? { return when (Workspace.activePhase.phaseType) { PhaseType.LEARN -> Workspace.activeStory.learnAudioFile - PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].chosenDraftFile - PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].chosenDramatizationFile - PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].chosenBackTranslationFile + PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].draftRecordings.selectedFile + PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].dramatizationRecordings.selectedFile + PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].backTranslationRecordings.selectedFile + PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].backTranslationRecordings.selectedFile PhaseType.WHOLE_STORY -> Workspace.activeStory.wholeStoryBackTAudioFile else -> throw Exception("Unsupported stage to get the audio file for") } } -/** - * Setting to -1 clears the chosen file. - */ -fun setChosenFileIndex(index: Int, slideNum: Int = Workspace.activeSlideNum){ - val nameSize = Workspace.activePhase.getCombNames(slideNum)?.size ?: -1 - val combName = if(index < 0 || index >= nameSize) "" else Workspace.activePhase.getCombNames(slideNum)!![index] - - when(Workspace.activePhase.phaseType){ - PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].chosenDraftFile = combName - PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].chosenDramatizationFile = combName - PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].chosenBackTranslationFile = combName - else -> return - } - return +fun getRecordedDisplayNames(slideNum: Int = Workspace.activeSlideNum): List { + return Workspace.activePhase.getCombNames(slideNum).map { it.displayName } } -fun getRecordedDisplayNames(slideNum:Int = Workspace.activeSlideNum) : MutableList? { - val filenames : MutableList = arrayListOf() - val combNames = Workspace.activePhase.getCombNames(slideNum) ?: return filenames - for (n in combNames){filenames.add(Story.getDisplayName(n))} - return filenames +fun getRecordedAudioFiles(slideNum: Int = Workspace.activeSlideNum): List { + return Workspace.activePhase.getCombNames(slideNum).map { it.fileName } } -fun getRecordedAudioFiles(slideNum:Int = Workspace.activeSlideNum) : MutableList { - val filenames : MutableList = arrayListOf() - val combNames = Workspace.activePhase.getCombNames(slideNum) ?: return filenames - for (n in combNames){filenames.add(Story.getFilename(n))} - return filenames +fun assignNewAudioRelPath(): String { + val recording = createRecordingCombinedName() + addCombinedName(recording) + return recording.fileName } -fun assignNewAudioRelPath() : String { - val combName = createRecordingCombinedName() - addCombinedName(combName) - return Story.getFilename(combName) -} - -fun updateDisplayName(position:Int, newName:String) { +fun updateDisplayName(position: Int, newName: String) { //make sure to update the actual list, not a copy. - val filenames = Workspace.activePhase.getCombNames() ?: return - filenames[position] = "$newName|${Story.getFilename(filenames[position])}" + val recordings = Workspace.activePhase.getRecordings().getFiles() + recordings[position].displayName = newName } fun deleteAudioFileFromList(context: Context, pos: Int) { //make sure to update the actual list, not a copy. - val filenames = Workspace.activePhase.getCombNames() ?: return - val filename = Story.getFilename(filenames[pos]) - if(getChosenCombName() == filenames[pos]){ - //the chosen filename was deleted! Make it some other selection. - filenames.removeAt(pos) - if(filenames.size == 0) { - //If there is only 1 left, the resulting index will be -1, or no chosen filename. - setChosenFileIndex(-1) - }else{ - setChosenFileIndex(0) - } - }else{ - //just delete the file index. - filenames.removeAt(pos) - } - deleteStoryFile(context,filename) + val filenames = Workspace.activePhase.getRecordings() + val filename = filenames.getFiles()[pos].fileName + filenames.removeAt(pos) + deleteStoryFile(context, filename) } -fun createRecordingCombinedName() : String { +fun createRecordingCombinedName(): Recording { //Example: project/communityCheck_3_2018-03-17T11:14;31.542.md4 //This is the file name generator for all audio files for the app. //the extension is added in the "when" statement because wav files are easier to concatenate, so //they are used for the stages that do that. - return when(Workspace.activePhase.phaseType) { + return when (Workspace.activePhase.phaseType) { //just one file. Overwrite when you re-record. - PhaseType.LEARN, PhaseType.WHOLE_STORY -> { - "${Workspace.activePhase.getDisplayName()}|$PROJECT_DIR/${Workspace.activePhase.getShortName()}$AUDIO_EXT" - } + PhaseType.LEARN, PhaseType.WHOLE_STORY -> Recording( + "$PROJECT_DIR/${Workspace.activePhase.getShortName()}$AUDIO_EXT", + Workspace.activePhase.getDisplayName()) //Make new files every time. Don't append. PhaseType.DRAFT, PhaseType.COMMUNITY_CHECK, PhaseType.DRAMATIZATION, PhaseType.CONSULTANT_CHECK -> { @@ -122,46 +87,38 @@ fun createRecordingCombinedName() : String { val names = getRecordedDisplayNames() val rNameNum = "${Workspace.activePhase.getDisplayName()} ([0-9]+)".toRegex() var maxNum = 0 - for (n in names!!){ + for (n in names) { try { val num = rNameNum.find(n) if (num != null) maxNum = max(maxNum, num.groupValues[1].toInt()) - }catch(e: Exception){ + } catch (e: Exception) { //If there is a crash (such as a bad int parse) just keep going. Crashlytics.logException(e) } } - "${Workspace.activePhase.getDisplayName()} ${maxNum+1}|${Workspace.activeDir}/${Workspace.activeFilenameRoot}_${Date().time}$AUDIO_EXT" + val displayName = "${Workspace.activePhase.getDisplayName()} ${maxNum + 1}" + val fileName = "${Workspace.activeDir}/${Workspace.activeFilenameRoot}_${Date().time}$AUDIO_EXT" + Recording(fileName, displayName) } - else -> {""} + else -> throw Exception("Unsupported phase to create recordings for") } } -fun addCombinedName(name:String){ +fun addCombinedName(recording: Recording) { //register it in the story data structure. - when(Workspace.activePhase.phaseType){ - //just one file. - PhaseType.LEARN -> {Workspace.activeStory.learnAudioFile = name} - PhaseType.WHOLE_STORY -> {Workspace.activeStory.wholeStoryBackTAudioFile = name} - //multiple files, no distinction. - //Add to beginning of list - PhaseType.COMMUNITY_CHECK -> { - Workspace.activeSlide!!.communityCheckAudioFiles.add(0,name) - } - PhaseType.CONSULTANT_CHECK -> {Workspace.activeSlide!!.consultantCheckAudioFiles.add(0,name)} - //multiple files, one chosen. - PhaseType.DRAFT ->{ - Workspace.activeSlide!!.draftAudioFiles.add(0,name) - Workspace.activeSlide!!.chosenDraftFile = name - } - PhaseType.DRAMATIZATION -> { - Workspace.activeSlide!!.dramatizationAudioFiles.add(0,name) - Workspace.activeSlide!!.chosenDramatizationFile = name - } - else -> {} + when (Workspace.activePhase.phaseType) { + PhaseType.LEARN -> Workspace.activeStory.learnAudioFile = recording + PhaseType.WHOLE_STORY -> Workspace.activeStory.wholeStoryBackTAudioFile = recording + PhaseType.COMMUNITY_CHECK -> Workspace.activeSlide!!.communityCheckRecordings.add(recording) + PhaseType.CONSULTANT_CHECK -> Workspace.activeSlide!!.consultantCheckRecordings.add(recording) + PhaseType.DRAFT -> Workspace.activeSlide!!.draftRecordings.add(recording) + PhaseType.DRAMATIZATION -> Workspace.activeSlide!!.dramatizationRecordings.add(recording) + else -> throw Exception("Unsupported phase to add an audio file to") } } -fun getTempAppendAudioRelPath():String {return "$PROJECT_DIR/temp$AUDIO_EXT"} +fun getTempAppendAudioRelPath(): String { + return "$PROJECT_DIR/temp$AUDIO_EXT" +} diff --git a/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt b/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt index 91f71c6a..00d0536c 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/file/FileIO.kt @@ -85,6 +85,7 @@ fun getStoryChildOutputStream(context: Context, relPath: String, mimeType: Strin fun storyRelPathExists(context: Context, relPath: String, dirRoot: String = Workspace.activeDirRoot) : Boolean{ if(relPath == "") return false val uri = getStoryUri(relPath,dirRoot) ?: return false + Log.e("@pwhite","checking path $uri") context.contentResolver.getType(uri) ?: return false return true } @@ -97,6 +98,7 @@ fun workspaceRelPathExists(context: Context, relPath: String) : Boolean{ } fun getStoryUri(relPath: String, dirRoot: String = Workspace.activeDirRoot) : Uri? { + Log.e("@pwhite", "getting story uri. $relPath, $dirRoot") if (dirRoot == "") return null return Uri.parse(Workspace.workspace.uri.toString() + Uri.encode("/$dirRoot/$relPath")) diff --git a/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt b/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt index 2d63d699..e36b444b 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/media/AudioPlayer.kt @@ -63,7 +63,7 @@ class AudioPlayer { mPlayer.release() mPlayer = MediaPlayer() mPlayer.setOnCompletionListener(onCompletionListenerPersist) - Log.e("@pwhite", "setting source and error listener") + Log.e("@pwhite", "setting source and error listener...uri = $uri") mPlayer.setOnErrorListener { _, what, extra -> Log.e("@pwhite", "media player error what = $what, extra = $extra") false diff --git a/app/src/main/java/org/sil/storyproducer/tools/media/story/AutoStoryMaker.kt b/app/src/main/java/org/sil/storyproducer/tools/media/story/AutoStoryMaker.kt index 31ab5004..c577f00c 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/media/story/AutoStoryMaker.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/media/story/AutoStoryMaker.kt @@ -23,7 +23,6 @@ import com.crashlytics.android.Crashlytics import org.sil.storyproducer.controller.MainActivity - /** * AutoStoryMaker is a layer of abstraction above [StoryMaker] that handles all of the * parameters for StoryMaker according to some defaults, structure of projects/templates, and @@ -32,11 +31,14 @@ import org.sil.storyproducer.controller.MainActivity class AutoStoryMaker(private val context: Context) : Thread(), Closeable { var videoRelPath: String = Workspace.activeStory.title.replace(' ', '_') + VIDEO_MP4_EXT - val video3gpPath: String get(){return File(videoRelPath).nameWithoutExtension + VIDEO_3GP_EXT} + val video3gpPath: String + get() { + return File(videoRelPath).nameWithoutExtension + VIDEO_3GP_EXT + } // bits per second for video - private var videoTempFile: File = File(context.filesDir,"temp$VIDEO_MP4_EXT") - private var video3gpFile: File = File(context.filesDir,"temp$VIDEO_3GP_EXT") + private var videoTempFile: File = File(context.filesDir, "temp$VIDEO_MP4_EXT") + private var video3gpFile: File = File(context.filesDir, "temp$VIDEO_3GP_EXT") var mIncludeBackgroundMusic = true var mIncludePictures = true @@ -64,9 +66,9 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { if (!mStoryMaker!!.isDone) { //Still making main video return mStoryMaker!!.progress / 2 - }else { + } else { //making 3gp video - return 0.5 + time3GPms*1000.0/mStoryMaker!!.storyDuration / 2 + return 0.5 + time3GPms * 1000.0 / mStoryMaker!!.storyDuration / 2 } } } @@ -74,7 +76,11 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { override fun start() { val outputFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4 - val videoFormat = if(mIncludePictures) {generateVideoFormat()} else {null} + val videoFormat = if (mIncludePictures) { + generateVideoFormat() + } else { + null + } val audioFormat = generateAudioFormat() val pages = generatePages() ?: return @@ -99,15 +105,15 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { if (isSuccess) { Log.v(TAG, "Moving completed video to " + videoRelPath) - copyToWorkspacePath(context,Uri.fromFile(videoTempFile),"$VIDEO_DIR/$videoRelPath") + copyToWorkspacePath(context, Uri.fromFile(videoTempFile), "$VIDEO_DIR/$videoRelPath") Workspace.activeStory.addVideo(videoRelPath) val params = Bundle() params.putString("video_name", videoRelPath) - Workspace.logEvent(context,"video_creation",params) + Workspace.logEvent(context, "video_creation", params) //Make 3gp video before you delete the temp video - it's made from that. - if(mIncludePictures) make3GPVideo() + if (mIncludePictures) make3GPVideo() videoTempFile.delete() @@ -122,7 +128,7 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { Log.v(TAG, "Creating 3gp video" + video3gpPath) video3gpFile.delete() //just in case it's still there. - try{ + try { Config.resetStatistics() Config.enableStatisticsCallback { newStatistics -> time3GPms = newStatistics.time } @@ -130,10 +136,12 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { "-f 3gp -vcodec $VIDEO_3GP_CODEC -framerate $VIDEO_3GP_FRAMERATE -vf " + "scale=${VIDEO_3GP_WIDTH}x$VIDEO_3GP_HEIGHT -acodec $VIDEO_3GP_AUDIO" + " -b:v $VIDEO_3GP_BITRATE " + video3gpFile.absolutePath) - Log.w(TAG,FFmpeg.getLastCommandOutput() ?: "No FFMPEG output") - copyToWorkspacePath(context,Uri.fromFile(video3gpFile),"$VIDEO_DIR/$video3gpPath") + Log.w(TAG, FFmpeg.getLastCommandOutput() ?: "No FFMPEG output") + copyToWorkspacePath(context, Uri.fromFile(video3gpFile), "$VIDEO_DIR/$video3gpPath") Workspace.activeStory.addVideo(video3gpPath) - }catch(e:Exception){Crashlytics.logException(e)} + } catch (e: Exception) { + Crashlytics.logException(e) + } video3gpFile.delete() } @@ -149,18 +157,12 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { val slide = Workspace.activeStory.slides[iSlide++] //Check if the song slide should be included - if(slide.slideType == SlideType.LOCALSONG && !mIncludeSong) continue + if (slide.slideType == SlideType.LOCALSONG && !mIncludeSong) continue val image = if (mIncludePictures) slide.imageFile else "" - var audio = Story.getFilename(slide.chosenDramatizationFile) - //fallback to draft audio - if (audio == "") { - audio = Story.getFilename(slide.chosenDraftFile) - } - //fallback to LWC audio - if (audio == "") { - audio = Story.getFilename(slide.narrationFile) - } + val audio = (slide.dramatizationRecordings.selectedFile + ?: slide.draftRecordings.selectedFile + ?: slide.narration)?.fileName var soundtrack = "" var soundtrackVolume = 0.0f @@ -190,11 +192,12 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { //error var duration = 5000000L // 5 seconds, microseconds. - if (audio != "") { + + if (audio != null) { duration = MediaHelper.getAudioDuration(context, getStoryUri(audio)!!) } - pages.add(StoryPage(image, audio, duration, kbfx, overlayText, soundtrack,soundtrackVolume,slide.slideType)) + pages.add(StoryPage(image, audio, duration, kbfx, overlayText, soundtrack, soundtrackVolume, slide.slideType)) } return pages.toTypedArray() @@ -268,7 +271,7 @@ class AutoStoryMaker(private val context: Context) : Thread(), Closeable { val videoFormat = MediaFormat.createVideoFormat(VIDEO_MP4_CODEC, VIDEO_MP4_WIDTH, VIDEO_MP4_HEIGHT) - videoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,VIDEO_MP4_COLOR) + videoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, VIDEO_MP4_COLOR) videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, VIDEO_MP4_FRAMERATE) videoFormat.setInteger(MediaFormat.KEY_CAPTURE_RATE, VIDEO_MP4_FRAMERATE) videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, VIDEO_MP4_BITRATE) diff --git a/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryPage.kt b/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryPage.kt index d5838f59..14dd74fd 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryPage.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/media/story/StoryPage.kt @@ -11,7 +11,7 @@ import org.sil.storyproducer.tools.media.graphics.TextOverlay */ class StoryPage -(val imRelPath: String = "", val narrationAudioPath: String = "", private val mDuration: Long, val kenBurnsEffect: KenBurnsEffect? = null, +(val imRelPath: String = "", val narrationAudioPath: String? = null, private val mDuration: Long, val kenBurnsEffect: KenBurnsEffect? = null, val textOverlay: TextOverlay? = null, val soundtrackAudioPath: String = "", val soundtrackVolume: Float = 0.25f, val sType: SlideType = SlideType.NONE) { /** diff --git a/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt b/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt index 612ecada..c8ce53ae 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt @@ -104,8 +104,7 @@ open class PlayBackRecordingToolbar : RecordingToolbar() { * a playback file. */ override fun updateInheritedToolbarButtonVisibility() { - val playBackFileExist = storyRelPathExists(activity!!, getChosenFilename(slideNum)) - if (playBackFileExist) { + if (getChosenFilename(slideNum) != null) { showInheritedToolbarButtons() } else { hideInheritedToolbarButtons() @@ -140,7 +139,7 @@ open class PlayBackRecordingToolbar : RecordingToolbar() { (toolbarMediaListener as ToolbarMediaListener).onStartedToolbarPlayBack() Log.e("@pwhite", "playButtonOnClickListener with filename ${getChosenFilename()}") - if (audioPlayer.setStorySource(this.appContext, getChosenFilename())) { + if (audioPlayer.setStorySource(this.appContext, getChosenFilename()!!)) { audioPlayer.playAudio() playButton.setBackgroundResource(R.drawable.ic_stop_white_48dp) diff --git a/app/src/main/res/layout/activity_whole_story.xml b/app/src/main/res/layout/activity_whole_story.xml index 482d089e..1767bbdf 100644 --- a/app/src/main/res/layout/activity_whole_story.xml +++ b/app/src/main/res/layout/activity_whole_story.xml @@ -1,86 +1,109 @@ - - - + + app:layout_constraintBottom_toTopOf="@id/guideline" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + + android:background="@color/black_semi_transparent" + android:contentDescription="@string/play" + android:onClick="onClickPlayPauseButton" + android:src="@drawable/ic_play_arrow_white_48dp" + app:layout_constraintEnd_toStartOf="@id/videoSeekBar" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/guideline" /> - + - + android:layout_marginEnd="8dp" + android:contentDescription="@string/sound_off" + app:layout_constraintBottom_toTopOf="@id/toolbar_for_recording_toolbar" + app:layout_constraintEnd_toStartOf="@id/volumeSwitch" + app:layout_constraintHorizontal_bias="0.85" + app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/videoSeekBar" + app:srcCompat="@drawable/ic_volume_off_white_36dp" /> - + - + + - + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"> + - - - - - - + diff --git a/gradle.properties b/gradle.properties index 29e0686f..41f5b624 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,4 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -ROCC_URL_PREFIX="http://10.13.119.69:3030" +ROCC_URL_PREFIX="http://192.168.10.112:3030" From 83f4ed2a5f94e7555da28ad7d8ac4961f1b1ff66 Mon Sep 17 00:00:00 2001 From: Philip White Date: Sun, 3 Nov 2019 20:42:19 +0000 Subject: [PATCH 09/64] Remove some unnecessary function indirection We don't need functions that perform a mapping over a list, but the only time it is called is to extract a single item out of the list. --- .../adapter/RecordingsListAdapter.kt | 13 +++++----- .../DramatizationRecordingToolbar.kt | 7 +++-- .../WholeStoryBackTranslationActivity.kt | 2 +- .../java/org/sil/storyproducer/model/Phase.kt | 12 --------- .../storyproducer/tools/file/AudioFiles.kt | 26 ++++--------------- .../tools/toolbar/PlayBackRecordingToolbar.kt | 11 +++----- 6 files changed, 18 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt b/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt index 7473bbfd..15b1f18a 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/adapter/RecordingsListAdapter.kt @@ -224,8 +224,9 @@ class RecordingsListAdapter(private val recordings: RecordingList, private val l stopAudio() }) - if (storyRelPathExists(context, getRecordedAudioFiles()[pos])) { - audioPlayer.setStorySource(context, getRecordedAudioFiles()[pos]) + val recordingFile = Workspace.activePhase.getRecordings().getFiles()[pos].fileName + if (storyRelPathExists(context, recordingFile)) { + audioPlayer.setStorySource(context, recordingFile) audioPlayer.playAudio() when (Workspace.activePhase.phaseType) { PhaseType.DRAFT -> saveLog(context.getString(R.string.DRAFT_PLAYBACK)) @@ -242,11 +243,9 @@ class RecordingsListAdapter(private val recordings: RecordingList, private val l override fun onDeleteClick(name: String, pos: Int) { deleteAudioFileFromList(context, pos) recyclerView?.adapter!!.notifyDataSetChanged() - if ("${Workspace.activeDir}/$name" == getChosenDisplayName()) { - if (displayNames.getFiles().isNotEmpty()) { - onRowClick(displayNames.getFiles().size - 1) - } else { - displayNames.selectedIndex = -1 + if (pos == Workspace.activePhase.getRecordings().selectedIndex) { + displayNames.selectedIndex = displayNames.getFiles().size - 1 + if (displayNames.getFiles().isEmpty()) { toolbar?.updateInheritedToolbarButtonVisibility() dialog?.dismiss() } diff --git a/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt b/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt index 9f9efe48..802e8378 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/dramatization/DramatizationRecordingToolbar.kt @@ -4,9 +4,8 @@ import android.view.View import android.widget.ImageButton import com.crashlytics.android.Crashlytics import org.sil.storyproducer.R -import org.sil.storyproducer.model.Workspace import org.sil.storyproducer.tools.file.assignNewAudioRelPath -import org.sil.storyproducer.tools.file.getChosenFilename +import org.sil.storyproducer.tools.file.getChosenRecording import org.sil.storyproducer.tools.file.getTempAppendAudioRelPath import org.sil.storyproducer.tools.media.AudioRecorder import org.sil.storyproducer.tools.toolbar.MultiRecordRecordingToolbar @@ -90,7 +89,7 @@ class DramatizationRecordingToolbar: MultiRecordRecordingToolbar() { if (wasRecording) { if (isAppendingOn) { try { - AudioRecorder.concatenateAudioFiles(appContext, getChosenFilename()!!, audioTempName) + AudioRecorder.concatenateAudioFiles(appContext, getChosenRecording()!!.fileName, audioTempName) } catch (e: FileNotFoundException) { Crashlytics.logException(e) } @@ -118,7 +117,7 @@ class DramatizationRecordingToolbar: MultiRecordRecordingToolbar() { if (isAppendingOn && (voiceRecorder?.isRecording == true)) { stopToolbarMedia() try { - AudioRecorder.concatenateAudioFiles(appContext, getChosenFilename()!!, audioTempName) + AudioRecorder.concatenateAudioFiles(appContext, getChosenRecording()!!.fileName, audioTempName) } catch (e: FileNotFoundException) { Crashlytics.logException(e) } diff --git a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt index 4d1eb22d..6a352543 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt @@ -112,7 +112,7 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording js["Key"] = getString(R.string.api_token) js["PhoneId"] = phoneID js["TemplateTitle"] = Workspace.activeStory.title - js["SlideNumber"] = Workspace.activeSlideNum.toString() + js["SlideNumber"] = Workspace.activeStory.slides.size.toString() js["Data"] = byteString val url = BuildConfig.ROCC_URL_PREFIX + getString(R.string.url_upload_audio) val req = object : paramStringRequest(Method.POST, url, js, { diff --git a/app/src/main/java/org/sil/storyproducer/model/Phase.kt b/app/src/main/java/org/sil/storyproducer/model/Phase.kt index e846e617..e8958e53 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Phase.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Phase.kt @@ -42,18 +42,6 @@ class Phase(val phaseType: PhaseType) { } } - fun getCombNames(slideNum: Int = Workspace.activeSlideNum): List { - val recordings = - when (phaseType) { - PhaseType.DRAFT -> Workspace.activeStory.slides[slideNum].draftRecordings - PhaseType.COMMUNITY_CHECK -> Workspace.activeStory.slides[slideNum].communityCheckRecordings - PhaseType.DRAMATIZATION -> Workspace.activeStory.slides[slideNum].dramatizationRecordings - PhaseType.BACKT -> Workspace.activeStory.slides[slideNum].backTranslationRecordings - else -> throw Exception("Unsupported phase to get a list of recordings from") - } - return recordings.getFiles() - } - fun getIcon(phase: PhaseType = phaseType): Int { return when (phase) { PhaseType.LEARN -> R.drawable.ic_ear_speak diff --git a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt index 557227e2..020a1e36 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt @@ -21,14 +21,6 @@ internal const val AUDIO_EXT = ".m4a" * @return the path generated, or an empty string if there is a failure. */ -fun getChosenFilename(slideNum: Int = Workspace.activeSlideNum): String? { - return getChosenRecording(slideNum)?.fileName -} - -fun getChosenDisplayName(slideNum: Int = Workspace.activeSlideNum): String? { - return getChosenRecording(slideNum)?.displayName -} - fun getChosenRecording(slideNum: Int = Workspace.activeSlideNum): Recording? { return when (Workspace.activePhase.phaseType) { PhaseType.LEARN -> Workspace.activeStory.learnAudioFile @@ -41,17 +33,9 @@ fun getChosenRecording(slideNum: Int = Workspace.activeSlideNum): Recording? { } } -fun getRecordedDisplayNames(slideNum: Int = Workspace.activeSlideNum): List { - return Workspace.activePhase.getCombNames(slideNum).map { it.displayName } -} - -fun getRecordedAudioFiles(slideNum: Int = Workspace.activeSlideNum): List { - return Workspace.activePhase.getCombNames(slideNum).map { it.fileName } -} - fun assignNewAudioRelPath(): String { - val recording = createRecordingCombinedName() - addCombinedName(recording) + val recording = createRecording() + addRecording(recording) return recording.fileName } @@ -69,7 +53,7 @@ fun deleteAudioFileFromList(context: Context, pos: Int) { deleteStoryFile(context, filename) } -fun createRecordingCombinedName(): Recording { +fun createRecording(): Recording { //Example: project/communityCheck_3_2018-03-17T11:14;31.542.md4 //This is the file name generator for all audio files for the app. @@ -84,7 +68,7 @@ fun createRecordingCombinedName(): Recording { PhaseType.DRAFT, PhaseType.COMMUNITY_CHECK, PhaseType.DRAMATIZATION, PhaseType.CONSULTANT_CHECK -> { //find the next number that is available for saving files at. - val names = getRecordedDisplayNames() + val names = Workspace.activePhase.getRecordings().getFiles().map { it.displayName } val rNameNum = "${Workspace.activePhase.getDisplayName()} ([0-9]+)".toRegex() var maxNum = 0 for (n in names) { @@ -105,7 +89,7 @@ fun createRecordingCombinedName(): Recording { } } -fun addCombinedName(recording: Recording) { +fun addRecording(recording: Recording) { //register it in the story data structure. when (Workspace.activePhase.phaseType) { PhaseType.LEARN -> Workspace.activeStory.learnAudioFile = recording diff --git a/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt b/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt index c8ce53ae..f206187d 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/toolbar/PlayBackRecordingToolbar.kt @@ -3,19 +3,15 @@ package org.sil.storyproducer.tools.toolbar import android.content.Context import android.media.MediaPlayer import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import android.widget.ImageButton import android.widget.Toast -import android.util.Log import org.sil.storyproducer.R import org.sil.storyproducer.model.PhaseType import org.sil.storyproducer.model.SLIDE_NUM import org.sil.storyproducer.model.Workspace import org.sil.storyproducer.model.logging.saveLog -import org.sil.storyproducer.tools.file.getChosenFilename -import org.sil.storyproducer.tools.file.storyRelPathExists +import org.sil.storyproducer.tools.file.getChosenRecording import org.sil.storyproducer.tools.media.AudioPlayer /** @@ -104,7 +100,7 @@ open class PlayBackRecordingToolbar : RecordingToolbar() { * a playback file. */ override fun updateInheritedToolbarButtonVisibility() { - if (getChosenFilename(slideNum) != null) { + if (getChosenRecording(slideNum) != null) { showInheritedToolbarButtons() } else { hideInheritedToolbarButtons() @@ -138,8 +134,7 @@ open class PlayBackRecordingToolbar : RecordingToolbar() { if (!wasPlaying) { (toolbarMediaListener as ToolbarMediaListener).onStartedToolbarPlayBack() - Log.e("@pwhite", "playButtonOnClickListener with filename ${getChosenFilename()}") - if (audioPlayer.setStorySource(this.appContext, getChosenFilename()!!)) { + if (audioPlayer.setStorySource(this.appContext, getChosenRecording()!!.fileName)) { audioPlayer.playAudio() playButton.setBackgroundResource(R.drawable.ic_stop_white_48dp) From 9c9dcdc644574b802a4ab06c246305888177506b Mon Sep 17 00:00:00 2001 From: Philip White Date: Mon, 4 Nov 2019 03:58:12 +0000 Subject: [PATCH 10/64] Added backtranslation activity with upload button --- .../backtranslation/BackTranslationFrag.kt | 133 ++++++++++++++++ .../controller/pager/PagerAdapter.kt | 36 ++--- .../WholeStoryBackTranslationActivity.kt | 6 +- .../java/org/sil/storyproducer/model/Slide.kt | 1 + .../storyproducer/tools/file/AudioFiles.kt | 4 +- .../res/layout/fragment_backtranslation.xml | 145 +++++++++--------- gradle.properties | 2 +- 7 files changed, 226 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/org/sil/storyproducer/controller/backtranslation/BackTranslationFrag.kt diff --git a/app/src/main/java/org/sil/storyproducer/controller/backtranslation/BackTranslationFrag.kt b/app/src/main/java/org/sil/storyproducer/controller/backtranslation/BackTranslationFrag.kt new file mode 100644 index 00000000..d01c200c --- /dev/null +++ b/app/src/main/java/org/sil/storyproducer/controller/backtranslation/BackTranslationFrag.kt @@ -0,0 +1,133 @@ +package org.sil.storyproducer.controller.backtranslation + +import android.os.Bundle +import android.provider.Settings +import android.support.graphics.drawable.VectorDrawableCompat +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.Toast +import org.apache.commons.io.IOUtils +import org.sil.storyproducer.BuildConfig +import org.sil.storyproducer.R +import org.sil.storyproducer.controller.MultiRecordFrag +import org.sil.storyproducer.controller.phase.PhaseBaseActivity +import org.sil.storyproducer.model.UploadState +import org.sil.storyproducer.model.Workspace +import org.sil.storyproducer.tools.Network.VolleySingleton +import org.sil.storyproducer.tools.Network.paramStringRequest +import org.sil.storyproducer.tools.file.getStoryChildInputStream + +/** + * The fragment for the Draft view. This is where a user can draft out the story slide by slide + */ +class BackTranslationFrag : MultiRecordFrag() { + + private lateinit var greenCheckmark: VectorDrawableCompat + private lateinit var grayCheckmark: VectorDrawableCompat + private lateinit var yellowCheckmark: VectorDrawableCompat + private lateinit var uploadButton: ImageButton + private lateinit var imageView: ImageView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + + setHasOptionsMenu(true) + } + + override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?): View? { + // The last two arguments ensure LayoutParams are inflated + rootView = inflater.inflate(R.layout.fragment_backtranslation, container, false) + + imageView = rootView!!.findViewById(R.id.fragment_image_view) as ImageView + uploadButton = rootView!!.findViewById(R.id.upload_audio_botton) + + setPic(imageView) + + greenCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_green, null)!! + grayCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_gray, null)!! + yellowCheckmark = VectorDrawableCompat.create(resources, R.drawable.ic_checkmark_yellow, null)!! + uploadButton.background = when (Workspace.activeSlide!!.backTranslationUploadState) { + UploadState.UPLOADED -> greenCheckmark + UploadState.NOT_UPLOADED -> grayCheckmark + UploadState.UPLOADING -> yellowCheckmark + } + + uploadButton.setOnClickListener { + when (Workspace.activeSlide!!.backTranslationUploadState) { + UploadState.UPLOADED -> Toast.makeText(context, "Selected recording already uploaded", Toast.LENGTH_SHORT).show() + UploadState.NOT_UPLOADED -> { + val audioRecording = Workspace.activeSlide!!.backTranslationRecordings.selectedFile + if (audioRecording != null) { + Workspace.activeSlide!!.backTranslationUploadState = UploadState.UPLOADING + uploadButton.background = yellowCheckmark + + Toast.makeText(context!!, "Uploading audio", Toast.LENGTH_SHORT).show() + val input = getStoryChildInputStream(context!!, audioRecording.fileName) + val audioBytes = IOUtils.toByteArray(input) + val byteString = android.util.Base64.encodeToString(audioBytes, android.util.Base64.DEFAULT) + val phoneID = Settings.Secure.getString(context!!.contentResolver, Settings.Secure.ANDROID_ID) + val js = HashMap() + js["Key"] = getString(R.string.api_token) + js["PhoneId"] = phoneID + js["TemplateTitle"] = Workspace.activeStory.title + js["SlideNumber"] = Workspace.activeSlideNum.toString() + js["Data"] = byteString + val url = BuildConfig.ROCC_URL_PREFIX + getString(R.string.url_upload_audio) + val req = object : paramStringRequest(Method.POST, url, js, { + Log.i("LOG_VOLLEY_RESP_UPL", it) + Toast.makeText(context, R.string.audio_Sent, Toast.LENGTH_SHORT).show() + Workspace.activeSlide!!.backTranslationUploadState = UploadState.UPLOADED + uploadButton.background = greenCheckmark + }, { + Log.e("LOG_VOLLEY_ERR_UPL", it.toString()) + Log.e("LOG_VOLLEY", "HIT ERROR") + Toast.makeText(context, R.string.audio_Send_Failed, Toast.LENGTH_SHORT).show() + Workspace.activeSlide!!.backTranslationUploadState = UploadState.NOT_UPLOADED + uploadButton.background = grayCheckmark + }) { + override fun getParams(): Map { + return this.mParams + } + } + VolleySingleton.getInstance(context).addToRequestQueue(req) + } else { + Toast.makeText(context!!, "No recording found", Toast.LENGTH_SHORT).show() + } + + + } + UploadState.UPLOADING -> { + uploadButton.background = yellowCheckmark + Toast.makeText(context!!, "Upload already in progress", Toast.LENGTH_SHORT).show() + } + } + } + + uploadButton.setOnLongClickListener { + when (Workspace.activeSlide!!.backTranslationUploadState) { + UploadState.UPLOADING -> { + Workspace.activeSlide!!.backTranslationUploadState = UploadState.NOT_UPLOADED + Toast.makeText(context!!, "Cancelling upload", Toast.LENGTH_SHORT).show() + uploadButton.background = grayCheckmark + } + UploadState.UPLOADED -> { + Workspace.activeSlide!!.backTranslationUploadState = UploadState.NOT_UPLOADED + Toast.makeText(context!!, "Ignoring previous upload", Toast.LENGTH_SHORT).show() + uploadButton.background = grayCheckmark + } + UploadState.NOT_UPLOADED -> Toast.makeText(context!!, "There have been no uploads yet", Toast.LENGTH_SHORT).show() + } + true + } + + setToolbar() + return rootView + } + +} diff --git a/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt b/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt index 051dec10..e0ab8c82 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/pager/PagerAdapter.kt @@ -5,6 +5,7 @@ import android.support.v4.app.Fragment import android.support.v4.app.FragmentManager import android.support.v4.app.FragmentStatePagerAdapter import android.util.Log; +import org.sil.storyproducer.controller.backtranslation.BackTranslationFrag import org.sil.storyproducer.controller.community.CommunityCheckFrag import org.sil.storyproducer.controller.consultant.ConsultantCheckFrag import org.sil.storyproducer.controller.draft.DraftFrag @@ -25,32 +26,17 @@ class PagerAdapter(fm: FragmentManager) : FragmentStatePagerAdapter(fm) { * @return the fragment */ override fun getItem(i: Int): Fragment { - val fragment: Fragment - val passedArgs = Bundle() - Log.e("@pwhite", "PagerAdapter.getItem(): phase type is ${Workspace.activePhase.phaseType}"); - when (Workspace.activePhase.phaseType) { - PhaseType.DRAFT -> { - fragment = DraftFrag() - } - PhaseType.COMMUNITY_CHECK -> { - fragment = CommunityCheckFrag() - } - PhaseType.CONSULTANT_CHECK -> { - fragment = ConsultantCheckFrag() - } - PhaseType.DRAMATIZATION -> { - fragment = DramatizationFrag() - } - //PhaseType.BACKT -> { - //fragment = BackTranslationFrag() - //} - PhaseType.REMOTE_CHECK -> { - fragment = RemoteCheckFrag() - } - else -> { - fragment = DraftFrag() - } + val fragment: Fragment = when (Workspace.activePhase.phaseType) { + PhaseType.DRAFT -> DraftFrag() + PhaseType.COMMUNITY_CHECK -> CommunityCheckFrag() + PhaseType.CONSULTANT_CHECK -> ConsultantCheckFrag() + PhaseType.DRAMATIZATION -> DramatizationFrag() + PhaseType.BACKT -> BackTranslationFrag() + PhaseType.REMOTE_CHECK -> RemoteCheckFrag() + else -> DraftFrag() } + + val passedArgs = Bundle() passedArgs.putInt(SLIDE_NUM, i) fragment.arguments = passedArgs diff --git a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt index 6a352543..f7f71ae5 100644 --- a/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt +++ b/app/src/main/java/org/sil/storyproducer/controller/remote/WholeStoryBackTranslationActivity.kt @@ -98,10 +98,10 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording when (Workspace.activeStory.wholeStoryBackTranslationUploadState) { UploadState.UPLOADED -> Toast.makeText(this, "Selected recording already uploaded", Toast.LENGTH_SHORT).show() UploadState.NOT_UPLOADED -> { - Workspace.activeStory.wholeStoryBackTranslationUploadState = UploadState.UPLOADING - uploadButton.background = yellowCheckmark val audioRecording = Workspace.activeStory.wholeStoryBackTAudioFile if (audioRecording != null) { + Workspace.activeStory.wholeStoryBackTranslationUploadState = UploadState.UPLOADING + uploadButton.background = yellowCheckmark Toast.makeText(this, "Uploading audio", Toast.LENGTH_SHORT).show() val input = getStoryChildInputStream(this, audioRecording.fileName) @@ -132,6 +132,8 @@ class WholeStoryBackTranslationActivity : PhaseBaseActivity(), PlayBackRecording } } VolleySingleton.getInstance(applicationContext).addToRequestQueue(req) + } else { + Toast.makeText(this, "No recording found", Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/sil/storyproducer/model/Slide.kt b/app/src/main/java/org/sil/storyproducer/model/Slide.kt index 19ed41d5..872bdccd 100644 --- a/app/src/main/java/org/sil/storyproducer/model/Slide.kt +++ b/app/src/main/java/org/sil/storyproducer/model/Slide.kt @@ -55,6 +55,7 @@ class Slide{ var consultantCheckRecordings = RecordingList() var dramatizationRecordings = RecordingList() var backTranslationRecordings = RecordingList() + var backTranslationUploadState = UploadState.NOT_UPLOADED //consultant approval var isChecked: Boolean = false diff --git a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt index 020a1e36..b3b1e101 100644 --- a/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt +++ b/app/src/main/java/org/sil/storyproducer/tools/file/AudioFiles.kt @@ -66,7 +66,8 @@ fun createRecording(): Recording { Workspace.activePhase.getDisplayName()) //Make new files every time. Don't append. PhaseType.DRAFT, PhaseType.COMMUNITY_CHECK, - PhaseType.DRAMATIZATION, PhaseType.CONSULTANT_CHECK -> { + PhaseType.DRAMATIZATION, PhaseType.CONSULTANT_CHECK, + PhaseType.BACKT -> { //find the next number that is available for saving files at. val names = Workspace.activePhase.getRecordings().getFiles().map { it.displayName } val rNameNum = "${Workspace.activePhase.getDisplayName()} ([0-9]+)".toRegex() @@ -98,6 +99,7 @@ fun addRecording(recording: Recording) { PhaseType.CONSULTANT_CHECK -> Workspace.activeSlide!!.consultantCheckRecordings.add(recording) PhaseType.DRAFT -> Workspace.activeSlide!!.draftRecordings.add(recording) PhaseType.DRAMATIZATION -> Workspace.activeSlide!!.dramatizationRecordings.add(recording) + PhaseType.BACKT -> Workspace.activeSlide!!.backTranslationRecordings.add(recording) else -> throw Exception("Unsupported phase to add an audio file to") } } diff --git a/app/src/main/res/layout/fragment_backtranslation.xml b/app/src/main/res/layout/fragment_backtranslation.xml index 1da9f80b..03b007f4 100644 --- a/app/src/main/res/layout/fragment_backtranslation.xml +++ b/app/src/main/res/layout/fragment_backtranslation.xml @@ -1,85 +1,86 @@ - + android:background="@color/gray"> - - - - - - - + - + - + - - - - + app:layout_constraintBottom_toTopOf="@+id/guideline" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + - - - + - + + + \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 41f5b624..29e0686f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,4 @@ # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -ROCC_URL_PREFIX="http://192.168.10.112:3030" +ROCC_URL_PREFIX="http://10.13.119.69:3030" From 18d0e145a539c01f81bf42336d461ff30bffda03 Mon Sep 17 00:00:00 2001 From: Philip White Date: Tue, 12 Nov 2019 16:43:37 -0500 Subject: [PATCH 11/64] Reformat layout file --- .../layout/fragment_remote_check_layout.xml | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/app/src/main/res/layout/fragment_remote_check_layout.xml b/app/src/main/res/layout/fragment_remote_check_layout.xml index 099c254d..9a4c0ede 100644 --- a/app/src/main/res/layout/fragment_remote_check_layout.xml +++ b/app/src/main/res/layout/fragment_remote_check_layout.xml @@ -14,62 +14,58 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/messagingSecondary" - android:orientation="horizontal" - > + android:orientation="horizontal"> + android:textSize="18sp" /> - + android:id="@+id/message_history" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="2" + android:background="@color/messagingBkrd" + android:divider="@color/white">
    - + - + android:textColor="@color/black" />