Skip to content

Commit

Permalink
fix ankidroid#15772: implemented .avi video playback
Browse files Browse the repository at this point in the history
We implemented .avi video playback by checking the
type of file being imported. If the user has set the
advanced preference to enable this workaround,
if the file is in fact an .avi file, we use ffmpeg to
decode the file, enabling android webview media
player to playback the file

Co-authored-by: Bernardo Galante <[email protected]>
  • Loading branch information
henriqueccmac and BAH-HA committed Jun 3, 2024
1 parent fabbd7a commit 142998c
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 15 deletions.
1 change: 1 addition & 0 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ dependencies {
implementation libs.nanohttpd
implementation libs.kotlinx.serialization.json
implementation libs.seismic
implementation 'com.arthenica:mobile-ffmpeg-full-gpl:4.4.LTS'

debugImplementation libs.androidx.fragment.testing.manifest

Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ class NoteEditor : AnkiActivity(), DeckSelectionListener, SubtitleListener, Tags
// Import field media
// This goes before setting formattedValue to update
// media paths with the checksum when they have the same name
NoteService.importMediaToDirectory(col, field)
NoteService.importMediaToDirectory(this, col, field)
// Completely replace text for text fields (because current text was passed in)
val formattedValue = field.formattedValue
if (field.type === EFieldType.TEXT) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ object UsageAnalytics {
"noteEditorNewlineReplace", // Replace newlines with HTML
"autoFocusTypeInAnswer", // Focus ‘type in answer’
"mediaImportAllowAllFiles", // Allow all files in media imports
"mediaForceAviDecoding", // Force AVI decoding
"providerEnabled", // Enable AnkiDroid API
// App bar buttons
"reset_custom_buttons",
Expand Down
128 changes: 125 additions & 3 deletions AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/NoteService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@

package com.ichi2.anki.servicelayer

import android.content.Context
import android.os.Bundle
import androidx.annotation.CheckResult
import androidx.annotation.VisibleForTesting
import com.arthenica.mobileffmpeg.Config
import com.arthenica.mobileffmpeg.FFmpeg
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.CrashReportService
import com.ichi2.anki.FieldEditText
import com.ichi2.anki.multimediacard.IMultimediaEditableNote
import com.ichi2.anki.multimediacard.fields.*
import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.libanki.Card
import com.ichi2.libanki.Collection
import com.ichi2.libanki.Consts
Expand Down Expand Up @@ -117,7 +121,7 @@ object NoteService {
*
* @param field
*/
fun importMediaToDirectory(col: Collection, field: IField?) {
fun importMediaToDirectory(context: Context, col: Collection, field: IField?) {
var tmpMediaPath: String? = null
when (field!!.type) {
EFieldType.AUDIO_RECORDING, EFieldType.MEDIA_CLIP -> tmpMediaPath = field.audioPath
Expand All @@ -127,13 +131,28 @@ object NoteService {
}
if (tmpMediaPath != null) {
try {
val inFile = File(tmpMediaPath)
var inFile = File(tmpMediaPath)
if (inFile.exists() && inFile.length() > 0) {
// Check if AVI decoding is forced by user preferences
val forceAviConversion = context.sharedPrefs().getBoolean("mediaForceAviDecoding", false)

if (forceAviConversion && inFile.extension == "avi") {
// Convert AVI to MP4
val outFile = File(inFile.parent, inFile.nameWithoutExtension + "Converted.mp4")
val success = convertVideoToMp4(inFile.absolutePath, outFile.absolutePath)

if (success) {
inFile = outFile
} else {
Timber.e("Failed to convert AVI to MP4: $inFile")
}
}

val fname = col.media.addFile(inFile)
val outFile = File(col.media.dir, fname)
Timber.v("""File "%s" should be copied to "%s""", fname, outFile)
if (field.hasTemporaryMedia && outFile.absolutePath != tmpMediaPath) {
// Delete original
// Delete original file if it was copied to media directory
inFile.delete()
}
when (field.type) {
Expand Down Expand Up @@ -227,6 +246,109 @@ object NoteService {
return nonNewOrLearningCards.average { it.ivl }?.toInt()
}

/**
* Converts a video file from AVI format to MP4 format using FFmpeg.
*
* The conversion is done synchronously, ideally should be done in a background thread.
* @param inputPath The path to the input AVI file.
* @param outputPath The path where the output MP4 file should be saved.
* @return A boolean indicating whether the conversion was successful.
*/
fun convertVideoToMp4(inputPath: String, outputPath: String): Boolean {
Timber.i("Starting video conversion from AVI to MP4")
Timber.d("Input path: $inputPath")
Timber.d("Output path: $outputPath")

if (!validateFilePath(inputPath)) {
Timber.e("Invalid input file path: $inputPath")
return false
}

if (!validateOutputPath(outputPath)) {
Timber.e("Invalid output file path: $outputPath")
return false
}

val command = buildFFmpegCommand(inputPath, outputPath)
Timber.d("FFmpeg command: $command")

// Execute synchronously
val returnCode = FFmpeg.execute(command)

val commandOutput = Config.getLastCommandOutput()
Timber.d("FFmpeg command output: $commandOutput")

return handleFFmpegReturnCode(returnCode, inputPath, outputPath)
}

/**
* Validates the input file path.
*
* @param path The file path to validate.
* @return A boolean indicating whether the file path is valid.
*/
private fun validateFilePath(path: String): Boolean {
val file = File(path)

return file.exists() && file.isFile
}

/**
* Validates the output file path.
*
* @param path The file path to validate.
* @return A boolean indicating whether the file path is valid.
*/
private fun validateOutputPath(path: String): Boolean {
val file = File(path)
val parentDir = file.parentFile

return parentDir != null && parentDir.exists() && parentDir.isDirectory
}

/**
* Builds the FFmpeg command for converting AVI to MP4.
*
* Uses H.264 video codec and AAC audio codec.
* The ultrafast preset is used for faster conversion along with the
* -y flag to overwrite the output file if it already exists.
*
* @param inputPath The path to the input AVI file.
* @param outputPath The path where the output MP4 file should be saved.
* @return The constructed FFmpeg command as a string.
*/
private fun buildFFmpegCommand(inputPath: String, outputPath: String): String {
return "-i $inputPath -c:v libx264 -preset ultrafast -c:a aac -y $outputPath"
}

/**
* Handles the FFmpeg return code and logs the appropriate messages.
*
* @param returnCode The return code from the FFmpeg execution.
* @param inputPath The path to the input AVI file.
* @param outputPath The path where the output MP4 file should be saved.
* @return A boolean indicating whether the conversion was successful.
*/
private fun handleFFmpegReturnCode(returnCode: Int, inputPath: String, outputPath: String): Boolean {
return when (returnCode) {
Config.RETURN_CODE_SUCCESS -> {
Timber.d("Video conversion successful: $inputPath to $outputPath")
true
}

Config.RETURN_CODE_CANCEL -> {
Timber.d("Video conversion cancelled")
false
}

else -> {
val error = Config.getLastCommandOutput()
Timber.e("Video conversion failed with return code $returnCode: $error")
false
}
}
}

interface NoteField {
val ord: Int

Expand Down
3 changes: 3 additions & 0 deletions AnkiDroid/src/main/res/values/10-preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@
<!-- Allow all files in media imports -->
<string name="media_import_allow_all_files" maxLength="41">Allow all files in media imports</string>
<string name="media_import_allow_all_files_summ">If Android doesn’t recognize a media file</string>
<!-- Force avi multimedia format decoding -->
<string name="media_force_avi_decoding" maxLength="41">Force .avi file decoding</string>
<string name="media_force_avi_decoding_summ">Enable .avi files to be decoded into mp4 for playback.</string>
<!-- Custom sync server settings -->
<string name="custom_sync_server_title" maxLength="41">Custom sync server</string>
<string name="custom_sync_server_summary_none_of_the_two_servers_used"
Expand Down
1 change: 1 addition & 0 deletions AnkiDroid/src/main/res/values/preferences.xml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@
<string name="type_in_answer_focus_key">autoFocusTypeInAnswer</string>
<string name="exit_via_double_tap_back_key">exitViaDoubleTapBack</string>
<string name="media_import_allow_all_files_key">mediaImportAllowAllFiles</string>
<string name="media_force_avi_decoding_key">mediaForceAviDecoding</string>
<string name="pref_cat_plugins_key">category_plugins</string>
<string name="enable_api_key">providerEnabled</string>
<string name="thirdparty_apps_key">thirdpartyapps_link</string>
Expand Down
6 changes: 6 additions & 0 deletions AnkiDroid/src/main/res/xml/preferences_advanced.xml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@
android:key="@string/media_import_allow_all_files_key"
android:summary="@string/media_import_allow_all_files_summ"
android:title="@string/media_import_allow_all_files" />
<!-- Workaround for #15772 -->
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="@string/media_force_avi_decoding_key"
android:summary="@string/media_force_avi_decoding_summ"
android:title="@string/media_force_avi_decoding" />
</PreferenceCategory>
<PreferenceCategory
android:key="@string/pref_cat_plugins_key"
Expand Down
Loading

0 comments on commit 142998c

Please sign in to comment.