ionic-chromecast 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/android/build.gradle +11 -2
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/fabianacevedo/ionicchromecast/CastOptionsProvider.java +25 -8
- package/android/src/main/java/com/fabianacevedo/ionicchromecast/IonicChromecast.java +321 -103
- package/android/src/main/java/com/fabianacevedo/ionicchromecast/IonicChromecastPlugin.java +75 -62
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -41,7 +41,8 @@ import { IonicChromecast } from 'ionic-chromecast';
|
|
|
41
41
|
// In your app.component.ts or main initialization
|
|
42
42
|
async initializeCast() {
|
|
43
43
|
const result = await IonicChromecast.initialize({
|
|
44
|
-
|
|
44
|
+
// Prefer CastVideos CAF receiver for reliable UI on TV. Use 'CC1AD845' if you need the default.
|
|
45
|
+
receiverApplicationId: '4F8B3483'
|
|
45
46
|
});
|
|
46
47
|
|
|
47
48
|
if (result.success) {
|
|
@@ -538,3 +539,8 @@ Listen to Chromecast events (Android only)
|
|
|
538
539
|
<code>'sessionStarted' | 'sessionEnded' | 'mediaLoaded' | 'mediaError' | 'deviceAvailable' | 'deviceUnavailable' | 'volumeChanged' | 'playbackStatusChanged'</code>
|
|
539
540
|
|
|
540
541
|
</docgen-api>
|
|
542
|
+
|
|
543
|
+
## Troubleshooting
|
|
544
|
+
- No Cast UI on TV after connecting: use the CastVideos CAF receiver ID `4F8B3483` instead of the default `CC1AD845` when calling `initialize`.
|
|
545
|
+
- Media returns success but nothing plays: confirm a session is active (`isSessionActive`), then retry `loadMedia` with a known-good HTTPS MP4 (e.g., BigBuckBunny).
|
|
546
|
+
- Devices not found: ensure the phone and Chromecast are on the same WiFi and Google Play Services is up to date.
|
package/android/build.gradle
CHANGED
|
@@ -3,6 +3,8 @@ ext {
|
|
|
3
3
|
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
|
|
4
4
|
androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.2.1'
|
|
5
5
|
androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.6.1'
|
|
6
|
+
// Add a default version for MediaRouter if not provided by root project
|
|
7
|
+
androidxMediaRouterVersion = project.hasProperty('androidxMediaRouterVersion') ? rootProject.ext.androidxMediaRouterVersion : '1.6.0'
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
buildscript {
|
|
@@ -37,8 +39,9 @@ android {
|
|
|
37
39
|
abortOnError false
|
|
38
40
|
}
|
|
39
41
|
compileOptions {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
// Use Java 17 for broader compatibility with current Android toolchains
|
|
43
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
44
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -56,6 +59,12 @@ dependencies {
|
|
|
56
59
|
// Google Cast SDK
|
|
57
60
|
implementation 'com.google.android.gms:play-services-cast-framework:21.5.0'
|
|
58
61
|
|
|
62
|
+
// AndroidX MediaRouter (for Cast chooser/dialog)
|
|
63
|
+
implementation "androidx.mediarouter:mediarouter:$androidxMediaRouterVersion"
|
|
64
|
+
|
|
65
|
+
// AndroidX Media (required for MediaMetadataCompat)
|
|
66
|
+
implementation 'androidx.media:media:1.7.0'
|
|
67
|
+
|
|
59
68
|
testImplementation "junit:junit:$junitVersion"
|
|
60
69
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
61
70
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
5
5
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
6
6
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
7
|
+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
|
8
|
+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
|
9
|
+
<uses-permission
|
|
10
|
+
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
|
11
|
+
android:usesPermissionFlags="neverForLocation" />
|
|
7
12
|
|
|
8
13
|
<application>
|
|
9
14
|
<!-- Google Cast OptionsProvider -->
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.fabianacevedo.ionicchromecast;
|
|
2
2
|
|
|
3
3
|
import android.content.Context;
|
|
4
|
+
import android.content.SharedPreferences;
|
|
4
5
|
import com.google.android.gms.cast.framework.CastOptions;
|
|
5
6
|
import com.google.android.gms.cast.framework.OptionsProvider;
|
|
6
7
|
import com.google.android.gms.cast.framework.SessionProvider;
|
|
@@ -9,22 +10,38 @@ import java.util.List;
|
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* OptionsProvider for Google Cast SDK
|
|
12
|
-
* This class is required by the Cast SDK to provide configuration options
|
|
13
13
|
*/
|
|
14
14
|
public class CastOptionsProvider implements OptionsProvider {
|
|
15
|
-
|
|
16
|
-
// Default Media Receiver App ID
|
|
15
|
+
|
|
17
16
|
private static final String DEFAULT_RECEIVER_APP_ID = "CC1AD845";
|
|
18
|
-
|
|
17
|
+
private static final String PREFS_NAME = "IonicChromecastPrefs";
|
|
18
|
+
private static final String KEY_RECEIVER_APP_ID = "receiverApplicationId";
|
|
19
|
+
|
|
20
|
+
public static String sReceiverApplicationId = null;
|
|
21
|
+
|
|
19
22
|
@Override
|
|
20
23
|
public CastOptions getCastOptions(Context context) {
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
String receiverAppId = DEFAULT_RECEIVER_APP_ID;
|
|
25
|
+
|
|
26
|
+
// Prefer static variable set by initialize()
|
|
27
|
+
if (sReceiverApplicationId != null && !sReceiverApplicationId.isEmpty()) {
|
|
28
|
+
receiverAppId = sReceiverApplicationId;
|
|
29
|
+
} else {
|
|
30
|
+
// Try from SharedPreferences
|
|
31
|
+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
32
|
+
String savedId = prefs.getString(KEY_RECEIVER_APP_ID, null);
|
|
33
|
+
if (savedId != null && !savedId.isEmpty()) {
|
|
34
|
+
receiverAppId = savedId;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
return new CastOptions.Builder()
|
|
24
|
-
.setReceiverApplicationId(
|
|
39
|
+
.setReceiverApplicationId(receiverAppId)
|
|
40
|
+
.setStopReceiverApplicationWhenEndingSession(true)
|
|
41
|
+
.setResumeSavedSession(false)
|
|
25
42
|
.build();
|
|
26
43
|
}
|
|
27
|
-
|
|
44
|
+
|
|
28
45
|
@Override
|
|
29
46
|
public List<SessionProvider> getAdditionalSessionProviders(Context context) {
|
|
30
47
|
return null;
|
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
package com.fabianacevedo.ionicchromecast;
|
|
2
2
|
|
|
3
3
|
import android.content.Context;
|
|
4
|
+
import android.content.SharedPreferences;
|
|
5
|
+
import android.os.Handler;
|
|
6
|
+
import android.os.Looper;
|
|
4
7
|
import com.getcapacitor.Logger;
|
|
5
8
|
import com.google.android.gms.cast.framework.CastContext;
|
|
6
|
-
import com.google.android.gms.
|
|
7
|
-
import com.google.android.gms.
|
|
8
|
-
import
|
|
9
|
+
import com.google.android.gms.common.ConnectionResult;
|
|
10
|
+
import com.google.android.gms.common.GoogleApiAvailability;
|
|
11
|
+
import java.util.concurrent.CountDownLatch;
|
|
12
|
+
import java.util.concurrent.TimeUnit;
|
|
13
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
14
|
+
import androidx.mediarouter.media.MediaRouter;
|
|
15
|
+
import androidx.mediarouter.media.MediaRouteSelector;
|
|
9
16
|
import com.google.android.gms.cast.CastMediaControlIntent;
|
|
10
|
-
import
|
|
17
|
+
import com.google.android.gms.cast.MediaMetadata;
|
|
18
|
+
import com.google.android.gms.cast.MediaInfo;
|
|
19
|
+
import com.google.android.gms.cast.framework.CastSession;
|
|
20
|
+
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
|
|
21
|
+
import com.google.android.gms.cast.MediaLoadRequestData;
|
|
22
|
+
import com.google.android.gms.common.api.PendingResult;
|
|
23
|
+
import com.google.android.gms.common.images.WebImage;
|
|
11
24
|
|
|
12
25
|
public class IonicChromecast {
|
|
13
26
|
|
|
14
27
|
private static final String TAG = "IonicChromecast";
|
|
28
|
+
private static final String PREFS_NAME = "IonicChromecastPrefs";
|
|
29
|
+
private static final String KEY_RECEIVER_APP_ID = "receiverApplicationId";
|
|
15
30
|
private CastContext castContext;
|
|
16
31
|
private boolean isInitialized = false;
|
|
32
|
+
private Context appContext;
|
|
33
|
+
private String lastError = null;
|
|
34
|
+
private MediaRouter mediaRouter;
|
|
35
|
+
private MediaRouteSelector mediaRouteSelector;
|
|
36
|
+
private final Object discoveryLock = new Object();
|
|
17
37
|
|
|
18
38
|
/**
|
|
19
39
|
* Initialize the Google Cast SDK with the provided receiver application ID
|
|
@@ -23,162 +43,360 @@ public class IonicChromecast {
|
|
|
23
43
|
*/
|
|
24
44
|
public boolean initialize(Context context, String receiverApplicationId) {
|
|
25
45
|
try {
|
|
46
|
+
lastError = null;
|
|
26
47
|
if (isInitialized) {
|
|
27
48
|
Logger.info(TAG, "Cast SDK already initialized");
|
|
28
49
|
return true;
|
|
29
50
|
}
|
|
30
51
|
|
|
31
52
|
if (receiverApplicationId == null || receiverApplicationId.isEmpty()) {
|
|
32
|
-
Logger.error(TAG, "Receiver Application ID is required");
|
|
53
|
+
Logger.error(TAG, "Receiver Application ID is required", null);
|
|
33
54
|
return false;
|
|
34
55
|
}
|
|
35
|
-
|
|
56
|
+
|
|
36
57
|
Logger.info(TAG, "Initializing Cast SDK with receiver ID: " + receiverApplicationId);
|
|
58
|
+
Logger.info(TAG, "Thread at init: " + Thread.currentThread().getName());
|
|
59
|
+
|
|
60
|
+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
61
|
+
prefs.edit().putString(KEY_RECEIVER_APP_ID, receiverApplicationId).apply();
|
|
37
62
|
|
|
38
|
-
//
|
|
39
|
-
|
|
63
|
+
// Also set it in the static variable for immediate use
|
|
64
|
+
CastOptionsProvider.sReceiverApplicationId = receiverApplicationId;
|
|
40
65
|
|
|
66
|
+
int playStatus = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context);
|
|
67
|
+
Logger.info(TAG, "Google Play Services status=" + playStatus);
|
|
68
|
+
if (playStatus != ConnectionResult.SUCCESS) {
|
|
69
|
+
lastError = "Google Play Services status=" + playStatus;
|
|
70
|
+
Logger.error(TAG, lastError, null);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
// Obtener CastContext y preparar MediaRouter siempre en el hilo principal
|
|
74
|
+
appContext = context.getApplicationContext();
|
|
75
|
+
Runnable initRunnable = () -> {
|
|
76
|
+
try {
|
|
77
|
+
castContext = CastContext.getSharedInstance(appContext);
|
|
78
|
+
if (castContext != null) {
|
|
79
|
+
mediaRouter = MediaRouter.getInstance(appContext);
|
|
80
|
+
mediaRouteSelector = new MediaRouteSelector.Builder()
|
|
81
|
+
.addControlCategory(CastMediaControlIntent.categoryForCast(CastOptionsProvider.sReceiverApplicationId != null ? CastOptionsProvider.sReceiverApplicationId : "CC1AD845"))
|
|
82
|
+
.build();
|
|
83
|
+
}
|
|
84
|
+
} catch (Exception e) {
|
|
85
|
+
lastError = "Error initializing on main thread: " + e.getMessage();
|
|
86
|
+
Logger.error(TAG, lastError, e);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
91
|
+
initRunnable.run();
|
|
92
|
+
} else {
|
|
93
|
+
final CountDownLatch latch = new CountDownLatch(1);
|
|
94
|
+
Handler mainHandler = new Handler(Looper.getMainLooper());
|
|
95
|
+
mainHandler.post(() -> {
|
|
96
|
+
initRunnable.run();
|
|
97
|
+
latch.countDown();
|
|
98
|
+
});
|
|
99
|
+
boolean awaited = latch.await(6, TimeUnit.SECONDS);
|
|
100
|
+
if (!awaited && castContext == null && lastError == null) {
|
|
101
|
+
lastError = "Timed out waiting for CastContext on main thread";
|
|
102
|
+
Logger.error(TAG, lastError, null);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
41
106
|
if (castContext != null) {
|
|
42
107
|
isInitialized = true;
|
|
43
108
|
Logger.info(TAG, "Cast SDK initialized successfully");
|
|
44
|
-
return true;
|
|
45
109
|
} else {
|
|
46
|
-
|
|
47
|
-
|
|
110
|
+
if (lastError == null) lastError = "Failed to get CastContext";
|
|
111
|
+
Logger.error(TAG, lastError, null);
|
|
48
112
|
}
|
|
113
|
+
return isInitialized;
|
|
49
114
|
|
|
50
115
|
} catch (Exception e) {
|
|
51
|
-
|
|
116
|
+
lastError = "Error initializing Cast SDK: " + e.getMessage();
|
|
117
|
+
Logger.error(TAG, lastError, e);
|
|
52
118
|
return false;
|
|
53
119
|
}
|
|
54
120
|
}
|
|
121
|
+
|
|
122
|
+
public String getLastError() {
|
|
123
|
+
return lastError;
|
|
124
|
+
}
|
|
55
125
|
|
|
56
|
-
/**
|
|
57
|
-
* Check if the Cast SDK is initialized
|
|
58
|
-
* @return true if initialized
|
|
59
|
-
*/
|
|
60
126
|
public boolean isInitialized() {
|
|
61
127
|
return isInitialized;
|
|
62
128
|
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Get the CastContext instance
|
|
66
|
-
* @return CastContext or null if not initialized
|
|
67
|
-
*/
|
|
68
129
|
public CastContext getCastContext() {
|
|
69
130
|
return castContext;
|
|
70
131
|
}
|
|
71
132
|
|
|
72
|
-
public String echo(String value) {
|
|
73
|
-
Logger.info("Echo", value);
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
133
|
/**
|
|
78
|
-
*
|
|
79
|
-
* Shows the Cast dialog and starts a session if a device is selected
|
|
80
|
-
* @param context The application context
|
|
81
|
-
* @return true if session started, false otherwise
|
|
134
|
+
* Verifica si hay sesión activa
|
|
82
135
|
*/
|
|
83
|
-
public boolean
|
|
84
|
-
if (!isInitialized || castContext == null)
|
|
85
|
-
|
|
86
|
-
|
|
136
|
+
public boolean isSessionActive() {
|
|
137
|
+
if (!isInitialized || castContext == null) return false;
|
|
138
|
+
|
|
139
|
+
// Consultar SessionManager en el hilo principal para obtener el estado real
|
|
140
|
+
final AtomicBoolean active = new AtomicBoolean(false);
|
|
141
|
+
Runnable check = () -> {
|
|
142
|
+
try {
|
|
143
|
+
CastSession s = castContext.getSessionManager().getCurrentCastSession();
|
|
144
|
+
active.set(s != null && s.isConnected());
|
|
145
|
+
} catch (Exception e) {
|
|
146
|
+
Logger.error(TAG, "Error checking session status: " + e.getMessage(), e);
|
|
147
|
+
active.set(false);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
152
|
+
check.run();
|
|
153
|
+
return active.get();
|
|
87
154
|
}
|
|
155
|
+
|
|
88
156
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
157
|
+
CountDownLatch latch = new CountDownLatch(1);
|
|
158
|
+
new Handler(Looper.getMainLooper()).post(() -> {
|
|
159
|
+
check.run();
|
|
160
|
+
latch.countDown();
|
|
161
|
+
});
|
|
162
|
+
latch.await(3, TimeUnit.SECONDS);
|
|
163
|
+
} catch (InterruptedException ie) {
|
|
164
|
+
Logger.error(TAG, "Interrupted while checking session", ie);
|
|
96
165
|
}
|
|
166
|
+
|
|
167
|
+
return active.get();
|
|
97
168
|
}
|
|
98
|
-
|
|
169
|
+
|
|
99
170
|
/**
|
|
100
|
-
*
|
|
101
|
-
* @return true if session is active, false otherwise
|
|
171
|
+
* Verifica si hay dispositivos Cast disponibles mediante MediaRouter
|
|
102
172
|
*/
|
|
103
|
-
public boolean
|
|
104
|
-
if (!isInitialized || castContext == null) {
|
|
105
|
-
Logger.error(TAG, "Cast SDK not initialized. Call initialize() first.");
|
|
173
|
+
public boolean areDevicesAvailable() {
|
|
174
|
+
if (!isInitialized || castContext == null || appContext == null || mediaRouter == null || mediaRouteSelector == null) {
|
|
175
|
+
Logger.error(TAG, "Cast SDK not initialized. Call initialize() first.", null);
|
|
106
176
|
return false;
|
|
107
177
|
}
|
|
178
|
+
|
|
179
|
+
// MediaRouter debe consultarse en el hilo principal para obtener rutas válidas
|
|
180
|
+
final AtomicBoolean result = new AtomicBoolean(false);
|
|
181
|
+
Runnable scanRunnable = () -> result.set(runDeviceScan());
|
|
182
|
+
|
|
183
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
184
|
+
scanRunnable.run();
|
|
185
|
+
return result.get();
|
|
186
|
+
}
|
|
187
|
+
|
|
108
188
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
189
|
+
CountDownLatch latch = new CountDownLatch(1);
|
|
190
|
+
new Handler(Looper.getMainLooper()).post(() -> {
|
|
191
|
+
scanRunnable.run();
|
|
192
|
+
latch.countDown();
|
|
193
|
+
});
|
|
194
|
+
latch.await(6, TimeUnit.SECONDS);
|
|
195
|
+
} catch (InterruptedException ie) {
|
|
196
|
+
Logger.error(TAG, "Interrupted while checking devices", ie);
|
|
115
197
|
}
|
|
198
|
+
|
|
199
|
+
return result.get();
|
|
116
200
|
}
|
|
117
|
-
|
|
201
|
+
|
|
118
202
|
/**
|
|
119
|
-
*
|
|
120
|
-
* @return true if devices are available, false otherwise
|
|
203
|
+
* Realiza el escaneo de rutas Cast usando MediaRouter.
|
|
121
204
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
205
|
+
private boolean runDeviceScan() {
|
|
206
|
+
final AtomicBoolean found = new AtomicBoolean(false);
|
|
207
|
+
final CountDownLatch latch = new CountDownLatch(1);
|
|
208
|
+
MediaRouter.Callback discoveryCallback = new MediaRouter.Callback() {
|
|
209
|
+
@Override
|
|
210
|
+
public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo route) {
|
|
211
|
+
if (route != null && route.matchesSelector(mediaRouteSelector) && !route.isDefault()) {
|
|
212
|
+
found.set(true);
|
|
213
|
+
latch.countDown();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
@Override
|
|
218
|
+
public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo route) {
|
|
219
|
+
if (route != null && route.matchesSelector(mediaRouteSelector) && !route.isDefault()) {
|
|
220
|
+
found.set(true);
|
|
221
|
+
latch.countDown();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
127
226
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
227
|
+
synchronized (discoveryLock) {
|
|
228
|
+
mediaRouter.addCallback(
|
|
229
|
+
mediaRouteSelector,
|
|
230
|
+
discoveryCallback,
|
|
231
|
+
MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY | MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Revisar rutas conocidas inmediatamente
|
|
235
|
+
for (MediaRouter.RouteInfo route : mediaRouter.getRoutes()) {
|
|
236
|
+
if (route != null && route.matchesSelector(mediaRouteSelector) && !route.isDefault()) {
|
|
237
|
+
found.set(true);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Esperar algo de tiempo para descubrimiento activo
|
|
243
|
+
if (!found.get()) {
|
|
244
|
+
latch.await(4000, TimeUnit.MILLISECONDS);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Revisión final de rutas conocidas antes de salir
|
|
248
|
+
if (!found.get()) {
|
|
249
|
+
for (MediaRouter.RouteInfo route : mediaRouter.getRoutes()) {
|
|
250
|
+
if (route != null && route.matchesSelector(mediaRouteSelector) && !route.isDefault()) {
|
|
251
|
+
found.set(true);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
mediaRouter.removeCallback(discoveryCallback);
|
|
258
|
+
}
|
|
259
|
+
Logger.info(TAG, "areDevicesAvailable: found=" + found.get());
|
|
260
|
+
return found.get();
|
|
133
261
|
} catch (Exception e) {
|
|
134
|
-
Logger.error(TAG, "Error checking
|
|
262
|
+
Logger.error(TAG, "Error checking available devices: " + e.getMessage(), e);
|
|
135
263
|
return false;
|
|
136
264
|
}
|
|
137
265
|
}
|
|
138
|
-
|
|
266
|
+
|
|
139
267
|
/**
|
|
140
|
-
*
|
|
141
|
-
* @param url The media URL
|
|
142
|
-
* @param metadata Optional metadata (title, images, etc)
|
|
143
|
-
* @return true if media loaded successfully
|
|
268
|
+
* Envía media al dispositivo Cast (flujo básico)
|
|
144
269
|
*/
|
|
145
|
-
public boolean loadMedia(String url,
|
|
270
|
+
public boolean loadMedia(String url, String title, String subtitle, String imageUrl, String contentType) {
|
|
146
271
|
if (!isInitialized || castContext == null) {
|
|
147
|
-
|
|
272
|
+
lastError = "Cast SDK not initialized. Call initialize() first.";
|
|
273
|
+
Logger.error(TAG, lastError, null);
|
|
148
274
|
return false;
|
|
149
275
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
276
|
+
|
|
277
|
+
lastError = null;
|
|
278
|
+
final AtomicBoolean success = new AtomicBoolean(false);
|
|
279
|
+
final CountDownLatch done = new CountDownLatch(1);
|
|
280
|
+
Handler mainHandler = new Handler(Looper.getMainLooper());
|
|
281
|
+
|
|
282
|
+
Runnable loadRunnable = () -> {
|
|
283
|
+
try {
|
|
284
|
+
if (url == null || url.isEmpty()) {
|
|
285
|
+
lastError = "Media URL is required";
|
|
286
|
+
Logger.error(TAG, lastError, null);
|
|
287
|
+
done.countDown();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
Logger.info(TAG, "loadMedia: url=" + url + ", contentType=" + contentType);
|
|
292
|
+
|
|
293
|
+
MediaMetadata md = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE);
|
|
294
|
+
if (title != null && !title.isEmpty()) md.putString(MediaMetadata.KEY_TITLE, title);
|
|
295
|
+
if (subtitle != null && !subtitle.isEmpty()) md.putString(MediaMetadata.KEY_SUBTITLE, subtitle);
|
|
296
|
+
if (imageUrl != null && !imageUrl.isEmpty()) md.addImage(new WebImage(android.net.Uri.parse(imageUrl)));
|
|
297
|
+
|
|
298
|
+
String ct = (contentType != null && !contentType.isEmpty()) ? contentType : "video/mp4";
|
|
299
|
+
MediaInfo mediaInfo = new MediaInfo.Builder(url)
|
|
300
|
+
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
|
301
|
+
.setContentType(ct)
|
|
302
|
+
.setMetadata(md)
|
|
303
|
+
.build();
|
|
304
|
+
|
|
305
|
+
CastSession session = castContext.getSessionManager().getCurrentCastSession();
|
|
306
|
+
if (session == null || !session.isConnected()) {
|
|
307
|
+
lastError = "No active Cast session";
|
|
308
|
+
Logger.error(TAG, lastError, null);
|
|
309
|
+
done.countDown();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
String appId = session.getApplicationMetadata() != null ? session.getApplicationMetadata().getApplicationId() : "";
|
|
315
|
+
String deviceName = session.getCastDevice() != null ? session.getCastDevice().getFriendlyName() : "";
|
|
316
|
+
Logger.info(TAG, "Session connected. appId=" + appId + ", device=" + deviceName);
|
|
317
|
+
} catch (Exception ignored) {}
|
|
318
|
+
|
|
319
|
+
RemoteMediaClient rmc = session.getRemoteMediaClient();
|
|
320
|
+
if (rmc == null) {
|
|
321
|
+
lastError = "RemoteMediaClient is null";
|
|
322
|
+
Logger.error(TAG, lastError, null);
|
|
323
|
+
done.countDown();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
MediaLoadRequestData req = new MediaLoadRequestData.Builder()
|
|
328
|
+
.setMediaInfo(mediaInfo)
|
|
329
|
+
.setAutoplay(true)
|
|
330
|
+
.setCurrentTime(0L)
|
|
331
|
+
.build();
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
PendingResult<RemoteMediaClient.MediaChannelResult> pending = rmc.load(req);
|
|
335
|
+
if (pending == null) {
|
|
336
|
+
lastError = "rmc.load() returned null";
|
|
337
|
+
Logger.error(TAG, lastError, null);
|
|
338
|
+
done.countDown();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
pending.setResultCallback(result1 -> {
|
|
343
|
+
if (result1 != null && result1.getStatus() != null && result1.getStatus().isSuccess()) {
|
|
344
|
+
success.set(true);
|
|
345
|
+
Logger.info(TAG, "Media load success");
|
|
346
|
+
} else {
|
|
347
|
+
int statusCode = (result1 != null && result1.getStatus() != null) ? result1.getStatus().getStatusCode() : -1;
|
|
348
|
+
lastError = "Media load failed, statusCode=" + statusCode;
|
|
349
|
+
Logger.error(TAG, lastError, null);
|
|
350
|
+
}
|
|
351
|
+
done.countDown();
|
|
352
|
+
});
|
|
353
|
+
} catch (Exception e) {
|
|
354
|
+
lastError = "Error sending media load request: " + e.getMessage();
|
|
355
|
+
Logger.error(TAG, lastError, e);
|
|
356
|
+
done.countDown();
|
|
357
|
+
}
|
|
358
|
+
} catch (Exception e) {
|
|
359
|
+
lastError = "Error loading media: " + e.getMessage();
|
|
360
|
+
Logger.error(TAG, lastError, e);
|
|
361
|
+
done.countDown();
|
|
154
362
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Always dispatch load to the main thread but wait off-main for completion
|
|
366
|
+
mainHandler.post(loadRunnable);
|
|
367
|
+
|
|
368
|
+
boolean awaited = false;
|
|
369
|
+
try {
|
|
370
|
+
awaited = done.await(14, TimeUnit.SECONDS);
|
|
371
|
+
} catch (InterruptedException ie) {
|
|
372
|
+
lastError = "Interrupted while loading media";
|
|
373
|
+
Logger.error(TAG, lastError, ie);
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!awaited && !success.get()) {
|
|
378
|
+
try {
|
|
379
|
+
CastSession session = castContext.getSessionManager().getCurrentCastSession();
|
|
380
|
+
String appId = session != null && session.getApplicationMetadata() != null ? session.getApplicationMetadata().getApplicationId() : "";
|
|
381
|
+
String deviceName = session != null && session.getCastDevice() != null ? session.getCastDevice().getFriendlyName() : "";
|
|
382
|
+
lastError = "Media load timed out (appId=" + appId + ", device=" + deviceName + ")";
|
|
383
|
+
} catch (Exception e) {
|
|
384
|
+
lastError = "Media load timed out";
|
|
165
385
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
386
|
+
Logger.error(TAG, lastError, null);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!success.get() && (lastError == null || lastError.isEmpty())) {
|
|
390
|
+
try {
|
|
391
|
+
CastSession session = castContext.getSessionManager().getCurrentCastSession();
|
|
392
|
+
String appId = session != null && session.getApplicationMetadata() != null ? session.getApplicationMetadata().getApplicationId() : "";
|
|
393
|
+
String deviceName = session != null && session.getCastDevice() != null ? session.getCastDevice().getFriendlyName() : "";
|
|
394
|
+
lastError = "Media load failed (unknown reason, appId=" + appId + ", device=" + deviceName + ")";
|
|
395
|
+
} catch (Exception e) {
|
|
396
|
+
lastError = "Media load failed (unknown reason)";
|
|
175
397
|
}
|
|
176
|
-
|
|
177
|
-
Logger.info(TAG, "Media loaded to Cast device: " + url);
|
|
178
|
-
return true;
|
|
179
|
-
} catch (Exception e) {
|
|
180
|
-
Logger.error(TAG, "Error loading media: " + e.getMessage(), e);
|
|
181
|
-
return false;
|
|
398
|
+
Logger.error(TAG, lastError, null);
|
|
182
399
|
}
|
|
400
|
+
return success.get();
|
|
183
401
|
}
|
|
184
402
|
}
|
|
@@ -5,7 +5,14 @@ import com.getcapacitor.Plugin;
|
|
|
5
5
|
import com.getcapacitor.PluginCall;
|
|
6
6
|
import com.getcapacitor.PluginMethod;
|
|
7
7
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
import android.text.TextUtils;
|
|
10
|
+
import com.google.android.gms.cast.CastMediaControlIntent;
|
|
11
|
+
import androidx.appcompat.app.AppCompatActivity;
|
|
12
|
+
import androidx.mediarouter.app.MediaRouteChooserDialog;
|
|
13
|
+
import androidx.mediarouter.media.MediaRouteSelector;
|
|
14
|
+
import androidx.mediarouter.media.MediaRouter;
|
|
15
|
+
import android.content.DialogInterface;
|
|
9
16
|
|
|
10
17
|
@CapacitorPlugin(name = "IonicChromecast")
|
|
11
18
|
public class IonicChromecastPlugin extends Plugin {
|
|
@@ -29,6 +36,10 @@ public class IonicChromecastPlugin extends Plugin {
|
|
|
29
36
|
|
|
30
37
|
JSObject ret = new JSObject();
|
|
31
38
|
ret.put("success", success);
|
|
39
|
+
String initError = implementation.getLastError();
|
|
40
|
+
if (initError != null && !initError.isEmpty()) {
|
|
41
|
+
ret.put("error", initError);
|
|
42
|
+
}
|
|
32
43
|
|
|
33
44
|
if (success) {
|
|
34
45
|
call.resolve(ret);
|
|
@@ -37,95 +48,97 @@ public class IonicChromecastPlugin extends Plugin {
|
|
|
37
48
|
}
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
@PluginMethod
|
|
41
|
-
public void echo(PluginCall call) {
|
|
42
|
-
String value = call.getString("value");
|
|
43
|
-
|
|
44
|
-
JSObject ret = new JSObject();
|
|
45
|
-
ret.put("value", implementation.echo(value));
|
|
46
|
-
call.resolve(ret);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
51
|
/**
|
|
50
|
-
*
|
|
52
|
+
* Muestra el selector nativo de dispositivos Cast
|
|
51
53
|
*/
|
|
52
54
|
@PluginMethod
|
|
53
55
|
public void requestSession(PluginCall call) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} else {
|
|
61
|
-
ret.put("message", "Failed to request Cast session. Make sure SDK is initialized.");
|
|
62
|
-
call.reject("Failed to request Cast session", ret);
|
|
56
|
+
if (!implementation.isInitialized() || implementation.getCastContext() == null) {
|
|
57
|
+
JSObject err = new JSObject();
|
|
58
|
+
err.put("success", false);
|
|
59
|
+
err.put("message", "Cast SDK not initialized");
|
|
60
|
+
call.reject("Failed to request Cast session", err);
|
|
61
|
+
return;
|
|
63
62
|
}
|
|
63
|
+
|
|
64
|
+
getActivity().runOnUiThread(() -> {
|
|
65
|
+
try {
|
|
66
|
+
AppCompatActivity activity = (AppCompatActivity) getActivity();
|
|
67
|
+
String receiverId = CastOptionsProvider.sReceiverApplicationId;
|
|
68
|
+
if (TextUtils.isEmpty(receiverId)) receiverId = "CC1AD845";
|
|
69
|
+
|
|
70
|
+
MediaRouteSelector selector = new MediaRouteSelector.Builder()
|
|
71
|
+
.addControlCategory(CastMediaControlIntent.categoryForCast(receiverId))
|
|
72
|
+
.build();
|
|
73
|
+
|
|
74
|
+
MediaRouteChooserDialog chooserDialog = new MediaRouteChooserDialog(activity, androidx.appcompat.R.style.Theme_AppCompat_NoActionBar);
|
|
75
|
+
chooserDialog.setRouteSelector(selector);
|
|
76
|
+
chooserDialog.setCanceledOnTouchOutside(true);
|
|
77
|
+
chooserDialog.setOnCancelListener(new DialogInterface.OnCancelListener() {
|
|
78
|
+
@Override public void onCancel(DialogInterface dialog) {}
|
|
79
|
+
});
|
|
80
|
+
chooserDialog.show();
|
|
81
|
+
|
|
82
|
+
JSObject ret = new JSObject();
|
|
83
|
+
ret.put("success", true);
|
|
84
|
+
ret.put("message", "Cast chooser displayed");
|
|
85
|
+
call.resolve(ret);
|
|
86
|
+
} catch (Exception e) {
|
|
87
|
+
JSObject err = new JSObject();
|
|
88
|
+
err.put("success", false);
|
|
89
|
+
err.put("message", "Error showing chooser: " + e.getMessage());
|
|
90
|
+
call.reject("Failed to request Cast session", err);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
/**
|
|
67
|
-
*
|
|
96
|
+
* Estado de sesión activa
|
|
68
97
|
*/
|
|
69
98
|
@PluginMethod
|
|
70
99
|
public void isSessionActive(PluginCall call) {
|
|
71
100
|
boolean active = implementation.isSessionActive();
|
|
72
101
|
JSObject ret = new JSObject();
|
|
73
102
|
ret.put("active", active);
|
|
74
|
-
if (active) {
|
|
75
|
-
ret.put("message", "There is an active Cast session.");
|
|
76
|
-
} else {
|
|
77
|
-
ret.put("message", "No active Cast session.");
|
|
78
|
-
}
|
|
79
103
|
call.resolve(ret);
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
/**
|
|
83
|
-
*
|
|
107
|
+
* Carga media en el dispositivo
|
|
84
108
|
*/
|
|
85
109
|
@PluginMethod
|
|
86
|
-
public void
|
|
87
|
-
|
|
110
|
+
public void loadMedia(PluginCall call) {
|
|
111
|
+
String url = call.getString("url");
|
|
112
|
+
JSObject metadataObj = call.getObject("metadata");
|
|
113
|
+
|
|
114
|
+
String title = metadataObj != null ? metadataObj.getString("title") : null;
|
|
115
|
+
String subtitle = metadataObj != null ? metadataObj.getString("subtitle") : null;
|
|
116
|
+
String contentType = metadataObj != null ? metadataObj.getString("contentType") : null;
|
|
117
|
+
String imageUrl = null;
|
|
118
|
+
if (metadataObj != null && metadataObj.has("images")) {
|
|
119
|
+
try { imageUrl = metadataObj.getJSONArray("images").optString(0, null); } catch (Exception ignore) {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
boolean ok = implementation.loadMedia(url, title, subtitle, imageUrl, contentType);
|
|
88
123
|
JSObject ret = new JSObject();
|
|
89
|
-
ret.put("
|
|
90
|
-
|
|
91
|
-
|
|
124
|
+
ret.put("success", ok);
|
|
125
|
+
String err = implementation.getLastError();
|
|
126
|
+
if (err != null && !err.isEmpty()) ret.put("error", err);
|
|
127
|
+
if (ok) {
|
|
128
|
+
call.resolve(ret);
|
|
92
129
|
} else {
|
|
93
|
-
|
|
130
|
+
call.reject("Failed to load media", ret);
|
|
94
131
|
}
|
|
95
|
-
call.resolve(ret);
|
|
96
132
|
}
|
|
97
133
|
|
|
98
134
|
/**
|
|
99
|
-
*
|
|
135
|
+
* Revisa si hay dispositivos disponibles
|
|
100
136
|
*/
|
|
101
137
|
@PluginMethod
|
|
102
|
-
public void
|
|
103
|
-
|
|
104
|
-
JSObject metadataObj = call.getObject("metadata");
|
|
105
|
-
MediaMetadataCompat metadata = null;
|
|
106
|
-
if (metadataObj != null) {
|
|
107
|
-
MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder();
|
|
108
|
-
if (metadataObj.has("title")) builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadataObj.getString("title"));
|
|
109
|
-
if (metadataObj.has("subtitle")) builder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, metadataObj.getString("subtitle"));
|
|
110
|
-
if (metadataObj.has("images")) {
|
|
111
|
-
// Only use the first image for now
|
|
112
|
-
String img = metadataObj.getJSONArray("images").optString(0, null);
|
|
113
|
-
if (img != null) builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, img);
|
|
114
|
-
}
|
|
115
|
-
if (metadataObj.has("studio")) builder.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, metadataObj.getString("studio"));
|
|
116
|
-
if (metadataObj.has("contentType")) builder.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, metadataObj.getString("contentType"));
|
|
117
|
-
if (metadataObj.has("duration")) builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, metadataObj.getLong("duration"));
|
|
118
|
-
metadata = builder.build();
|
|
119
|
-
}
|
|
120
|
-
boolean success = implementation.loadMedia(url, metadata);
|
|
138
|
+
public void areDevicesAvailable(PluginCall call) {
|
|
139
|
+
boolean available = implementation.areDevicesAvailable();
|
|
121
140
|
JSObject ret = new JSObject();
|
|
122
|
-
ret.put("
|
|
123
|
-
|
|
124
|
-
ret.put("message", "Media sent to Cast device.");
|
|
125
|
-
call.resolve(ret);
|
|
126
|
-
} else {
|
|
127
|
-
ret.put("message", "Failed to send media. Check session and device.");
|
|
128
|
-
call.reject("Failed to send media", ret);
|
|
129
|
-
}
|
|
141
|
+
ret.put("available", available);
|
|
142
|
+
call.resolve(ret);
|
|
130
143
|
}
|
|
131
144
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ionic-chromecast",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Capacitor plugin for Google Cast SDK (Chromecast) integration with Android support",
|
|
5
5
|
"main": "dist/plugin.cjs.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -50,7 +50,10 @@
|
|
|
50
50
|
"build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
|
|
51
51
|
"clean": "rimraf ./dist",
|
|
52
52
|
"watch": "tsc --watch",
|
|
53
|
-
"prepublishOnly": "npm run build"
|
|
53
|
+
"prepublishOnly": "npm run build",
|
|
54
|
+
"android:run": "cd example-app && npm install && npx cap sync android && npx cap run android",
|
|
55
|
+
"android:serve": "cd example-app && npm install && npm run dev -- --host --port 5173",
|
|
56
|
+
"android:run:live": "cd example-app && npm install && npx cap sync android && npx cap run android --livereload --external --livereload-url=http://localhost:5173"
|
|
54
57
|
},
|
|
55
58
|
"devDependencies": {
|
|
56
59
|
"@capacitor/android": "^7.0.0",
|