feat: support audio shares and ingest enqueue endpoint
This commit is contained in:
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "pro.relaytv"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 3
|
||||
versionName = "1.2.0"
|
||||
versionCode = 4
|
||||
versionName = "1.2.2"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
@@ -81,6 +82,7 @@
|
||||
<action android:name="android.intent.action.SEND"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
<data android:mimeType="audio/*"/>
|
||||
<data android:mimeType="video/*"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.work.Data
|
||||
@@ -18,18 +19,37 @@ import java.util.UUID
|
||||
class ShareActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayTVShare"
|
||||
private const val META_ENDPOINT_PATH = "pro.relaytv.SHARE_ENDPOINT_PATH"
|
||||
private const val META_SUCCESS_TEMPLATE = "pro.relaytv.SHARE_SUCCESS_TEMPLATE"
|
||||
private val SUPPORTED_VIDEO_MIME_TYPES = setOf("video/mp4", "video/webm")
|
||||
private val SUPPORTED_MEDIA_MIME_TYPES = setOf(
|
||||
"application/octet-stream",
|
||||
"application/ogg",
|
||||
"audio/aac",
|
||||
"audio/flac",
|
||||
"audio/m4a",
|
||||
"audio/mpeg",
|
||||
"audio/mp4",
|
||||
"audio/ogg",
|
||||
"audio/opus",
|
||||
"audio/wav",
|
||||
"audio/wave",
|
||||
"audio/x-aac",
|
||||
"audio/x-flac",
|
||||
"audio/x-m4a",
|
||||
"audio/x-wav",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
)
|
||||
}
|
||||
|
||||
private sealed class SharePayload {
|
||||
data class RemoteUrl(val url: String) : SharePayload()
|
||||
data class LocalVideo(val filePath: String, val mimeType: String, val title: String) : SharePayload()
|
||||
data class LocalMedia(val filePath: String, val mimeType: String, val title: String) : SharePayload()
|
||||
}
|
||||
|
||||
private data class LocalVideoStageResult(
|
||||
val payload: SharePayload.LocalVideo? = null,
|
||||
private data class LocalMediaStageResult(
|
||||
val payload: SharePayload.LocalMedia? = null,
|
||||
val errorMessageResId: Int? = null,
|
||||
)
|
||||
|
||||
@@ -52,7 +72,7 @@ class ShareActivity : AppCompatActivity() {
|
||||
val streamUri = extractStreamUri()
|
||||
if (streamUri != null) {
|
||||
Thread {
|
||||
val result = stageLocalVideo(streamUri)
|
||||
val result = stageLocalMedia(streamUri)
|
||||
runOnUiThread {
|
||||
if (result.payload != null) {
|
||||
dispatchPayload(base, endpointPath, successTemplate, result.payload)
|
||||
@@ -82,11 +102,11 @@ class ShareActivity : AppCompatActivity() {
|
||||
|
||||
val toastTemplate = when (payload) {
|
||||
is SharePayload.RemoteUrl -> successTemplate
|
||||
is SharePayload.LocalVideo -> {
|
||||
is SharePayload.LocalMedia -> {
|
||||
if (endpointPath == "/play_now") {
|
||||
getString(R.string.share_video_play_started)
|
||||
getString(R.string.share_media_play_started)
|
||||
} else {
|
||||
getString(R.string.share_video_queue_started)
|
||||
getString(R.string.share_media_queue_started)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +115,7 @@ class ShareActivity : AppCompatActivity() {
|
||||
is SharePayload.RemoteUrl -> {
|
||||
inputBuilder.putString(ShareWorker.KEY_URL, payload.url)
|
||||
}
|
||||
is SharePayload.LocalVideo -> {
|
||||
is SharePayload.LocalMedia -> {
|
||||
inputBuilder
|
||||
.putString(ShareWorker.KEY_UPLOAD_FILE_PATH, payload.filePath)
|
||||
.putString(ShareWorker.KEY_UPLOAD_MIME_TYPE, payload.mimeType)
|
||||
@@ -139,21 +159,20 @@ class ShareActivity : AppCompatActivity() {
|
||||
return extra
|
||||
}
|
||||
|
||||
private fun stageLocalVideo(uri: Uri): LocalVideoStageResult {
|
||||
val mimeType = contentResolver.getType(uri)
|
||||
private fun stageLocalMedia(uri: Uri): LocalMediaStageResult {
|
||||
val rawMimeType = contentResolver.getType(uri)
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?: ""
|
||||
val displayName = queryDisplayName(uri)
|
||||
val inferredMimeType = when {
|
||||
mimeType.isNotBlank() -> mimeType
|
||||
displayName.endsWith(".mp4", ignoreCase = true) || displayName.endsWith(".m4v", ignoreCase = true) -> "video/mp4"
|
||||
displayName.endsWith(".webm", ignoreCase = true) -> "video/webm"
|
||||
else -> ""
|
||||
}
|
||||
if (inferredMimeType !in SUPPORTED_VIDEO_MIME_TYPES) {
|
||||
return LocalVideoStageResult(errorMessageResId = R.string.share_video_unsupported)
|
||||
val inferredMimeType = normalizeSharedMimeType(rawMimeType, displayName)
|
||||
Log.i(
|
||||
TAG,
|
||||
"stageLocalMedia uri=$uri rawMime=$rawMimeType inferredMime=$inferredMimeType displayName=$displayName"
|
||||
)
|
||||
if (inferredMimeType !in SUPPORTED_MEDIA_MIME_TYPES) {
|
||||
return LocalMediaStageResult(errorMessageResId = R.string.share_media_unsupported)
|
||||
}
|
||||
|
||||
val staged = runCatching {
|
||||
@@ -161,12 +180,12 @@ class ShareActivity : AppCompatActivity() {
|
||||
}.getOrNull()
|
||||
|
||||
if (staged == null) {
|
||||
return LocalVideoStageResult(errorMessageResId = R.string.share_video_read_failed)
|
||||
return LocalMediaStageResult(errorMessageResId = R.string.share_media_read_failed)
|
||||
}
|
||||
|
||||
val title = staged.nameWithoutExtension.ifBlank { displayName.substringBeforeLast('.', displayName) }
|
||||
return LocalVideoStageResult(
|
||||
payload = SharePayload.LocalVideo(
|
||||
return LocalMediaStageResult(
|
||||
payload = SharePayload.LocalMedia(
|
||||
filePath = staged.absolutePath,
|
||||
mimeType = inferredMimeType,
|
||||
title = title
|
||||
@@ -179,6 +198,13 @@ class ShareActivity : AppCompatActivity() {
|
||||
val dir = File(cacheDir, "shared-media").apply { mkdirs() }
|
||||
val ext = when {
|
||||
displayName.contains('.') -> ".${displayName.substringAfterLast('.')}"
|
||||
mimeType == "audio/mpeg" -> ".mp3"
|
||||
mimeType == "audio/mp4" || mimeType == "audio/m4a" || mimeType == "audio/x-m4a" -> ".m4a"
|
||||
mimeType == "audio/aac" || mimeType == "audio/x-aac" -> ".aac"
|
||||
mimeType == "audio/wav" || mimeType == "audio/wave" || mimeType == "audio/x-wav" -> ".wav"
|
||||
mimeType == "audio/flac" || mimeType == "audio/x-flac" -> ".flac"
|
||||
mimeType == "audio/ogg" || mimeType == "application/ogg" -> ".ogg"
|
||||
mimeType == "audio/opus" -> ".opus"
|
||||
mimeType == "video/webm" -> ".webm"
|
||||
else -> ".mp4"
|
||||
}
|
||||
@@ -210,6 +236,44 @@ class ShareActivity : AppCompatActivity() {
|
||||
return if (cleaned.isBlank()) "relaytv-upload" else cleaned.take(96)
|
||||
}
|
||||
|
||||
private fun normalizeSharedMimeType(rawMimeType: String, displayName: String): String {
|
||||
val byExtension = when {
|
||||
displayName.endsWith(".mp3", ignoreCase = true) -> "audio/mpeg"
|
||||
displayName.endsWith(".m4a", ignoreCase = true) -> "audio/m4a"
|
||||
displayName.endsWith(".aac", ignoreCase = true) -> "audio/aac"
|
||||
displayName.endsWith(".wav", ignoreCase = true) -> "audio/wav"
|
||||
displayName.endsWith(".flac", ignoreCase = true) -> "audio/flac"
|
||||
displayName.endsWith(".ogg", ignoreCase = true) -> "application/ogg"
|
||||
displayName.endsWith(".opus", ignoreCase = true) -> "audio/opus"
|
||||
else -> ""
|
||||
}
|
||||
if (byExtension.isNotBlank()) return byExtension
|
||||
|
||||
val byMime = when (rawMimeType) {
|
||||
"application/octet-stream" -> "application/octet-stream"
|
||||
"application/ogg" -> "application/ogg"
|
||||
"audio/aac", "audio/x-aac" -> "audio/aac"
|
||||
"audio/flac", "audio/x-flac" -> "audio/flac"
|
||||
"audio/m4a" -> "audio/m4a"
|
||||
"audio/mpeg", "audio/mp3", "audio/x-mp3" -> "audio/mpeg"
|
||||
"audio/mp4" -> "audio/mp4"
|
||||
"audio/ogg" -> "audio/ogg"
|
||||
"audio/opus" -> "audio/opus"
|
||||
"audio/wav", "audio/wave", "audio/x-wav" -> "audio/wav"
|
||||
"audio/x-m4a" -> "audio/x-m4a"
|
||||
"video/mp4" -> "video/mp4"
|
||||
"video/webm" -> "video/webm"
|
||||
else -> ""
|
||||
}
|
||||
if (byMime.isNotBlank()) return byMime
|
||||
|
||||
return when {
|
||||
displayName.endsWith(".mp4", ignoreCase = true) || displayName.endsWith(".m4v", ignoreCase = true) -> "video/mp4"
|
||||
displayName.endsWith(".webm", ignoreCase = true) -> "video/webm"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneStagedFiles() {
|
||||
val dir = File(cacheDir, "shared-media")
|
||||
if (!dir.exists()) return
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
@@ -19,6 +20,7 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
|
||||
) : Exception(message)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayTVShare"
|
||||
const val KEY_BASE = "base"
|
||||
const val KEY_URL = "url"
|
||||
const val KEY_ENDPOINT_PATH = "endpoint_path"
|
||||
@@ -40,11 +42,22 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
|
||||
?: "item"
|
||||
|
||||
return try {
|
||||
val url = when {
|
||||
localUpload != null -> uploadMedia(base, localUpload)
|
||||
else -> inputData.getString(KEY_URL) ?: return Result.failure()
|
||||
if (localUpload != null && endpointPath == "/play_now") {
|
||||
val playResult = uploadAndPlayMedia(base, localUpload)
|
||||
postNotification(playResult.title, displayText, tapToOpen = true)
|
||||
localUpload.delete()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (localUpload != null) {
|
||||
val enqueueResult = uploadAndEnqueueMedia(base, localUpload)
|
||||
postNotification(enqueueResult.title, displayText, tapToOpen = true)
|
||||
localUpload.delete()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val url = inputData.getString(KEY_URL) ?: return Result.failure()
|
||||
|
||||
val payload = JSONObject().put("url", url).toString()
|
||||
val req = Net.postJson(base + endpointPath, payload)
|
||||
|
||||
@@ -91,14 +104,24 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadMedia(base: String, file: File): String {
|
||||
private data class UploadPlayResult(
|
||||
val title: String,
|
||||
val playbackMode: String,
|
||||
)
|
||||
|
||||
private data class UploadEnqueueResult(
|
||||
val title: String,
|
||||
)
|
||||
|
||||
private fun uploadAndEnqueueMedia(base: String, file: File): UploadEnqueueResult {
|
||||
if (!file.exists() || !file.isFile) {
|
||||
throw ShareFailure("Shared media file is no longer available", retryable = false)
|
||||
}
|
||||
val mimeType = inputData.getString(KEY_UPLOAD_MIME_TYPE)?.trim().orEmpty()
|
||||
val title = inputData.getString(KEY_UPLOAD_TITLE)?.trim().orEmpty()
|
||||
Log.i(TAG, "uploadAndEnqueueMedia file=${file.name} size=${file.length()} mime=$mimeType title=$title")
|
||||
val req = Net.postMultipartFile(
|
||||
url = "$base/ingest/media",
|
||||
url = "$base/ingest/media/enqueue",
|
||||
file = file,
|
||||
mimeType = mimeType.ifBlank { null },
|
||||
title = title.ifBlank { null },
|
||||
@@ -106,15 +129,53 @@ class ShareWorker(appContext: Context, params: WorkerParameters) : Worker(appCon
|
||||
Net.uploadClient.newCall(req).execute().use { resp ->
|
||||
val body = resp.body?.string().orEmpty()
|
||||
if (!resp.isSuccessful) {
|
||||
val detail = extractDetail(body) ?: "Upload failed"
|
||||
Log.w(TAG, "uploadAndEnqueueMedia failed code=${resp.code} body=${body.take(300)}")
|
||||
val detail = extractDetail(body) ?: "Upload enqueue failed"
|
||||
throw ShareFailure(detail, retryable = shouldRetry(resp.code))
|
||||
}
|
||||
Log.i(TAG, "uploadAndEnqueueMedia success code=${resp.code} body=${body.take(300)}")
|
||||
val json = JSONObject(body)
|
||||
val uploadedUrl = json.optString("url").trim()
|
||||
if (uploadedUrl.isBlank()) {
|
||||
throw ShareFailure("Upload completed without a playable URL", retryable = false)
|
||||
val result = json.optJSONObject("result")
|
||||
val titleText = when (result?.optString("status")) {
|
||||
"queued" -> "Enqueued"
|
||||
else -> "Enqueued"
|
||||
}
|
||||
return uploadedUrl
|
||||
return UploadEnqueueResult(title = titleText)
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadAndPlayMedia(base: String, file: File): UploadPlayResult {
|
||||
if (!file.exists() || !file.isFile) {
|
||||
throw ShareFailure("Shared media file is no longer available", retryable = false)
|
||||
}
|
||||
val mimeType = inputData.getString(KEY_UPLOAD_MIME_TYPE)?.trim().orEmpty()
|
||||
val title = inputData.getString(KEY_UPLOAD_TITLE)?.trim().orEmpty()
|
||||
Log.i(TAG, "uploadAndPlayMedia file=${file.name} size=${file.length()} mime=$mimeType title=$title")
|
||||
val req = Net.postMultipartFile(
|
||||
url = "$base/ingest/media/play",
|
||||
file = file,
|
||||
mimeType = mimeType.ifBlank { null },
|
||||
title = title.ifBlank { null },
|
||||
)
|
||||
Net.uploadClient.newCall(req).execute().use { resp ->
|
||||
val body = resp.body?.string().orEmpty()
|
||||
if (!resp.isSuccessful) {
|
||||
Log.w(TAG, "uploadAndPlayMedia failed code=${resp.code} body=${body.take(300)}")
|
||||
val detail = extractDetail(body) ?: "Upload playback failed"
|
||||
throw ShareFailure(detail, retryable = shouldRetry(resp.code))
|
||||
}
|
||||
Log.i(TAG, "uploadAndPlayMedia success code=${resp.code} body=${body.take(300)}")
|
||||
val json = JSONObject(body)
|
||||
val playbackMode = json.optString("playback_mode").trim()
|
||||
val message = when (playbackMode) {
|
||||
"progressive" -> "Playing now"
|
||||
"full_upload" -> "Playing now"
|
||||
else -> "Playing now"
|
||||
}
|
||||
return UploadPlayResult(
|
||||
title = message,
|
||||
playbackMode = playbackMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
<string name="share_play_now_label">RelayTV Play</string>
|
||||
<string name="share_queue_toast">Queued on "%s"</string>
|
||||
<string name="share_play_now_toast">Playing now on "%s"</string>
|
||||
<string name="share_video_queue_started">Uploading video to "%s"</string>
|
||||
<string name="share_video_play_started">Uploading video for playback on "%s"</string>
|
||||
<string name="share_video_unsupported">Unsupported shared video. RelayTV currently accepts MP4 or WebM uploads.</string>
|
||||
<string name="share_video_read_failed">Couldn\'t read the shared video from this app.</string>
|
||||
<string name="share_media_queue_started">Uploading media to "%s"</string>
|
||||
<string name="share_media_play_started">Uploading media for playback on "%s"</string>
|
||||
<string name="share_media_unsupported">Unsupported shared media. RelayTV currently accepts MP3, M4A, AAC, WAV, FLAC, OGG, OPUS, MP4, or WebM uploads.</string>
|
||||
<string name="share_media_read_failed">Couldn\'t read the shared media from this app.</string>
|
||||
<string name="privacy_policy">Privacy policy</string>
|
||||
<string name="privacy_policy_url">https://github.com/mcgeezy/relaytv-android/blob/main/docs/PRIVACY_POLICY.md</string>
|
||||
<string name="privacy_open_failed">No browser available to open the privacy policy.</string>
|
||||
|
||||
Reference in New Issue
Block a user