Compare commits

...
3 Commits
12 changed files with 123 additions and 31 deletions
+7
View File
@@ -16,5 +16,12 @@
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<activity
android:name="dev.zoriya.omni.OmniCastTapActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.NoDisplay" />
</application>
</manifest>
@@ -4,6 +4,8 @@ import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.media.MediaIntentReceiver
import com.google.android.gms.cast.framework.media.NotificationOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
@@ -12,9 +14,23 @@ class OmniCastOptionsProvider : OptionsProvider {
val appId = OmniPlayer.receiverApplicationId
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
val notificationOptions = NotificationOptions.Builder()
.setActions(
listOf(
MediaIntentReceiver.ACTION_SKIP_PREV,
MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK,
MediaIntentReceiver.ACTION_SKIP_NEXT,
),
intArrayOf(0, 1, 2),
)
.setSmallIconDrawableResId(
androidx.media3.session.R.drawable.media3_notification_small_icon
)
.setTargetActivityClassName(OmniCastTapActivity::class.java.name)
.build()
val mediaOptions = CastMediaOptions.Builder()
.setMediaSessionEnabled(false)
.setNotificationOptions(null)
.setNotificationOptions(notificationOptions)
.build()
return CastOptions.Builder()
@@ -0,0 +1,30 @@
package dev.zoriya.omni
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri
class OmniCastTapActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val url = getSharedPreferences(OmniPlayer.OMNI_PREFS, Context.MODE_PRIVATE)
.getString(OmniPlayer.NOTIFICATION_URL_PREF, null)
val intent = url
?.let { Intent(Intent.ACTION_VIEW, it.toUri()).setPackage(packageName) }
?: packageManager.getLaunchIntentForPackage(packageName)
try {
startActivity(
intent?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
} catch (e: Throwable) {
android.util.Log.w("OmniPlayer", "could not open $url", e)
}
finish()
}
}
@@ -42,7 +42,6 @@ import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.cast.CastPlayer
import androidx.media3.cast.RemoteCastPlayer
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.mediarouter.app.MediaRouteChooserDialog
@@ -105,6 +104,7 @@ class OmniPlayer(
private val castStateListener = CastStateListener {
eventMap.emitCastStatus(computeCastStatus())
listenToReceiver()
syncNotificationService()
}
// receivers have no queue to skip in (a queue would make them play things on
@@ -128,6 +128,12 @@ class OmniPlayer(
val player: Player = runOnMainThreadSync {
castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
// the cast notification outlives the app, so OmniCastTapActivity has to find the
// url back even when nothing of ours ran in that process yet.
ctx.getSharedPreferences(OMNI_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(NOTIFICATION_URL_PREF, castOptions?.notificationUrl)
.apply()
val cc = try {
CastContext.getSharedInstance(ctx)
} catch (_: Throwable) {
@@ -166,7 +172,8 @@ class OmniPlayer(
private var serviceRunning = false
private fun syncNotificationService() {
val shouldShow = showNotification == true && source != null
val shouldShow = showNotification == true && source != null &&
!runOnMainThreadSync { isCasting }
when {
shouldShow && !serviceRunning -> {
val otherIsPlaying = notificationPlayer?.let { other ->
@@ -232,7 +239,7 @@ class OmniPlayer(
runOnMainThread {
eventMap.dispose()
castContext?.removeCastStateListener(castStateListener)
player.release()
if (!isCasting) player.release()
}
}
@@ -647,6 +654,10 @@ class OmniPlayer(
// Receiver application id passed at runtime via OmniProvider's `cast`
// prop; read by OmniCastOptionsProvider when the Cast SDK initializes.
var receiverApplicationId: String? = null
// `cast.notificationUrl`, read back by OmniCastTapActivity.
const val OMNI_PREFS = "dev.zoriya.omni"
const val NOTIFICATION_URL_PREF = "notificationUrl"
}
}
@@ -681,10 +692,6 @@ class OmniPlayerService : MediaSessionService() {
}
.build()
setMediaNotificationProvider(DefaultMediaNotificationProvider.Builder(this).build().apply {
setSmallIcon(applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.ic_media_play)
})
addSession(mediaSession)
setShowNotificationForIdlePlayer(SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS)
@@ -725,8 +732,7 @@ class OmniPlayerService : MediaSessionService() {
)
return NotificationCompat.Builder(this, "omni_playback")
.setSmallIcon(applicationInfo.icon.takeIf { it != 0 }
?: android.R.drawable.ic_media_play)
.setSmallIcon(androidx.media3.session.R.drawable.media3_notification_small_icon)
.setContentTitle("Omni Player")
.setContentText("Preparing playback...")
.setContentIntent(pendingIntent)
@@ -737,7 +743,14 @@ class OmniPlayerService : MediaSessionService() {
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
override fun onTaskRemoved(rootIntent: Intent?) {
pauseAllPlayersAndStopSelf()
val casting = try {
CastContext.getSharedInstance(this)
.sessionManager.currentCastSession?.isConnected == true
} catch (_: Throwable) {
false
}
// keep service open if casting
if (!casting) pauseAllPlayersAndStopSelf()
}
override fun onDestroy() {
+4 -1
View File
@@ -511,7 +511,10 @@ function App(): React.JSX.Element {
<OmniProvider
source={hasSource ? source : undefined}
backend={{ android: backend }}
cast={{ receiverApplicationId: "D8FB0FC1" }}
cast={{
receiverApplicationId: "D8FB0FC1",
notificationUrl: "omniexample://remote",
}}
showNotification
>
<PlayerExample
@@ -25,6 +25,12 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="omniexample" />
</intent-filter>
</activity>
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
+1
View File
@@ -66,6 +66,7 @@ module.exports = {
port: 3000,
hot: true,
historyApiFallback: true,
allowedHosts: "all",
// jassub's wasm is multithreaded (pthreads), which needs SharedArrayBuffer
// and therefore a cross-origin-isolated page. `credentialless` keeps
// cross-origin video/subtitle/font requests working without CORP headers.
+7 -3
View File
@@ -34,8 +34,11 @@ namespace margelo::nitro::omni {
static const auto clazz = javaClassStatic();
static const auto fieldReceiverApplicationId = clazz->getField<jni::JString>("receiverApplicationId");
jni::local_ref<jni::JString> receiverApplicationId = this->getFieldValue(fieldReceiverApplicationId);
static const auto fieldNotificationUrl = clazz->getField<jni::JString>("notificationUrl");
jni::local_ref<jni::JString> notificationUrl = this->getFieldValue(fieldNotificationUrl);
return CastOptions(
receiverApplicationId != nullptr ? std::make_optional(receiverApplicationId->toStdString()) : std::nullopt
receiverApplicationId != nullptr ? std::make_optional(receiverApplicationId->toStdString()) : std::nullopt,
notificationUrl != nullptr ? std::make_optional(notificationUrl->toStdString()) : std::nullopt
);
}
@@ -45,12 +48,13 @@ namespace margelo::nitro::omni {
*/
[[maybe_unused]]
static jni::local_ref<JCastOptions::javaobject> fromCpp(const CastOptions& value) {
using JSignature = JCastOptions(jni::alias_ref<jni::JString>);
using JSignature = JCastOptions(jni::alias_ref<jni::JString>, jni::alias_ref<jni::JString>);
static const auto clazz = javaClassStatic();
static const auto create = clazz->getStaticMethod<JSignature>("fromCpp");
return create(
clazz,
value.receiverApplicationId.has_value() ? jni::make_jstring(value.receiverApplicationId.value()) : nullptr
value.receiverApplicationId.has_value() ? jni::make_jstring(value.receiverApplicationId.value()) : nullptr,
value.notificationUrl.has_value() ? jni::make_jstring(value.notificationUrl.value()) : nullptr
);
}
};
@@ -20,7 +20,10 @@ import java.util.Objects
data class CastOptions(
@DoNotStrip
@Keep
val receiverApplicationId: String?
val receiverApplicationId: String?,
@DoNotStrip
@Keep
val notificationUrl: String?
) {
/* primary constructor */
@@ -28,11 +31,13 @@ data class CastOptions(
if (this === other) return true
if (other !is CastOptions) return false
return Objects.deepEquals(this.receiverApplicationId, other.receiverApplicationId)
&& Objects.deepEquals(this.notificationUrl, other.notificationUrl)
}
override fun hashCode(): Int {
return arrayOf<Any?>(
receiverApplicationId
receiverApplicationId,
notificationUrl
).contentDeepHashCode()
}
@@ -44,8 +49,8 @@ data class CastOptions(
@Keep
@Suppress("unused")
@JvmStatic
private fun fromCpp(receiverApplicationId: String?): CastOptions {
return CastOptions(receiverApplicationId)
private fun fromCpp(receiverApplicationId: String?, notificationUrl: String?): CastOptions {
return CastOptions(receiverApplicationId, notificationUrl)
}
}
}
+6 -2
View File
@@ -41,10 +41,11 @@ namespace margelo::nitro::omni {
struct CastOptions final {
public:
std::optional<std::string> receiverApplicationId SWIFT_PRIVATE;
std::optional<std::string> notificationUrl SWIFT_PRIVATE;
public:
CastOptions() = default;
explicit CastOptions(std::optional<std::string> receiverApplicationId): receiverApplicationId(receiverApplicationId) {}
explicit CastOptions(std::optional<std::string> receiverApplicationId, std::optional<std::string> notificationUrl): receiverApplicationId(receiverApplicationId), notificationUrl(notificationUrl) {}
public:
friend bool operator==(const CastOptions& lhs, const CastOptions& rhs) = default;
@@ -60,12 +61,14 @@ namespace margelo::nitro {
static inline margelo::nitro::omni::CastOptions fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) {
jsi::Object obj = arg.asObject(runtime);
return margelo::nitro::omni::CastOptions(
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId")))
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId"))),
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "notificationUrl")))
);
}
static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::omni::CastOptions& arg) {
jsi::Object obj(runtime);
obj.setProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId"), JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.receiverApplicationId));
obj.setProperty(runtime, PropNameIDCache::get(runtime, "notificationUrl"), JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.notificationUrl));
return obj;
}
static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) {
@@ -77,6 +80,7 @@ namespace margelo::nitro {
return false;
}
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId")))) return false;
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "notificationUrl")))) return false;
return true;
}
};
+2
View File
@@ -16,6 +16,8 @@ export interface Source {
export interface CastOptions {
receiverApplicationId?: string;
// deep link opened when the cast notification is tapped (android only)
notificationUrl?: string;
}
export interface VideoSrc {
+10 -9
View File
@@ -3,10 +3,10 @@ import { HlsJsVideo } from "@videojs/react/media/hlsjs-video";
import { Video } from "@videojs/react/video";
import {
type CSSProperties,
type RefObject,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { usePlayerState } from "./events";
@@ -20,7 +20,9 @@ const SubtitleOverlay = ({
assets,
fonts,
}: {
video: RefObject<HTMLVideoElement>;
// not a ref: the overlay is re-created when the element is swapped (which
// happens when the tech changes, ie. going from/to hls)
video: HTMLVideoElement | null;
assets?: SubtitleAssets;
fonts?: string[];
}) => {
@@ -36,8 +38,7 @@ const SubtitleOverlay = ({
fontsRef.current = fonts;
useEffect(() => {
const el = video.current;
if (!el || !subtitle) return;
if (!video || !subtitle) return;
let renderer: { destroy(): void } | null = null;
let cancelled = false;
const attach = (created: { destroy(): void }) => {
@@ -58,7 +59,7 @@ const SubtitleOverlay = ({
import("jassub")
.then(({ default: JASSUB }) => {
const instance = new JASSUB({
video: el,
video,
subUrl: subtitle.link,
...(videoWidth && { videoWidth }),
...(videoHeight && { videoHeight }),
@@ -81,7 +82,7 @@ const SubtitleOverlay = ({
import("libpgs")
.then(({ PgsRenderer }) => {
const instance = new PgsRenderer({
video: el,
video,
subUrl: subtitle.link,
workerUrl:
pgs?.workerUrl ??
@@ -107,7 +108,7 @@ export const OmniView = ({
subtitleAssets,
}: OmniViewProps & { style: CSSProperties }) => {
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLVideoElement>(undefined!);
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
const source = usePlayerState("source");
const isHls =
@@ -146,7 +147,7 @@ export const OmniView = ({
style={{ position: "relative", ...style }}
>
<Tech
ref={ref}
ref={setVideo}
src={source && startTime ? `${source.src.uri}#t=${startTime}` : source?.src.uri}
config={config}
autoPlay={autoplay}
@@ -167,7 +168,7 @@ export const OmniView = ({
</Tech>
{castStatus !== "connected" && castStatus !== "connecting" && (
<SubtitleOverlay
video={ref}
video={video}
assets={subtitleAssets}
fonts={source?.fonts}
/>