投稿時の動画変換のコードを整理

This commit is contained in:
tateisu 2022-01-06 07:29:05 +09:00
parent 3c90cfa6c8
commit cd8168b62a
7 changed files with 227 additions and 240 deletions

View File

@ -967,19 +967,14 @@ class SavedAccount(
?: if (ti.instanceType == InstanceType.Pixelfed) 15 else 8
)
fun getMovieResizeConfig(): MovieResizeConfig = MovieResizeConfig(
mode = when (movieTranscodeMode) {
MovieResizeConfig.MODE_NO,
MovieResizeConfig.MODE_AUTO,
MovieResizeConfig.NODE_ALWAYS,
-> movieTranscodeMode
else -> MovieResizeConfig.MODE_AUTO
},
limitBitrate = movieTranscodeBitrate.toLongOrNull()
?.takeIf { it >= 100_000L } ?: 2_000_000L,
limitFrameRate = movieTranscodeFramerate.toIntOrNull()
?.takeIf { it >= 1 } ?: 30,
limitPixelMatrix = movieTranscodeSquarePixels.toIntOrNull()
?.takeIf { it > 0 } ?: 2304000,
)
fun getMovieResizeConfig() =
MovieResizeConfig(
mode = MovideResizeMode.fromInt(movieTranscodeMode),
limitBitrate = movieTranscodeBitrate.toLongOrNull()
?.takeIf { it >= 100_000L } ?: 2_000_000L,
limitFrameRate = movieTranscodeFramerate.toIntOrNull()
?.takeIf { it >= 1 } ?: 30,
limitSquarePixels = movieTranscodeSquarePixels.toIntOrNull()
?.takeIf { it > 0 } ?: 2304000,
)
}

View File

@ -15,13 +15,13 @@ import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.*
import jp.juggler.util.VideoInfo.Companion.videoInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
@ -30,7 +30,6 @@ import java.io.*
import java.util.*
import java.util.concurrent.CancellationException
import kotlin.coroutines.coroutineContext
import kotlin.math.min
class AttachmentRequest(
val account: SavedAccount,
@ -302,27 +301,13 @@ class AttachmentUploader(
?.let { ResizeConfig(ResizeType.SquarePixel, it) }
?: account.getResizeConfig()
val movieResizeConfig = account.getMovieResizeConfig()
mediaConfig?.int("video_frame_rate_limit")
?.takeIf { it >= 1f }
?.let {
movieResizeConfig.limitFrameRate = min(movieResizeConfig.limitFrameRate, it)
}
mediaConfig?.int("video_matrix_limit")
?.takeIf { it > 1 }
?.let {
movieResizeConfig.limitPixelMatrix = min(movieResizeConfig.limitPixelMatrix, it)
}
// 入力データの変換など
val opener = createOpener(
account,
uri,
mimeType,
mediaConfig = mediaConfig,
imageResizeConfig = imageResizeConfig,
movieResizeConfig = movieResizeConfig,
postAttachment = pa,
)
@ -337,8 +322,7 @@ class AttachmentUploader(
?: account.getImageMaxBytes(ti)
}
val contentLength = getStreamSize(true, opener.open())
if (contentLength > mediaSizeMax) {
if (opener.contentLength > mediaSizeMax) {
return TootApiResult(
context.getString(R.string.file_size_too_big, mediaSizeMax / 1000000)
)
@ -362,17 +346,12 @@ class AttachmentUploader(
val fileName = fixDocumentName(getDocumentName(context.contentResolver, uri))
pa.progress = context.getString(R.string.attachment_handling_uploading, 0)
var nWrite = 0
fun writeProgress(delta: Int) {
nWrite += delta
if (contentLength > 0) {
val percent = (100f * nWrite.toFloat() / contentLength.toFloat()).toInt()
if (percent < 100) {
pa.progress =
context.getString(R.string.attachment_handling_uploading, percent)
} else {
pa.progress = context.getString(R.string.attachment_handling_waiting)
}
fun writeProgress(percent: Int) {
if (percent < 100) {
pa.progress =
context.getString(R.string.attachment_handling_uploading, percent)
} else {
pa.progress = context.getString(R.string.attachment_handling_waiting)
}
}
@ -388,29 +367,7 @@ class AttachmentUploader(
multipartBuilder.addFormDataPart(
"file",
fileName,
object : RequestBody() {
override fun contentType(): MediaType {
return opener.mimeType.toMediaType()
}
@Throws(IOException::class)
override fun contentLength(): Long {
return contentLength
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
opener.open().use { inData ->
val tmp = ByteArray(4096)
while (true) {
val r = inData.read(tmp, 0, tmp.size)
if (r <= 0) break
writeProgress(r)
sink.write(tmp, 0, r)
}
}
}
}
opener.toRequestBody { writeProgress(it) },
)
val result = client.request(
@ -437,29 +394,7 @@ class AttachmentUploader(
.addFormDataPart(
"file",
fileName,
object : RequestBody() {
override fun contentType(): MediaType {
return opener.mimeType.toMediaType()
}
@Throws(IOException::class)
override fun contentLength(): Long {
return contentLength
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
opener.open().use { inData ->
val tmp = ByteArray(4096)
while (true) {
val r = inData.read(tmp, 0, tmp.size)
if (r <= 0) break
writeProgress(r)
sink.write(tmp, 0, r)
}
}
}
}
opener.toRequestBody { writeProgress(it) },
)
.build().toPost()
)
@ -481,18 +416,18 @@ class AttachmentUploader(
// 202 accepted 以外はポーリングしない
code != 202 -> return result
}
pa.progress = context.getString(R.string.attachment_handling_waiting2)
// ポーリングして処理完了を待つ
val id =
parseItem(::TootAttachment, ServiceType.MASTODON, result?.jsonObject)
?.id
?: return TootApiResult("/api/v2/media did not return the media ID.")
pa.progress = context.getString(R.string.attachment_handling_waiting_async)
val id = parseItem(::TootAttachment, ServiceType.MASTODON, result?.jsonObject)
?.id
?: return TootApiResult("/api/v2/media did not return the media ID.")
var lastResponse = SystemClock.elapsedRealtime()
loop@ while (true) {
delay(1000L)
val r2 = client.request("/api/v1/media/$id")
?: return null // cancelled
@ -504,7 +439,7 @@ class AttachmentUploader(
// continue to wait
206 -> lastResponse = now
// too many temporary error without 206 response.
// temporary errors, check timeout without 206 response.
else -> if (now - lastResponse >= 120000L) {
return TootApiResult("timeout.")
}
@ -561,25 +496,78 @@ class AttachmentUploader(
pa.callback?.onPostAttachmentComplete(pa)
}
internal interface InputStreamOpener {
val mimeType: String
// contentLengthの測定などで複数回オープンする必要がある
private abstract class InputStreamOpener {
abstract val mimeType: String
@Throws(IOException::class)
fun open(): InputStream
abstract fun open(): InputStream
fun deleteTempFile()
abstract fun deleteTempFile()
val contentLength by lazy { getStreamSize(true, open()) }
// okhttpのRequestBodyにする
fun toRequestBody(onWrote: (percent: Int) -> Unit = {}) =
object : RequestBody() {
override fun contentType() = mimeType.toMediaType()
@Throws(IOException::class)
override fun contentLength(): Long = contentLength
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
val length = contentLength.toFloat()
open().use { inStream ->
val tmp = ByteArray(4096)
var nWrite = 0L
while (true) {
val delta = inStream.read(tmp, 0, tmp.size)
if (delta <= 0) break
sink.write(tmp, 0, delta)
nWrite += delta
val percent = (100f * nWrite.toFloat() / length).toInt()
onWrote(percent)
}
}
}
}
}
private fun contentUriOpener(contentResolver: ContentResolver, uri: Uri, mimeType: String) =
object : InputStreamOpener() {
override val mimeType = mimeType
@Throws(IOException::class)
override fun open(): InputStream {
return contentResolver.openInputStream(uri)
?: error("openInputStream returns null")
}
override fun deleteTempFile() = Unit
}
private fun tempFileOpener(mimeType: String, file: File) =
object : InputStreamOpener() {
override val mimeType = mimeType
@Throws(IOException::class)
override fun open() = FileInputStream(file)
override fun deleteTempFile() {
file.delete()
}
}
private suspend fun createOpener(
account: SavedAccount,
uri: Uri,
mimeType: String,
mediaConfig: JsonObject? = null,
imageResizeConfig: ResizeConfig,
movieResizeConfig: MovieResizeConfig? = null,
postAttachment: PostAttachment? = null,
): InputStreamOpener {
if (mimeType == MIME_TYPE_JPEG || mimeType == MIME_TYPE_PNG) {
// 静止画(リサイズできなくてもOK)
// 静止画(失敗したらオリジナルデータにフォールバックする)
try {
return createResizedImageOpener(
uri,
@ -600,33 +588,22 @@ class AttachmentUploader(
postAttachment,
forcePng = true
)
} else {
// 動画画(リサイズできなくてもOK)
} else if (mimeType.startsWith("video/")) {
// 動画のトランスコード(失敗したらオリジナルデータにフォールバックする)
try {
return createResizedMovieOpener(
account,
uri,
mimeType,
mediaConfig = mediaConfig,
movieResizeConfig,
postAttachment,
postAttachment = postAttachment,
)
} catch (ex: Throwable) {
log.w(ex, "createResizedMovieOpener failed. fall back to original movie.")
}
}
return object : InputStreamOpener {
override val mimeType = mimeType
@Throws(IOException::class)
override fun open(): InputStream {
return context.contentResolver.openInputStream(uri)
?: error("openInputStream returns null")
}
override fun deleteTempFile() {
}
}
return contentUriOpener(context.contentResolver, uri, mimeType)
}
private fun createResizedImageOpener(
@ -662,7 +639,7 @@ class AttachmentUploader(
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os)
}
}
return createTempFileOpener(outputMimeType, tempFile)
return tempFileOpener(outputMimeType, tempFile)
} finally {
bitmap.recycle()
}
@ -670,14 +647,12 @@ class AttachmentUploader(
private suspend fun createResizedMovieOpener(
account: SavedAccount,
uri: Uri,
mimeType: String,
mediaConfig: JsonObject?,
movieResizeConfig: MovieResizeConfig?,
postAttachment: PostAttachment?,
): InputStreamOpener {
movieResizeConfig ?: error("missing movieResizeConfig.")
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
@ -693,20 +668,41 @@ class AttachmentUploader(
}
try {
val limitFileSize = mediaConfig?.long("video_size_limit")?.takeIf { it > 0L }
// 動画のメタデータを調べる
val info = tempFile.videoInfo
// サーバに指定されたファイルサイズ上限と入力動画の時間長があれば、ビットレート上限を制限する
val duration = info.duration?.takeIf { it >= 0.1f }
val limitFileSize = mediaConfig?.float("video_size_limit")?.takeIf { it >= 1f }
val limitBitrate = when {
duration != null && limitFileSize != null ->
(limitFileSize / duration).toLong()
else -> null
}
// アカウント別の動画トランスコード設定
// ビットレート、フレームレート、平方ピクセル数をサーバ指定により制限する
val movieResizeConfig = account.getMovieResizeConfig()
.restrict(
limitBitrate = limitBitrate,
limitFrameRate = mediaConfig?.int("video_frame_rate_limit")
?.takeIf { it >= 1f },
limitSquarePixels = mediaConfig?.int("video_matrix_limit")
?.takeIf { it > 1 },
)
val result = transcodeVideo(
info,
tempFile,
outFile,
movieResizeConfig,
limitFileSize
) {
val percent = (it * 100f).toInt()
postAttachment?.progress =
context.getString(R.string.attachment_handling_compress_ratio, percent)
}
resultFile = result
return createTempFileOpener(
return tempFileOpener(
when (result) {
tempFile -> mimeType
else -> "video/mp4"
@ -719,16 +715,6 @@ class AttachmentUploader(
}
}
private fun createTempFileOpener(mimeType: String, file: File) =
object : InputStreamOpener {
override val mimeType = mimeType
@Throws(IOException::class)
override fun open() = FileInputStream(file)
override fun deleteTempFile() {
file.delete()
}
}
fun getMimeType(uri: Uri, mimeTypeArg: String?): String? {
// image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い
@ -829,7 +815,7 @@ class AttachmentUploader(
}
///////////////////////////////////////////////////////////////
// 添付データのカスタムサムネイル
suspend fun uploadCustomThumbnail(
account: SavedAccount,
src: GetContentResultEntry,
@ -844,14 +830,15 @@ class AttachmentUploader(
val (ti, ri) = TootInstance.get(client)
ti ?: return@runApiTask ri
val resizeConfig = ResizeConfig(ResizeType.SquarePixel, 400)
val opener = createOpener(src.uri, mimeType, imageResizeConfig = resizeConfig)
val opener = createOpener(
account,
src.uri,
mimeType,
imageResizeConfig = ResizeConfig(ResizeType.SquarePixel, 400)
)
val mediaSizeMax = 1000000
val contentLength = getStreamSize(true, opener.open())
if (contentLength > mediaSizeMax) {
if (opener.contentLength > mediaSizeMax) {
return@runApiTask TootApiResult(
getString(R.string.file_size_too_big, mediaSizeMax / 1000000)
)
@ -886,28 +873,7 @@ class AttachmentUploader(
.addFormDataPart(
"thumbnail",
fileName,
object : RequestBody() {
override fun contentType(): MediaType {
return opener.mimeType.toMediaType()
}
@Throws(IOException::class)
override fun contentLength(): Long {
return contentLength
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
opener.open().use { inData ->
val tmp = ByteArray(4096)
while (true) {
val r = inData.read(tmp, 0, tmp.size)
if (r <= 0) break
sink.write(tmp, 0, r)
}
}
}
}
opener.toRequestBody(),
)
.build().toPut()
)

View File

@ -3,81 +3,92 @@ package jp.juggler.util
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.common.Size
import com.otaliastudios.transcoder.resize.Resizer
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import jp.juggler.util.VideoInfo.Companion.videoInfo
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import java.io.File
import java.io.FileInputStream
import kotlin.coroutines.resumeWithException
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
private val log = LogCategory("MovieUtils")
data class MovieResizeConfig(
var mode: Int = 0,
var limitFrameRate: Int = 30,
var limitBitrate: Long = 2_000_000L,
var limitPixelMatrix: Int = 2304000,
) {
enum class MovideResizeMode(val int: Int) {
Auto(0),
No(1),
Always(2),
;
companion object {
const val MODE_AUTO = 0
const val MODE_NO = 1
const val NODE_ALWAYS = 2
fun fromInt(i: Int) = values().find { it.int == i } ?: Auto
}
}
class AtMostSquarePixelResizer(private val limit: Int) : Resizer {
override fun getOutputSize(inputSize: Size): Size {
val inSquarePixel = abs(inputSize.major) * abs(inputSize.minor)
if (inSquarePixel <= limit || inputSize.major <= 0 || inputSize.minor <= 0) {
return inputSize
}
val aspect = inputSize.major.toFloat() / inputSize.minor.toFloat()
return Size(
max(1, (sqrt(limit.toFloat() * aspect) + 0.5f).toInt()),
max(1, (sqrt(limit.toFloat() / aspect) + 0.5f).toInt()),
)
data class MovieResizeConfig(
val mode: MovideResizeMode,
val limitFrameRate: Int,
val limitBitrate: Long,
val limitSquarePixels: Int,
) {
// 値を狭めた新しいオブジェクトを返す
fun restrict(
limitFrameRate: Int? = null,
limitBitrate: Long? = null,
limitSquarePixels: Int? = null,
) = MovieResizeConfig(
mode = this.mode,
limitFrameRate = min(
limitFrameRate ?: this.limitFrameRate,
this.limitFrameRate
),
limitBitrate = min(
limitBitrate ?: this.limitBitrate,
this.limitBitrate
),
limitSquarePixels = min(
limitSquarePixels ?: this.limitSquarePixels,
this.limitSquarePixels
),
)
// トランスコードをスキップする判定
fun isTranscodeRequired(info: VideoInfo) = when (mode) {
MovideResizeMode.No -> false
MovideResizeMode.Always -> true
MovideResizeMode.Auto ->
info.squarePixels > limitSquarePixels ||
(info.actualBps ?: 0).toFloat() > limitBitrate.toFloat() * 1.5f ||
(info.frameRatio?.toInt() ?: 0) > limitFrameRate
}
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun transcodeVideo(
info: VideoInfo,
inFile: File,
outFile: File,
resizeConfig: MovieResizeConfig,
limitFileSize: Long?,
onProgress: (Float) -> Unit,
): File = try {
withContext(Dispatchers.IO) {
val info = inFile.videoInfo
// サーバに指定された上限ファイルサイズと
// 入力動画の時間帳があれば
// ビットレート制限を更新できる
limitFileSize?.takeIf { it > 0L }?.let { size ->
info.duration?.takeIf { it > 0f }?.let { duration ->
(size.toFloat() / duration).toLong()
}
}?.let { limitBps ->
resizeConfig.limitBitrate = min(resizeConfig.limitBitrate, limitBps)
if (!resizeConfig.isTranscodeRequired(info)) {
log.i("transcodeVideo: isTranscodeRequired returns false.")
return@withContext inFile
}
when (resizeConfig.mode) {
MovieResizeConfig.MODE_NO ->
return@withContext inFile
MovieResizeConfig.MODE_AUTO -> {
if (info.size.w * info.size.h <= resizeConfig.limitPixelMatrix &&
MovideResizeMode.No -> return@withContext inFile
MovideResizeMode.Always -> Unit
MovideResizeMode.Auto -> {
if (info.squarePixels <= resizeConfig.limitSquarePixels &&
(info.actualBps ?: 0).toFloat() <= resizeConfig.limitBitrate * 1.5f &&
(info.frameRatio?.toInt() ?: 0) <= resizeConfig.limitFrameRate
) {
log.i("transcodeVideo skip.")
log.i("transcodeVideo: no need to transcode.")
return@withContext inFile
}
}
@ -107,16 +118,26 @@ suspend fun transcodeVideo(
// ワークアラウンドとしてファイルではなくfdを渡す
val future = Transcoder.into(outFile.canonicalPath)
.addDataSource(inStream.fd)
.setVideoTrackStrategy(
DefaultVideoStrategy.Builder()
.addResizer(
AtMostSquarePixelResizer(resizeConfig.limitPixelMatrix)
)
.frameRate(resizeConfig.limitFrameRate)
.keyFrameInterval(10f)
.bitRate(resizeConfig.limitBitrate)
.build()
)
.setVideoTrackStrategy(DefaultVideoStrategy.Builder()
.addResizer { inSize ->
val squarePixels = inSize.major * inSize.minor
val limit = resizeConfig.limitSquarePixels
if (squarePixels <= limit || inSize.major <= 0 || inSize.minor <= 0) {
// 入力サイズが0以下の場合もアスペクト計算に支障がでるのでリサイズできない
inSize
} else {
// アスペクト比を維持しつつ平方ピクセルが指定に収まるようにする
val aspect = inSize.major.toFloat() / inSize.minor.toFloat()
Size(
max(1, (sqrt(limit.toFloat() * aspect) + 0.5f).toInt()),
max(1, (sqrt(limit.toFloat() / aspect) + 0.5f).toInt()),
)
}
}
.frameRate(resizeConfig.limitFrameRate)
.keyFrameInterval(10f)
.bitRate(resizeConfig.limitBitrate)
.build())
.setAudioTrackStrategy(
DefaultAudioStrategy.Builder()
.channels(2)

View File

@ -5,6 +5,7 @@ import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.os.Build
import java.io.File
import kotlin.math.max
import kotlin.math.min
/**
@ -126,6 +127,8 @@ class VideoInfo(
mmr.int(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) ?: 0,
)
val squarePixels: Int get() = max(1, size.w) * max(1, size.h)
val bitrate = mmr.int(MediaMetadataRetriever.METADATA_KEY_BITRATE)
val duration = mmr.long(MediaMetadataRetriever.METADATA_KEY_DURATION)

View File

@ -23,10 +23,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingBottom="320dp"
android:paddingEnd="12dp"
android:paddingBottom="320dp">
android:paddingStart="12dp"
android:paddingTop="12dp">
<LinearLayout
android:id="@+id/llReply"
@ -101,15 +101,15 @@
android:layout_weight="1"
android:background="@drawable/btn_bg_transparent_round6dp"
android:gravity="center_vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingStart="8dp"
android:textAllCaps="false" />
</LinearLayout>
<LinearLayout
android:id="@+id/llAttachment"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:baselineAligned="false"
android:gravity="top|start"
@ -121,7 +121,8 @@
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
android:scaleType="fitCenter"
tools:src="@drawable/ic_videocam" />
<jp.juggler.subwaytooter.view.MyNetworkImageView
android:id="@+id/ivMedia2"
@ -129,7 +130,8 @@
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
android:scaleType="fitCenter"
tools:src="@drawable/ic_videocam" />
<jp.juggler.subwaytooter.view.MyNetworkImageView
android:id="@+id/ivMedia3"
@ -137,7 +139,8 @@
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
android:scaleType="fitCenter"
tools:src="@drawable/ic_videocam" />
<jp.juggler.subwaytooter.view.MyNetworkImageView
android:id="@+id/ivMedia4"
@ -145,17 +148,18 @@
android:layout_height="48dp"
android:layout_marginEnd="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:scaleType="fitCenter" />
android:scaleType="fitCenter"
tools:src="@drawable/ic_videocam" />
<TextView
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:id="@+id/tvAttachmentProgress"
android:visibility="gone"
tools:visibility="visible"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="11sp"
tools:text="アップロード中です"
/>
android:visibility="gone"
tools:text="アップロード中です\nアップロード中です\nアップロード中です\nアップロード中です\nアップロード中です"
tools:visibility="visible" />
</LinearLayout>
<com.google.android.flexbox.FlexboxLayout
@ -433,8 +437,8 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:text="@string/plus" />
<EditText
@ -454,8 +458,8 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:layout_marginStart="4dp"
android:text="@string/plus" />
<EditText
@ -503,8 +507,8 @@
android:layout_marginStart="4dp"
android:background="@drawable/btn_bg_transparent_round6dp"
android:contentDescription="@string/visibility"
android:minWidth="48dp"
android:minHeight="48dp"
android:minWidth="48dp"
app:tint="?attr/colorVectorDrawable"
tools:src="@drawable/ic_public" />
@ -526,9 +530,7 @@
android:background="@drawable/btn_bg_transparent_round6dp"
android:contentDescription="@string/more"
android:src="@drawable/ic_more"
app:tint="?attr/colorVectorDrawable"
/>
app:tint="?attr/colorVectorDrawable" />
<View
android:layout_width="0dp"
@ -555,6 +557,6 @@
android:src="@drawable/ic_send"
app:tint="?attr/colorVectorDrawable"
/>
tools:ignore="DuplicateSpeakableTextCheck" />
</LinearLayout>
</jp.juggler.subwaytooter.actpost.ActPostRootLinearLayout>

View File

@ -1113,7 +1113,7 @@
<string name="attachment_handling_compress_ratio">圧縮中 %1$d%%…</string>
<string name="attachment_handling_uploading">アップロード中 %1$d%%…</string>
<string name="attachment_handling_waiting">応答待ち…</string>
<string name="attachment_handling_waiting2">応答待ち(非同期)…</string>
<string name="attachment_handling_waiting_async">応答待ち(非同期)…</string>
<string name="option_deprecated_mastodon342">(Mastodon 3.4.2以降では指定した値ではなく、サーバから提供される情報が使われます)</string>
<string name="movie_transcode">動画の再圧縮</string>
<string name="movie_transcode_mode">モード</string>

View File

@ -1124,7 +1124,7 @@
<string name="attachment_handling_compress_ratio">Compressing %1$d%%…</string>
<string name="attachment_handling_uploading">Uploading %1$d%%…</string>
<string name="attachment_handling_waiting">Waiting response…</string>
<string name="attachment_handling_waiting2">Waiting response(asynchronized)…</string>
<string name="attachment_handling_waiting_async">Waiting response(asynchronized)…</string>
<string name="option_deprecated_mastodon342">(This option is deprecated for Mastodon 3.4.2+. App uses information from the server.)</string>
<string name="movie_transcode">Movie transcoding</string>
<string name="movie_transcode_mode">Mode</string>