feat: support audio shares and ingest enqueue endpoint

This commit is contained in:
2026-04-10 21:59:23 -05:00
parent d8e40bf847
commit 0fdc2508d3
5 changed files with 165 additions and 38 deletions
+2 -2
View File
@@ -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 {
+2
View File
@@ -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>
+86 -22
View File
@@ -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
+71 -10
View File
@@ -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,
)
}
}
+4 -4
View File
@@ -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>