Compare commits

..
8 Commits
23 changed files with 155 additions and 135 deletions
@@ -15,6 +15,8 @@ import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.Timeline
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import com.google.android.gms.cast.MediaStatus
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.margelo.nitro.omni.BoolProperty
import com.margelo.nitro.omni.CastStatus
import com.margelo.nitro.omni.HybridOmniEventMapSpec
@@ -103,6 +105,8 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
onTracksChanged(value.currentTracks)
}
var remote: RemoteMediaClient? = null
fun emitCastStatus(status: CastStatus) {
castStatusListeners.forEach { it(status) }
}
@@ -200,7 +204,13 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
override fun onPlaybackStateChanged(playbackState: Int) {
val state = when (player.playbackState) {
STATE_IDLE -> PlayerStatus.IDLE
STATE_IDLE -> {
// cast doesn't have STATE_ENDED, so handle this manually
if (remote?.mediaStatus?.idleReason == MediaStatus.IDLE_REASON_FINISHED)
onEndListeners.forEach { it() }
PlayerStatus.IDLE
}
STATE_BUFFERING -> PlayerStatus.LOADING
STATE_READY -> PlayerStatus.READYTOPLAY
STATE_ENDED -> {
@@ -42,11 +42,11 @@ 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
import androidx.mediarouter.app.SystemOutputSwitcherDialogController
import com.google.android.gms.cast.MediaStatus
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.CastStateListener
@@ -104,13 +104,17 @@ class OmniPlayer(
private val castStateListener = CastStateListener {
eventMap.emitCastStatus(computeCastStatus())
listenToReceiver()
// we were only kept alive to hold this cast, it is over now
if (abandoned && !isCasting) release()
}
// receivers have no queue to skip in (a queue would make them play things on
// their own), so they ask us to do it when something else - google home, the
// assistant, ... - requests a prev/next. same path as our own buttons.
private fun listenToReceiver() {
val session = castContext?.sessionManager?.currentCastSession ?: return
val session = castContext?.sessionManager?.currentCastSession
eventMap.remote = session?.remoteMediaClient
if (session == null) return
try {
session.setMessageReceivedCallbacks(OMNI_NAMESPACE) { _, _, message ->
when (JSONObject(message).optString("action")) {
@@ -125,6 +129,7 @@ class OmniPlayer(
val player: Player = runOnMainThreadSync {
castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
notificationUrl = castOptions?.notificationUrl
val cc = try {
CastContext.getSharedInstance(ctx)
} catch (_: Throwable) {
@@ -163,23 +168,26 @@ class OmniPlayer(
private var serviceRunning = false
private fun syncNotificationService() {
if (abandoned) return
val shouldShow = showNotification == true && source != null
when {
shouldShow && !serviceRunning -> {
val otherIsPlaying = notificationPlayer?.let { other ->
other !== player && runOnMainThreadSync { other.isPlaying }
// an abandoned one only holds it until the cast it kept alive ends
other !== this && !other.abandoned &&
runOnMainThreadSync { other.player.isPlaying }
} == true
if (otherIsPlaying) {
throw Error("Two players can't display notifications at the same time.")
}
notificationPlayer = player
notificationPlayer = this
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
serviceRunning = true
}
!shouldShow && serviceRunning -> {
ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
if (notificationPlayer == player) notificationPlayer = null
if (notificationPlayer === this) notificationPlayer = null
serviceRunning = false
}
}
@@ -219,22 +227,36 @@ class OmniPlayer(
}
}
// the app let go of us. while casting we stay alive anyway: OmniPlayerService holds the
// process (and thus the session) up, and its notification is the only remote left - we
// keep owning both until castStateListener sees the cast end.
@Volatile
private var released = false
private var abandoned = false
override fun release() {
if (released) return
released = true
showNotification = false
override fun abandon() {
if (abandoned) return
abandoned = true
if (!runOnMainThreadSync { isCasting }) release()
}
// drop everything. this ends the cast session, so it only runs once there is no cast
// left to hold (either there was none, or it just ended).
private fun release() {
runOnMainThread {
eventMap.dispose()
castContext?.removeCastStateListener(castStateListener)
if (notificationPlayer === this) {
// syncNotificationService ignores us now, hand the service back ourselves
ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
notificationPlayer = null
serviceRunning = false
}
eventMap.dispose()
player.release()
}
}
override fun dispose() {
release()
abandon()
super.dispose()
}
@@ -302,6 +324,8 @@ class OmniPlayer(
private fun sourceOf(item: MediaItem): Source {
val meta = item.mediaMetadata
val status = castContext?.sessionManager?.currentCastSession
?.remoteMediaClient?.mediaStatus
return Source(
src = VideoSrc(
uri = item.localConfiguration?.uri?.toString() ?: item.mediaId,
@@ -316,9 +340,8 @@ class OmniPlayer(
album = meta.albumTitle?.toString(),
artist = meta.artist?.toString(),
imageLink = meta.artworkUri?.toString(),
// we did not load it, we don't know what comes around it.
hasPrev = null,
hasNext = null,
hasPrev = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_PREVIOUS),
hasNext = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_NEXT),
),
mixAudio = null,
castId = item.mediaId,
@@ -630,7 +653,7 @@ class OmniPlayer(
}
companion object {
var notificationPlayer: Player? = null
var notificationPlayer: OmniPlayer? = null
// MediaItem RequestMetadata extras keys carrying cast-only data.
const val CAST_ID_EXTRA = "dev.zoriya.omni.castId"
@@ -643,6 +666,9 @@ 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`, opened when our notification is tapped.
var notificationUrl: String? = null
}
}
@@ -653,34 +679,20 @@ class OmniPlayerService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
val available = OmniPlayer.notificationPlayer
val available = OmniPlayer.notificationPlayer?.player
if (available == null) {
startForeground(1, createImmediateNotification())
stopSelf()
return
}
player = available
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val sessionActivity = launchIntent?.let {
PendingIntent.getActivity(
this,
0,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val sessionActivity = openIntent()
mediaSession = MediaSession.Builder(this, player)
.apply {
sessionActivity?.let { setSessionActivity(it) }
}
.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)
@@ -690,7 +702,7 @@ class OmniPlayerService : MediaSessionService() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val current = OmniPlayer.notificationPlayer
val current = OmniPlayer.notificationPlayer?.player
if (current != null && ::mediaSession.isInitialized && mediaSession.player !== current) {
player = current
mediaSession.player = current
@@ -710,30 +722,40 @@ class OmniPlayerService : MediaSessionService() {
manager.createNotificationChannel(channel)
}
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
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)
.setContentIntent(openIntent())
.setOngoing(true)
.build()
}
private fun openIntent(): PendingIntent? {
val intent = OmniPlayer.notificationUrl
?.let { Intent(Intent.ACTION_VIEW, it.toUri()).setPackage(packageName) }
?: packageManager.getLaunchIntentForPackage(packageName)
?: return null
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
return PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
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() {
@@ -6,16 +6,12 @@ import com.margelo.nitro.omni.CastOptions
import com.margelo.nitro.omni.HybridOmniPlayerFactorySpec
import com.margelo.nitro.omni.HybridOmniPlayerSpec
import com.margelo.nitro.omni.PlayerBackend
import com.margelo.nitro.omni.Source
class OmniPlayerFactory(val context: ThemedReactContext) : HybridOmniPlayerFactorySpec() {
override fun createPlayer(
props: Source?,
backend: PlayerBackend?,
cast: CastOptions?,
): HybridOmniPlayerSpec {
return OmniPlayer(backend?.android ?: AndroidBackend.VLC, cast).apply {
source = props
}
return OmniPlayer(backend?.android ?: AndroidBackend.VLC, cast)
}
}
@@ -616,7 +616,7 @@ class VlcPlayer(ctx: Context) :
.setWidth(videoTrack?.width?.takeIf { it > 0 } ?: Format.NO_VALUE)
.setHeight(videoTrack?.height?.takeIf { it > 0 } ?: Format.NO_VALUE)
.setFrameRate(
videoTrack?.takeIf { it.frameRateDen > 0 }
videoTrack?.takeIf { it.frameRateDen > 0 && it.frameRateNum > 0 }
?.let { it.frameRateNum.toFloat() / it.frameRateDen }
?: Format.NO_VALUE.toFloat()
)
+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
);
}
};
@@ -9,16 +9,6 @@
// Forward declaration of `HybridOmniPlayerSpec` to properly resolve imports.
namespace margelo::nitro::omni { class HybridOmniPlayerSpec; }
// Forward declaration of `Source` to properly resolve imports.
namespace margelo::nitro::omni { struct Source; }
// Forward declaration of `VideoSrc` to properly resolve imports.
namespace margelo::nitro::omni { struct VideoSrc; }
// Forward declaration of `Subtitle` to properly resolve imports.
namespace margelo::nitro::omni { struct Subtitle; }
// Forward declaration of `Metadata` to properly resolve imports.
namespace margelo::nitro::omni { struct Metadata; }
// Forward declaration of `MixAudioMode` to properly resolve imports.
namespace margelo::nitro::omni { enum class MixAudioMode; }
// Forward declaration of `PlayerBackend` to properly resolve imports.
namespace margelo::nitro::omni { struct PlayerBackend; }
// Forward declaration of `AndroidBackend` to properly resolve imports.
@@ -29,26 +19,14 @@ namespace margelo::nitro::omni { struct CastOptions; }
#include <memory>
#include "HybridOmniPlayerSpec.hpp"
#include "JHybridOmniPlayerSpec.hpp"
#include "Source.hpp"
#include <optional>
#include "JSource.hpp"
#include "VideoSrc.hpp"
#include "JVideoSrc.hpp"
#include <string>
#include <unordered_map>
#include "Subtitle.hpp"
#include <vector>
#include "JSubtitle.hpp"
#include "Metadata.hpp"
#include "JMetadata.hpp"
#include "MixAudioMode.hpp"
#include "JMixAudioMode.hpp"
#include "PlayerBackend.hpp"
#include <optional>
#include "JPlayerBackend.hpp"
#include "AndroidBackend.hpp"
#include "JAndroidBackend.hpp"
#include "CastOptions.hpp"
#include "JCastOptions.hpp"
#include <string>
namespace margelo::nitro::omni {
@@ -83,9 +61,9 @@ namespace margelo::nitro::omni {
// Methods
std::shared_ptr<HybridOmniPlayerSpec> JHybridOmniPlayerFactorySpec::createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) {
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JHybridOmniPlayerSpec::JavaPart>(jni::alias_ref<JSource> /* props */, jni::alias_ref<JPlayerBackend> /* backend */, jni::alias_ref<JCastOptions> /* cast */)>("createPlayer");
auto __result = method(_javaPart, props.has_value() ? JSource::fromCpp(props.value()) : nullptr, backend.has_value() ? JPlayerBackend::fromCpp(backend.value()) : nullptr, cast.has_value() ? JCastOptions::fromCpp(cast.value()) : nullptr);
std::shared_ptr<HybridOmniPlayerSpec> JHybridOmniPlayerFactorySpec::createPlayer(const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) {
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JHybridOmniPlayerSpec::JavaPart>(jni::alias_ref<JPlayerBackend> /* backend */, jni::alias_ref<JCastOptions> /* cast */)>("createPlayer");
auto __result = method(_javaPart, backend.has_value() ? JPlayerBackend::fromCpp(backend.value()) : nullptr, cast.has_value() ? JCastOptions::fromCpp(cast.value()) : nullptr);
return __result->getJHybridOmniPlayerSpec();
}
@@ -54,7 +54,7 @@ namespace margelo::nitro::omni {
public:
// Methods
std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) override;
std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) override;
private:
jni::global_ref<JHybridOmniPlayerFactorySpec::JavaPart> _javaPart;
+2 -2
View File
@@ -241,8 +241,8 @@ namespace margelo::nitro::omni {
}
// Methods
void JHybridOmniPlayerSpec::release() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("release");
void JHybridOmniPlayerSpec::abandon() {
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("abandon");
method(_javaPart);
}
void JHybridOmniPlayerSpec::play() {
+1 -1
View File
@@ -78,7 +78,7 @@ namespace margelo::nitro::omni {
public:
// Methods
void release() override;
void abandon() override;
void play() override;
void pause() override;
void seekBy(double offset) override;
@@ -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)
}
}
}
@@ -30,7 +30,7 @@ abstract class HybridOmniPlayerFactorySpec: HybridObject() {
// Methods
@DoNotStrip
@Keep
abstract fun createPlayer(props: Source?, backend: PlayerBackend?, cast: CastOptions?): HybridOmniPlayerSpec
abstract fun createPlayer(backend: PlayerBackend?, cast: CastOptions?): HybridOmniPlayerSpec
// Default implementation of `HybridObject.toString()`
override fun toString(): String {
@@ -116,7 +116,7 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
// Methods
@DoNotStrip
@Keep
abstract fun release(): Unit
abstract fun abandon(): Unit
@DoNotStrip
@Keep
+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;
}
};
@@ -15,8 +15,6 @@
// Forward declaration of `HybridOmniPlayerSpec` to properly resolve imports.
namespace margelo::nitro::omni { class HybridOmniPlayerSpec; }
// Forward declaration of `Source` to properly resolve imports.
namespace margelo::nitro::omni { struct Source; }
// Forward declaration of `PlayerBackend` to properly resolve imports.
namespace margelo::nitro::omni { struct PlayerBackend; }
// Forward declaration of `CastOptions` to properly resolve imports.
@@ -24,9 +22,8 @@ namespace margelo::nitro::omni { struct CastOptions; }
#include <memory>
#include "HybridOmniPlayerSpec.hpp"
#include "Source.hpp"
#include <optional>
#include "PlayerBackend.hpp"
#include <optional>
#include "CastOptions.hpp"
namespace margelo::nitro::omni {
@@ -60,7 +57,7 @@ namespace margelo::nitro::omni {
public:
// Methods
virtual std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) = 0;
virtual std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) = 0;
protected:
// Hybrid Setup
+1 -1
View File
@@ -39,7 +39,7 @@ namespace margelo::nitro::omni {
prototype.registerHybridGetter("audios", &HybridOmniPlayerSpec::getAudios);
prototype.registerHybridGetter("subtitles", &HybridOmniPlayerSpec::getSubtitles);
prototype.registerHybridGetter("renditions", &HybridOmniPlayerSpec::getRenditions);
prototype.registerHybridMethod("release", &HybridOmniPlayerSpec::release);
prototype.registerHybridMethod("abandon", &HybridOmniPlayerSpec::abandon);
prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play);
prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause);
prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy);
+1 -1
View File
@@ -91,7 +91,7 @@ namespace margelo::nitro::omni {
public:
// Methods
virtual void release() = 0;
virtual void abandon() = 0;
virtual void play() = 0;
virtual void pause() = 0;
virtual void seekBy(double offset) = 0;
+6 -3
View File
@@ -35,7 +35,7 @@ export const OmniProvider = ({
const [player, setPlayer] = useState<NativeOmniPlayer | null>(null);
const createPlayer = useEffectEvent((aBackend: AndroidBackend) =>
ProviderFactory.createPlayer(source, { android: aBackend }, cast),
ProviderFactory.createPlayer({ android: aBackend }, cast),
);
useEffect(() => {
@@ -44,11 +44,14 @@ export const OmniProvider = ({
useEffect(() => {
if (!player) return;
return () => player.release();
return () => player.abandon();
}, [player]);
useEffect(() => {
if (player) player.source = source;
if (!player) return;
// early return on init to keep running cast sessions active
if (source == null && player.source == null) return;
player.source = source;
}, [player, source]);
useEffect(() => {
+3 -6
View File
@@ -81,13 +81,10 @@ export interface OmniPlayer
OmniPlayerT {
readonly eventMap: OmniEventMap;
release(): void;
// let go of the player. it stays alive as long as it holds a cast session (android).
abandon(): void;
}
export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> {
createPlayer(
props?: Source,
backend?: PlayerBackend,
cast?: CastOptions,
): OmniPlayer;
createPlayer(backend?: PlayerBackend, cast?: CastOptions): OmniPlayer;
}
+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 {
+14 -22
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 =
@@ -119,9 +120,11 @@ export const OmniView = ({
const headersRef = useRef(source?.src?.headers);
headersRef.current = source?.src?.headers;
const startTime = source?.startTime;
const config = useMemo<HlsMediaConfig>(
() => ({
hlsJs: {
...(startTime && { startPosition: startTime }),
xhrSetup: (xhr: XMLHttpRequest) => {
const headers = headersRef.current;
if (!headers) return;
@@ -131,20 +134,9 @@ export const OmniView = ({
},
},
}),
[],
[startTime],
);
const player = usePlayer();
const uri = source?.src.uri;
const startTime = source?.startTime;
const prevUri = useRef<string | undefined>(undefined);
useEffect(() => {
if (!uri) return;
if (startTime) player.currentTime = startTime;
if (autoplay && prevUri.current && prevUri.current !== uri) player.play();
prevUri.current = uri;
}, [player, uri, startTime, autoplay]);
// While casting, the receiver renders subtitles (the player forwards the
// selection); skip rendering them locally on the idle <video>.
const castStatus = usePlayerState("castStatus");
@@ -155,8 +147,8 @@ export const OmniView = ({
style={{ position: "relative", ...style }}
>
<Tech
ref={ref}
src={source?.src.uri}
ref={setVideo}
src={source && startTime ? `${source.src.uri}#t=${startTime}` : source?.src.uri}
config={config}
autoPlay={autoplay}
playsInline
@@ -176,7 +168,7 @@ export const OmniView = ({
</Tech>
{castStatus !== "connected" && castStatus !== "connecting" && (
<SubtitleOverlay
video={ref}
video={video}
assets={subtitleAssets}
fonts={source?.fonts}
/>