@rocapine/expo-widget-activation 0.1.0

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 ADDED
@@ -0,0 +1,15 @@
1
+ # @rocapine/expo-widget-activation
2
+
3
+ Widget activation toolkit for Expo apps (iOS):
4
+
5
+ - `getInstalledWidgets()` — WidgetKit `getCurrentConfigurations`; `[]` anywhere the bridge is absent.
6
+ - `suspendApp()` — backgrounds the app (undocumented selector; accepted review risk; no-op off-iOS).
7
+ - `isWidgetInstalled(widgets, kind)` — predicate helper.
8
+ - `useWidgetDetection({ enabled, isTarget, onDetected })` — fires once the target widget appears on an app-foreground check.
9
+ - `@rocapine/expo-widget-activation/video` — `TutorialVideo` (muted, loop, initialTime, playbackRate, PiP) + `videoProgress`. Requires `expo-video`.
10
+
11
+ Install (git dep, dist committed):
12
+
13
+ "@rocapine/expo-widget-activation": "github:Rocapine/expo-widget-activation#v0.1.0"
14
+
15
+ Autolinks as a standard Expo module — new native build required after install.
@@ -0,0 +1,21 @@
1
+ interface InstalledWidget {
2
+ kind: string;
3
+ family: string;
4
+ }
5
+ declare function getInstalledWidgets(): Promise<InstalledWidget[]>;
6
+ declare function suspendApp(): void;
7
+ declare function isWidgetInstalled(widgets: InstalledWidget[], kind: string): boolean;
8
+ interface UseWidgetDetectionOptions {
9
+ enabled: boolean;
10
+ /** Returns true when the target widget is present in the installed set. */
11
+ isTarget: (widgets: InstalledWidget[]) => boolean;
12
+ onDetected: () => void;
13
+ }
14
+ /**
15
+ * While enabled, re-checks WidgetKit configurations on every foreground
16
+ * transition (the user may background/foreground several times before
17
+ * managing to add the widget) and fires onDetected once isTarget matches.
18
+ */
19
+ declare function useWidgetDetection({ enabled, isTarget, onDetected, }: UseWidgetDetectionOptions): void;
20
+
21
+ export { type InstalledWidget, type UseWidgetDetectionOptions, getInstalledWidgets, isWidgetInstalled, suspendApp, useWidgetDetection };
@@ -0,0 +1,21 @@
1
+ interface InstalledWidget {
2
+ kind: string;
3
+ family: string;
4
+ }
5
+ declare function getInstalledWidgets(): Promise<InstalledWidget[]>;
6
+ declare function suspendApp(): void;
7
+ declare function isWidgetInstalled(widgets: InstalledWidget[], kind: string): boolean;
8
+ interface UseWidgetDetectionOptions {
9
+ enabled: boolean;
10
+ /** Returns true when the target widget is present in the installed set. */
11
+ isTarget: (widgets: InstalledWidget[]) => boolean;
12
+ onDetected: () => void;
13
+ }
14
+ /**
15
+ * While enabled, re-checks WidgetKit configurations on every foreground
16
+ * transition (the user may background/foreground several times before
17
+ * managing to add the widget) and fires onDetected once isTarget matches.
18
+ */
19
+ declare function useWidgetDetection({ enabled, isTarget, onDetected, }: UseWidgetDetectionOptions): void;
20
+
21
+ export { type InstalledWidget, type UseWidgetDetectionOptions, getInstalledWidgets, isWidgetInstalled, suspendApp, useWidgetDetection };
package/dist/index.js ADDED
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ getInstalledWidgets: () => getInstalledWidgets,
24
+ isWidgetInstalled: () => isWidgetInstalled,
25
+ suspendApp: () => suspendApp,
26
+ useWidgetDetection: () => useWidgetDetection
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+ var import_react = require("react");
30
+ var import_react_native = require("react-native");
31
+ var import_expo_modules_core = require("expo-modules-core");
32
+ var widgetCenterModule = null;
33
+ if (import_react_native.Platform.OS === "ios") {
34
+ try {
35
+ widgetCenterModule = (0, import_expo_modules_core.requireNativeModule)("WidgetCenter");
36
+ } catch {
37
+ widgetCenterModule = null;
38
+ }
39
+ }
40
+ async function getInstalledWidgets() {
41
+ if (!widgetCenterModule) return [];
42
+ try {
43
+ return await widgetCenterModule.getInstalledWidgets();
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+ function suspendApp() {
49
+ widgetCenterModule?.suspendApp();
50
+ }
51
+ function isWidgetInstalled(widgets, kind) {
52
+ return widgets.some((widget) => widget.kind === kind);
53
+ }
54
+ function useWidgetDetection({
55
+ enabled,
56
+ isTarget,
57
+ onDetected
58
+ }) {
59
+ (0, import_react.useEffect)(
60
+ function detectWidgetOnForeground() {
61
+ if (!enabled) return;
62
+ let cancelled = false;
63
+ async function check() {
64
+ const widgets = await getInstalledWidgets();
65
+ if (!cancelled && isTarget(widgets)) {
66
+ onDetected();
67
+ }
68
+ }
69
+ const subscription = import_react_native.AppState.addEventListener("change", (nextState) => {
70
+ if (nextState === "active") {
71
+ void check();
72
+ }
73
+ });
74
+ return () => {
75
+ cancelled = true;
76
+ subscription.remove();
77
+ };
78
+ },
79
+ [enabled, isTarget, onDetected]
80
+ );
81
+ }
82
+ // Annotate the CommonJS export names for ESM import in node:
83
+ 0 && (module.exports = {
84
+ getInstalledWidgets,
85
+ isWidgetInstalled,
86
+ suspendApp,
87
+ useWidgetDetection
88
+ });
89
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { AppState, Platform } from \"react-native\";\nimport { requireNativeModule } from \"expo-modules-core\";\n\nexport interface InstalledWidget {\n kind: string;\n family: string;\n}\n\ninterface WidgetCenterModuleType {\n getInstalledWidgets(): Promise<InstalledWidget[]>;\n suspendApp(): void;\n}\n\nlet widgetCenterModule: WidgetCenterModuleType | null = null;\n\nif (Platform.OS === \"ios\") {\n try {\n // Jest and other non-native environments can report iOS without the bridge linked.\n widgetCenterModule =\n requireNativeModule<WidgetCenterModuleType>(\"WidgetCenter\");\n } catch {\n widgetCenterModule = null;\n }\n}\n\nexport async function getInstalledWidgets(): Promise<InstalledWidget[]> {\n if (!widgetCenterModule) return [];\n try {\n return await widgetCenterModule.getInstalledWidgets();\n } catch {\n // Treat \"can't read configurations\" as \"not installed\": callers always\n // keep a manual path available.\n return [];\n }\n}\n\nexport function suspendApp(): void {\n widgetCenterModule?.suspendApp();\n}\n\nexport function isWidgetInstalled(\n widgets: InstalledWidget[],\n kind: string\n): boolean {\n return widgets.some((widget) => widget.kind === kind);\n}\n\nexport interface UseWidgetDetectionOptions {\n enabled: boolean;\n /** Returns true when the target widget is present in the installed set. */\n isTarget: (widgets: InstalledWidget[]) => boolean;\n onDetected: () => void;\n}\n\n/**\n * While enabled, re-checks WidgetKit configurations on every foreground\n * transition (the user may background/foreground several times before\n * managing to add the widget) and fires onDetected once isTarget matches.\n */\nexport function useWidgetDetection({\n enabled,\n isTarget,\n onDetected,\n}: UseWidgetDetectionOptions) {\n useEffect(\n function detectWidgetOnForeground() {\n if (!enabled) return;\n\n let cancelled = false;\n\n async function check() {\n const widgets = await getInstalledWidgets();\n if (!cancelled && isTarget(widgets)) {\n onDetected();\n }\n }\n\n const subscription = AppState.addEventListener(\"change\", (nextState) => {\n if (nextState === \"active\") {\n void check();\n }\n });\n\n return () => {\n cancelled = true;\n subscription.remove();\n };\n },\n [enabled, isTarget, onDetected]\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA0B;AAC1B,0BAAmC;AACnC,+BAAoC;AAYpC,IAAI,qBAAoD;AAExD,IAAI,6BAAS,OAAO,OAAO;AACzB,MAAI;AAEF,6BACE,8CAA4C,cAAc;AAAA,EAC9D,QAAQ;AACN,yBAAqB;AAAA,EACvB;AACF;AAEA,eAAsB,sBAAkD;AACtE,MAAI,CAAC,mBAAoB,QAAO,CAAC;AACjC,MAAI;AACF,WAAO,MAAM,mBAAmB,oBAAoB;AAAA,EACtD,QAAQ;AAGN,WAAO,CAAC;AAAA,EACV;AACF;AAEO,SAAS,aAAmB;AACjC,sBAAoB,WAAW;AACjC;AAEO,SAAS,kBACd,SACA,MACS;AACT,SAAO,QAAQ,KAAK,CAAC,WAAW,OAAO,SAAS,IAAI;AACtD;AAcO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AACF,GAA8B;AAC5B;AAAA,IACE,SAAS,2BAA2B;AAClC,UAAI,CAAC,QAAS;AAEd,UAAI,YAAY;AAEhB,qBAAe,QAAQ;AACrB,cAAM,UAAU,MAAM,oBAAoB;AAC1C,YAAI,CAAC,aAAa,SAAS,OAAO,GAAG;AACnC,qBAAW;AAAA,QACb;AAAA,MACF;AAEA,YAAM,eAAe,6BAAS,iBAAiB,UAAU,CAAC,cAAc;AACtE,YAAI,cAAc,UAAU;AAC1B,eAAK,MAAM;AAAA,QACb;AAAA,MACF,CAAC;AAED,aAAO,MAAM;AACX,oBAAY;AACZ,qBAAa,OAAO;AAAA,MACtB;AAAA,IACF;AAAA,IACA,CAAC,SAAS,UAAU,UAAU;AAAA,EAChC;AACF;","names":[]}
package/dist/index.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // src/index.ts
2
+ import { useEffect } from "react";
3
+ import { AppState, Platform } from "react-native";
4
+ import { requireNativeModule } from "expo-modules-core";
5
+ var widgetCenterModule = null;
6
+ if (Platform.OS === "ios") {
7
+ try {
8
+ widgetCenterModule = requireNativeModule("WidgetCenter");
9
+ } catch {
10
+ widgetCenterModule = null;
11
+ }
12
+ }
13
+ async function getInstalledWidgets() {
14
+ if (!widgetCenterModule) return [];
15
+ try {
16
+ return await widgetCenterModule.getInstalledWidgets();
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+ function suspendApp() {
22
+ widgetCenterModule?.suspendApp();
23
+ }
24
+ function isWidgetInstalled(widgets, kind) {
25
+ return widgets.some((widget) => widget.kind === kind);
26
+ }
27
+ function useWidgetDetection({
28
+ enabled,
29
+ isTarget,
30
+ onDetected
31
+ }) {
32
+ useEffect(
33
+ function detectWidgetOnForeground() {
34
+ if (!enabled) return;
35
+ let cancelled = false;
36
+ async function check() {
37
+ const widgets = await getInstalledWidgets();
38
+ if (!cancelled && isTarget(widgets)) {
39
+ onDetected();
40
+ }
41
+ }
42
+ const subscription = AppState.addEventListener("change", (nextState) => {
43
+ if (nextState === "active") {
44
+ void check();
45
+ }
46
+ });
47
+ return () => {
48
+ cancelled = true;
49
+ subscription.remove();
50
+ };
51
+ },
52
+ [enabled, isTarget, onDetected]
53
+ );
54
+ }
55
+ export {
56
+ getInstalledWidgets,
57
+ isWidgetInstalled,
58
+ suspendApp,
59
+ useWidgetDetection
60
+ };
61
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { AppState, Platform } from \"react-native\";\nimport { requireNativeModule } from \"expo-modules-core\";\n\nexport interface InstalledWidget {\n kind: string;\n family: string;\n}\n\ninterface WidgetCenterModuleType {\n getInstalledWidgets(): Promise<InstalledWidget[]>;\n suspendApp(): void;\n}\n\nlet widgetCenterModule: WidgetCenterModuleType | null = null;\n\nif (Platform.OS === \"ios\") {\n try {\n // Jest and other non-native environments can report iOS without the bridge linked.\n widgetCenterModule =\n requireNativeModule<WidgetCenterModuleType>(\"WidgetCenter\");\n } catch {\n widgetCenterModule = null;\n }\n}\n\nexport async function getInstalledWidgets(): Promise<InstalledWidget[]> {\n if (!widgetCenterModule) return [];\n try {\n return await widgetCenterModule.getInstalledWidgets();\n } catch {\n // Treat \"can't read configurations\" as \"not installed\": callers always\n // keep a manual path available.\n return [];\n }\n}\n\nexport function suspendApp(): void {\n widgetCenterModule?.suspendApp();\n}\n\nexport function isWidgetInstalled(\n widgets: InstalledWidget[],\n kind: string\n): boolean {\n return widgets.some((widget) => widget.kind === kind);\n}\n\nexport interface UseWidgetDetectionOptions {\n enabled: boolean;\n /** Returns true when the target widget is present in the installed set. */\n isTarget: (widgets: InstalledWidget[]) => boolean;\n onDetected: () => void;\n}\n\n/**\n * While enabled, re-checks WidgetKit configurations on every foreground\n * transition (the user may background/foreground several times before\n * managing to add the widget) and fires onDetected once isTarget matches.\n */\nexport function useWidgetDetection({\n enabled,\n isTarget,\n onDetected,\n}: UseWidgetDetectionOptions) {\n useEffect(\n function detectWidgetOnForeground() {\n if (!enabled) return;\n\n let cancelled = false;\n\n async function check() {\n const widgets = await getInstalledWidgets();\n if (!cancelled && isTarget(widgets)) {\n onDetected();\n }\n }\n\n const subscription = AppState.addEventListener(\"change\", (nextState) => {\n if (nextState === \"active\") {\n void check();\n }\n });\n\n return () => {\n cancelled = true;\n subscription.remove();\n };\n },\n [enabled, isTarget, onDetected]\n );\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AAC1B,SAAS,UAAU,gBAAgB;AACnC,SAAS,2BAA2B;AAYpC,IAAI,qBAAoD;AAExD,IAAI,SAAS,OAAO,OAAO;AACzB,MAAI;AAEF,yBACE,oBAA4C,cAAc;AAAA,EAC9D,QAAQ;AACN,yBAAqB;AAAA,EACvB;AACF;AAEA,eAAsB,sBAAkD;AACtE,MAAI,CAAC,mBAAoB,QAAO,CAAC;AACjC,MAAI;AACF,WAAO,MAAM,mBAAmB,oBAAoB;AAAA,EACtD,QAAQ;AAGN,WAAO,CAAC;AAAA,EACV;AACF;AAEO,SAAS,aAAmB;AACjC,sBAAoB,WAAW;AACjC;AAEO,SAAS,kBACd,SACA,MACS;AACT,SAAO,QAAQ,KAAK,CAAC,WAAW,OAAO,SAAS,IAAI;AACtD;AAcO,SAAS,mBAAmB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AACF,GAA8B;AAC5B;AAAA,IACE,SAAS,2BAA2B;AAClC,UAAI,CAAC,QAAS;AAEd,UAAI,YAAY;AAEhB,qBAAe,QAAQ;AACrB,cAAM,UAAU,MAAM,oBAAoB;AAC1C,YAAI,CAAC,aAAa,SAAS,OAAO,GAAG;AACnC,qBAAW;AAAA,QACb;AAAA,MACF;AAEA,YAAM,eAAe,SAAS,iBAAiB,UAAU,CAAC,cAAc;AACtE,YAAI,cAAc,UAAU;AAC1B,eAAK,MAAM;AAAA,QACb;AAAA,MACF,CAAC;AAED,aAAO,MAAM;AACX,oBAAY;AACZ,qBAAa,OAAO;AAAA,MACtB;AAAA,IACF;AAAA,IACA,CAAC,SAAS,UAAU,UAAU;AAAA,EAChC;AACF;","names":[]}
@@ -0,0 +1,33 @@
1
+ import * as react from 'react';
2
+ import { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ /** Playback position mapped over the played range [initialTime, duration] to 0..1, clamped. */
5
+ declare function videoProgress(currentTime: number, initialTime: number, duration: number): number;
6
+ interface TutorialVideoHandle {
7
+ startPictureInPicture: () => Promise<void>;
8
+ }
9
+ interface TutorialVideoProps {
10
+ source: number;
11
+ /** Start position in seconds (e.g. skip the part covered by a previous step). */
12
+ initialTime?: number;
13
+ /** Playback speed multiplier (default 1). */
14
+ playbackRate?: number;
15
+ pipEnabled?: boolean;
16
+ /** Loop playback. Mutually exclusive with onEnded (a looping video never completes). */
17
+ loop?: boolean;
18
+ style?: StyleProp<ViewStyle>;
19
+ /** Reports playback position as 0..1 over the played range. */
20
+ onProgress?: (progress: number) => void;
21
+ /** Fires when playback reaches the end (loop off). */
22
+ onEnded?: () => void;
23
+ }
24
+ /**
25
+ * Muted tutorial player. It never pauses on backgrounding
26
+ * (staysActiveInBackground) so it has always progressed when the user comes
27
+ * back. With pipEnabled it detaches into PiP automatically when the app
28
+ * backgrounds (or programmatically via the handle); while in PiP the video
29
+ * loops over the full how-to, and playback resumes on screen when PiP ends.
30
+ */
31
+ declare const TutorialVideo: react.ForwardRefExoticComponent<TutorialVideoProps & react.RefAttributes<TutorialVideoHandle>>;
32
+
33
+ export { TutorialVideo, type TutorialVideoHandle, type TutorialVideoProps, videoProgress };
@@ -0,0 +1,33 @@
1
+ import * as react from 'react';
2
+ import { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ /** Playback position mapped over the played range [initialTime, duration] to 0..1, clamped. */
5
+ declare function videoProgress(currentTime: number, initialTime: number, duration: number): number;
6
+ interface TutorialVideoHandle {
7
+ startPictureInPicture: () => Promise<void>;
8
+ }
9
+ interface TutorialVideoProps {
10
+ source: number;
11
+ /** Start position in seconds (e.g. skip the part covered by a previous step). */
12
+ initialTime?: number;
13
+ /** Playback speed multiplier (default 1). */
14
+ playbackRate?: number;
15
+ pipEnabled?: boolean;
16
+ /** Loop playback. Mutually exclusive with onEnded (a looping video never completes). */
17
+ loop?: boolean;
18
+ style?: StyleProp<ViewStyle>;
19
+ /** Reports playback position as 0..1 over the played range. */
20
+ onProgress?: (progress: number) => void;
21
+ /** Fires when playback reaches the end (loop off). */
22
+ onEnded?: () => void;
23
+ }
24
+ /**
25
+ * Muted tutorial player. It never pauses on backgrounding
26
+ * (staysActiveInBackground) so it has always progressed when the user comes
27
+ * back. With pipEnabled it detaches into PiP automatically when the app
28
+ * backgrounds (or programmatically via the handle); while in PiP the video
29
+ * loops over the full how-to, and playback resumes on screen when PiP ends.
30
+ */
31
+ declare const TutorialVideo: react.ForwardRefExoticComponent<TutorialVideoProps & react.RefAttributes<TutorialVideoHandle>>;
32
+
33
+ export { TutorialVideo, type TutorialVideoHandle, type TutorialVideoProps, videoProgress };
package/dist/video.js ADDED
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/video.tsx
21
+ var video_exports = {};
22
+ __export(video_exports, {
23
+ TutorialVideo: () => TutorialVideo,
24
+ videoProgress: () => videoProgress
25
+ });
26
+ module.exports = __toCommonJS(video_exports);
27
+ var import_expo = require("expo");
28
+ var import_expo_video = require("expo-video");
29
+ var import_react = require("react");
30
+ var import_jsx_runtime = require("react/jsx-runtime");
31
+ function videoProgress(currentTime, initialTime, duration) {
32
+ if (duration <= 0 || duration <= initialTime) return 0;
33
+ const progress = (currentTime - initialTime) / (duration - initialTime);
34
+ return Math.max(0, Math.min(1, progress));
35
+ }
36
+ var TutorialVideo = (0, import_react.forwardRef)(function TutorialVideo2({
37
+ source,
38
+ initialTime,
39
+ playbackRate = 1,
40
+ pipEnabled = false,
41
+ loop = false,
42
+ style,
43
+ onProgress,
44
+ onEnded
45
+ }, ref) {
46
+ const videoViewRef = (0, import_react.useRef)(null);
47
+ const player = (0, import_expo_video.useVideoPlayer)(source, (player2) => {
48
+ player2.loop = loop;
49
+ player2.muted = true;
50
+ player2.staysActiveInBackground = true;
51
+ player2.playbackRate = playbackRate;
52
+ player2.timeUpdateEventInterval = 0.1;
53
+ if (initialTime !== void 0) {
54
+ player2.currentTime = initialTime;
55
+ }
56
+ player2.play();
57
+ });
58
+ const initialTimeValue = initialTime ?? 0;
59
+ (0, import_expo.useEventListener)(player, "timeUpdate", ({ currentTime }) => {
60
+ onProgress?.(videoProgress(currentTime, initialTimeValue, player.duration));
61
+ });
62
+ (0, import_expo.useEventListener)(player, "playToEnd", () => {
63
+ if (player.loop) return;
64
+ onEnded?.();
65
+ });
66
+ (0, import_react.useImperativeHandle)(
67
+ ref,
68
+ () => ({
69
+ startPictureInPicture: async () => {
70
+ try {
71
+ await videoViewRef.current?.startPictureInPicture();
72
+ } catch {
73
+ }
74
+ }
75
+ }),
76
+ []
77
+ );
78
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
79
+ import_expo_video.VideoView,
80
+ {
81
+ style: style ?? { flex: 1 },
82
+ ref: videoViewRef,
83
+ player,
84
+ nativeControls: false,
85
+ contentFit: "cover",
86
+ allowsPictureInPicture: pipEnabled,
87
+ startsPictureInPictureAutomatically: pipEnabled,
88
+ allowsVideoFrameAnalysis: false,
89
+ onPictureInPictureStart: () => {
90
+ if (player.duration > 0 && player.currentTime >= player.duration - 0.1) {
91
+ player.currentTime = 0;
92
+ }
93
+ player.loop = true;
94
+ player.play();
95
+ },
96
+ onPictureInPictureStop: () => {
97
+ player.loop = loop;
98
+ player.play();
99
+ }
100
+ }
101
+ );
102
+ });
103
+ // Annotate the CommonJS export names for ESM import in node:
104
+ 0 && (module.exports = {
105
+ TutorialVideo,
106
+ videoProgress
107
+ });
108
+ //# sourceMappingURL=video.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/video.tsx"],"sourcesContent":["import { useEventListener } from \"expo\";\nimport { useVideoPlayer, VideoView } from \"expo-video\";\nimport { forwardRef, useImperativeHandle, useRef } from \"react\";\nimport type { StyleProp, ViewStyle } from \"react-native\";\n\n/** Playback position mapped over the played range [initialTime, duration] to 0..1, clamped. */\nexport function videoProgress(\n currentTime: number,\n initialTime: number,\n duration: number\n): number {\n if (duration <= 0 || duration <= initialTime) return 0;\n const progress = (currentTime - initialTime) / (duration - initialTime);\n return Math.max(0, Math.min(1, progress));\n}\n\nexport interface TutorialVideoHandle {\n startPictureInPicture: () => Promise<void>;\n}\n\nexport interface TutorialVideoProps {\n source: number; // require()d asset\n /** Start position in seconds (e.g. skip the part covered by a previous step). */\n initialTime?: number;\n /** Playback speed multiplier (default 1). */\n playbackRate?: number;\n pipEnabled?: boolean;\n /** Loop playback. Mutually exclusive with onEnded (a looping video never completes). */\n loop?: boolean;\n style?: StyleProp<ViewStyle>;\n /** Reports playback position as 0..1 over the played range. */\n onProgress?: (progress: number) => void;\n /** Fires when playback reaches the end (loop off). */\n onEnded?: () => void;\n}\n\n/**\n * Muted tutorial player. It never pauses on backgrounding\n * (staysActiveInBackground) so it has always progressed when the user comes\n * back. With pipEnabled it detaches into PiP automatically when the app\n * backgrounds (or programmatically via the handle); while in PiP the video\n * loops over the full how-to, and playback resumes on screen when PiP ends.\n */\nexport const TutorialVideo = forwardRef<\n TutorialVideoHandle,\n TutorialVideoProps\n>(function TutorialVideo(\n {\n source,\n initialTime,\n playbackRate = 1,\n pipEnabled = false,\n loop = false,\n style,\n onProgress,\n onEnded,\n },\n ref\n) {\n const videoViewRef = useRef<VideoView>(null);\n\n const player = useVideoPlayer(source, (player) => {\n player.loop = loop;\n player.muted = true;\n player.staysActiveInBackground = true;\n player.playbackRate = playbackRate;\n player.timeUpdateEventInterval = 0.1;\n if (initialTime !== undefined) {\n player.currentTime = initialTime;\n }\n player.play();\n });\n\n const initialTimeValue = initialTime ?? 0;\n\n useEventListener(player, \"timeUpdate\", ({ currentTime }) => {\n onProgress?.(videoProgress(currentTime, initialTimeValue, player.duration));\n });\n\n useEventListener(player, \"playToEnd\", () => {\n // expo-video emits playToEnd on every loop iteration (it emits before\n // seeking back to zero). While looping — i.e. detached into PiP with the\n // app backgrounded — an end event is not a completion: reporting it\n // would tear down the UI out from under the user. Only report ends of\n // the on-screen, loop-off pass.\n if (player.loop) return;\n onEnded?.();\n });\n\n useImperativeHandle(\n ref,\n () => ({\n startPictureInPicture: async () => {\n try {\n await videoViewRef.current?.startPictureInPicture();\n } catch {\n // PiP unavailable (device/setting): the flow continues without it.\n }\n },\n }),\n []\n );\n\n return (\n <VideoView\n style={style ?? { flex: 1 }}\n ref={videoViewRef}\n player={player}\n nativeControls={false}\n contentFit=\"cover\"\n allowsPictureInPicture={pipEnabled}\n startsPictureInPictureAutomatically={pipEnabled}\n allowsVideoFrameAnalysis={false}\n onPictureInPictureStart={() => {\n // In the floating window the full how-to loops continuously. If the\n // on-screen playback had already finished (loop is off there), a\n // stale `loop = true` won't restart an ended player — rewind first.\n if (\n player.duration > 0 &&\n player.currentTime >= player.duration - 0.1\n ) {\n player.currentTime = 0;\n }\n player.loop = true;\n player.play();\n }}\n onPictureInPictureStop={() => {\n player.loop = loop;\n player.play();\n }}\n />\n );\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAiC;AACjC,wBAA0C;AAC1C,mBAAwD;AAsGpD;AAlGG,SAAS,cACd,aACA,aACA,UACQ;AACR,MAAI,YAAY,KAAK,YAAY,YAAa,QAAO;AACrD,QAAM,YAAY,cAAc,gBAAgB,WAAW;AAC3D,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,CAAC;AAC1C;AA6BO,IAAM,oBAAgB,yBAG3B,SAASA,eACT;AAAA,EACE;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf,aAAa;AAAA,EACb,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AACF,GACA,KACA;AACA,QAAM,mBAAe,qBAAkB,IAAI;AAE3C,QAAM,aAAS,kCAAe,QAAQ,CAACC,YAAW;AAChD,IAAAA,QAAO,OAAO;AACd,IAAAA,QAAO,QAAQ;AACf,IAAAA,QAAO,0BAA0B;AACjC,IAAAA,QAAO,eAAe;AACtB,IAAAA,QAAO,0BAA0B;AACjC,QAAI,gBAAgB,QAAW;AAC7B,MAAAA,QAAO,cAAc;AAAA,IACvB;AACA,IAAAA,QAAO,KAAK;AAAA,EACd,CAAC;AAED,QAAM,mBAAmB,eAAe;AAExC,oCAAiB,QAAQ,cAAc,CAAC,EAAE,YAAY,MAAM;AAC1D,iBAAa,cAAc,aAAa,kBAAkB,OAAO,QAAQ,CAAC;AAAA,EAC5E,CAAC;AAED,oCAAiB,QAAQ,aAAa,MAAM;AAM1C,QAAI,OAAO,KAAM;AACjB,cAAU;AAAA,EACZ,CAAC;AAED;AAAA,IACE;AAAA,IACA,OAAO;AAAA,MACL,uBAAuB,YAAY;AACjC,YAAI;AACF,gBAAM,aAAa,SAAS,sBAAsB;AAAA,QACpD,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF;AAAA,IACA,CAAC;AAAA,EACH;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,OAAO,SAAS,EAAE,MAAM,EAAE;AAAA,MAC1B,KAAK;AAAA,MACL;AAAA,MACA,gBAAgB;AAAA,MAChB,YAAW;AAAA,MACX,wBAAwB;AAAA,MACxB,qCAAqC;AAAA,MACrC,0BAA0B;AAAA,MAC1B,yBAAyB,MAAM;AAI7B,YACE,OAAO,WAAW,KAClB,OAAO,eAAe,OAAO,WAAW,KACxC;AACA,iBAAO,cAAc;AAAA,QACvB;AACA,eAAO,OAAO;AACd,eAAO,KAAK;AAAA,MACd;AAAA,MACA,wBAAwB,MAAM;AAC5B,eAAO,OAAO;AACd,eAAO,KAAK;AAAA,MACd;AAAA;AAAA,EACF;AAEJ,CAAC;","names":["TutorialVideo","player"]}
package/dist/video.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // src/video.tsx
2
+ import { useEventListener } from "expo";
3
+ import { useVideoPlayer, VideoView } from "expo-video";
4
+ import { forwardRef, useImperativeHandle, useRef } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ function videoProgress(currentTime, initialTime, duration) {
7
+ if (duration <= 0 || duration <= initialTime) return 0;
8
+ const progress = (currentTime - initialTime) / (duration - initialTime);
9
+ return Math.max(0, Math.min(1, progress));
10
+ }
11
+ var TutorialVideo = forwardRef(function TutorialVideo2({
12
+ source,
13
+ initialTime,
14
+ playbackRate = 1,
15
+ pipEnabled = false,
16
+ loop = false,
17
+ style,
18
+ onProgress,
19
+ onEnded
20
+ }, ref) {
21
+ const videoViewRef = useRef(null);
22
+ const player = useVideoPlayer(source, (player2) => {
23
+ player2.loop = loop;
24
+ player2.muted = true;
25
+ player2.staysActiveInBackground = true;
26
+ player2.playbackRate = playbackRate;
27
+ player2.timeUpdateEventInterval = 0.1;
28
+ if (initialTime !== void 0) {
29
+ player2.currentTime = initialTime;
30
+ }
31
+ player2.play();
32
+ });
33
+ const initialTimeValue = initialTime ?? 0;
34
+ useEventListener(player, "timeUpdate", ({ currentTime }) => {
35
+ onProgress?.(videoProgress(currentTime, initialTimeValue, player.duration));
36
+ });
37
+ useEventListener(player, "playToEnd", () => {
38
+ if (player.loop) return;
39
+ onEnded?.();
40
+ });
41
+ useImperativeHandle(
42
+ ref,
43
+ () => ({
44
+ startPictureInPicture: async () => {
45
+ try {
46
+ await videoViewRef.current?.startPictureInPicture();
47
+ } catch {
48
+ }
49
+ }
50
+ }),
51
+ []
52
+ );
53
+ return /* @__PURE__ */ jsx(
54
+ VideoView,
55
+ {
56
+ style: style ?? { flex: 1 },
57
+ ref: videoViewRef,
58
+ player,
59
+ nativeControls: false,
60
+ contentFit: "cover",
61
+ allowsPictureInPicture: pipEnabled,
62
+ startsPictureInPictureAutomatically: pipEnabled,
63
+ allowsVideoFrameAnalysis: false,
64
+ onPictureInPictureStart: () => {
65
+ if (player.duration > 0 && player.currentTime >= player.duration - 0.1) {
66
+ player.currentTime = 0;
67
+ }
68
+ player.loop = true;
69
+ player.play();
70
+ },
71
+ onPictureInPictureStop: () => {
72
+ player.loop = loop;
73
+ player.play();
74
+ }
75
+ }
76
+ );
77
+ });
78
+ export {
79
+ TutorialVideo,
80
+ videoProgress
81
+ };
82
+ //# sourceMappingURL=video.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/video.tsx"],"sourcesContent":["import { useEventListener } from \"expo\";\nimport { useVideoPlayer, VideoView } from \"expo-video\";\nimport { forwardRef, useImperativeHandle, useRef } from \"react\";\nimport type { StyleProp, ViewStyle } from \"react-native\";\n\n/** Playback position mapped over the played range [initialTime, duration] to 0..1, clamped. */\nexport function videoProgress(\n currentTime: number,\n initialTime: number,\n duration: number\n): number {\n if (duration <= 0 || duration <= initialTime) return 0;\n const progress = (currentTime - initialTime) / (duration - initialTime);\n return Math.max(0, Math.min(1, progress));\n}\n\nexport interface TutorialVideoHandle {\n startPictureInPicture: () => Promise<void>;\n}\n\nexport interface TutorialVideoProps {\n source: number; // require()d asset\n /** Start position in seconds (e.g. skip the part covered by a previous step). */\n initialTime?: number;\n /** Playback speed multiplier (default 1). */\n playbackRate?: number;\n pipEnabled?: boolean;\n /** Loop playback. Mutually exclusive with onEnded (a looping video never completes). */\n loop?: boolean;\n style?: StyleProp<ViewStyle>;\n /** Reports playback position as 0..1 over the played range. */\n onProgress?: (progress: number) => void;\n /** Fires when playback reaches the end (loop off). */\n onEnded?: () => void;\n}\n\n/**\n * Muted tutorial player. It never pauses on backgrounding\n * (staysActiveInBackground) so it has always progressed when the user comes\n * back. With pipEnabled it detaches into PiP automatically when the app\n * backgrounds (or programmatically via the handle); while in PiP the video\n * loops over the full how-to, and playback resumes on screen when PiP ends.\n */\nexport const TutorialVideo = forwardRef<\n TutorialVideoHandle,\n TutorialVideoProps\n>(function TutorialVideo(\n {\n source,\n initialTime,\n playbackRate = 1,\n pipEnabled = false,\n loop = false,\n style,\n onProgress,\n onEnded,\n },\n ref\n) {\n const videoViewRef = useRef<VideoView>(null);\n\n const player = useVideoPlayer(source, (player) => {\n player.loop = loop;\n player.muted = true;\n player.staysActiveInBackground = true;\n player.playbackRate = playbackRate;\n player.timeUpdateEventInterval = 0.1;\n if (initialTime !== undefined) {\n player.currentTime = initialTime;\n }\n player.play();\n });\n\n const initialTimeValue = initialTime ?? 0;\n\n useEventListener(player, \"timeUpdate\", ({ currentTime }) => {\n onProgress?.(videoProgress(currentTime, initialTimeValue, player.duration));\n });\n\n useEventListener(player, \"playToEnd\", () => {\n // expo-video emits playToEnd on every loop iteration (it emits before\n // seeking back to zero). While looping — i.e. detached into PiP with the\n // app backgrounded — an end event is not a completion: reporting it\n // would tear down the UI out from under the user. Only report ends of\n // the on-screen, loop-off pass.\n if (player.loop) return;\n onEnded?.();\n });\n\n useImperativeHandle(\n ref,\n () => ({\n startPictureInPicture: async () => {\n try {\n await videoViewRef.current?.startPictureInPicture();\n } catch {\n // PiP unavailable (device/setting): the flow continues without it.\n }\n },\n }),\n []\n );\n\n return (\n <VideoView\n style={style ?? { flex: 1 }}\n ref={videoViewRef}\n player={player}\n nativeControls={false}\n contentFit=\"cover\"\n allowsPictureInPicture={pipEnabled}\n startsPictureInPictureAutomatically={pipEnabled}\n allowsVideoFrameAnalysis={false}\n onPictureInPictureStart={() => {\n // In the floating window the full how-to loops continuously. If the\n // on-screen playback had already finished (loop is off there), a\n // stale `loop = true` won't restart an ended player — rewind first.\n if (\n player.duration > 0 &&\n player.currentTime >= player.duration - 0.1\n ) {\n player.currentTime = 0;\n }\n player.loop = true;\n player.play();\n }}\n onPictureInPictureStop={() => {\n player.loop = loop;\n player.play();\n }}\n />\n );\n});\n"],"mappings":";AAAA,SAAS,wBAAwB;AACjC,SAAS,gBAAgB,iBAAiB;AAC1C,SAAS,YAAY,qBAAqB,cAAc;AAsGpD;AAlGG,SAAS,cACd,aACA,aACA,UACQ;AACR,MAAI,YAAY,KAAK,YAAY,YAAa,QAAO;AACrD,QAAM,YAAY,cAAc,gBAAgB,WAAW;AAC3D,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,CAAC;AAC1C;AA6BO,IAAM,gBAAgB,WAG3B,SAASA,eACT;AAAA,EACE;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf,aAAa;AAAA,EACb,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AACF,GACA,KACA;AACA,QAAM,eAAe,OAAkB,IAAI;AAE3C,QAAM,SAAS,eAAe,QAAQ,CAACC,YAAW;AAChD,IAAAA,QAAO,OAAO;AACd,IAAAA,QAAO,QAAQ;AACf,IAAAA,QAAO,0BAA0B;AACjC,IAAAA,QAAO,eAAe;AACtB,IAAAA,QAAO,0BAA0B;AACjC,QAAI,gBAAgB,QAAW;AAC7B,MAAAA,QAAO,cAAc;AAAA,IACvB;AACA,IAAAA,QAAO,KAAK;AAAA,EACd,CAAC;AAED,QAAM,mBAAmB,eAAe;AAExC,mBAAiB,QAAQ,cAAc,CAAC,EAAE,YAAY,MAAM;AAC1D,iBAAa,cAAc,aAAa,kBAAkB,OAAO,QAAQ,CAAC;AAAA,EAC5E,CAAC;AAED,mBAAiB,QAAQ,aAAa,MAAM;AAM1C,QAAI,OAAO,KAAM;AACjB,cAAU;AAAA,EACZ,CAAC;AAED;AAAA,IACE;AAAA,IACA,OAAO;AAAA,MACL,uBAAuB,YAAY;AACjC,YAAI;AACF,gBAAM,aAAa,SAAS,sBAAsB;AAAA,QACpD,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF;AAAA,IACA,CAAC;AAAA,EACH;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,OAAO,SAAS,EAAE,MAAM,EAAE;AAAA,MAC1B,KAAK;AAAA,MACL;AAAA,MACA,gBAAgB;AAAA,MAChB,YAAW;AAAA,MACX,wBAAwB;AAAA,MACxB,qCAAqC;AAAA,MACrC,0BAA0B;AAAA,MAC1B,yBAAyB,MAAM;AAI7B,YACE,OAAO,WAAW,KAClB,OAAO,eAAe,OAAO,WAAW,KACxC;AACA,iBAAO,cAAc;AAAA,QACvB;AACA,eAAO,OAAO;AACd,eAAO,KAAK;AAAA,MACd;AAAA,MACA,wBAAwB,MAAM;AAC5B,eAAO,OAAO;AACd,eAAO,KAAK;AAAA,MACd;AAAA;AAAA,EACF;AAEJ,CAAC;","names":["TutorialVideo","player"]}
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["ios"],
3
+ "ios": {
4
+ "modules": ["WidgetCenterModule"]
5
+ }
6
+ }
@@ -0,0 +1,20 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = 'RocapineWidgetActivation'
3
+ s.version = '0.1.0'
4
+ s.summary = 'Read WidgetKit configurations and suspend the app'
5
+ s.description = 'Expo module exposing WidgetCenter.getCurrentConfigurations and an app-suspend helper for widget activation flows'
6
+ s.author = 'Rocapine'
7
+ s.homepage = 'https://github.com/Rocapine/expo-widget-activation'
8
+ s.platform = :ios, '15.1'
9
+ s.source = { git: 'https://github.com/Rocapine/expo-widget-activation.git' }
10
+ s.static_framework = true
11
+
12
+ s.dependency 'ExpoModulesCore'
13
+
14
+ s.pod_target_xcconfig = {
15
+ 'DEFINES_MODULE' => 'YES',
16
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
17
+ }
18
+
19
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
20
+ end
@@ -0,0 +1,32 @@
1
+ import ExpoModulesCore
2
+ import WidgetKit
3
+ import UIKit
4
+
5
+ public class WidgetCenterModule: Module {
6
+ public func definition() -> ModuleDefinition {
7
+ Name("WidgetCenter")
8
+
9
+ AsyncFunction("getInstalledWidgets") { (promise: Promise) in
10
+ WidgetCenter.shared.getCurrentConfigurations { result in
11
+ switch result {
12
+ case .success(let infos):
13
+ let widgets = infos.map { info -> [String: String] in
14
+ ["kind": info.kind, "family": String(describing: info.family)]
15
+ }
16
+ promise.resolve(widgets)
17
+ case .failure(let error):
18
+ promise.reject("WIDGET_CENTER_ERROR", error.localizedDescription)
19
+ }
20
+ }
21
+ }
22
+
23
+ Function("suspendApp") {
24
+ DispatchQueue.main.async {
25
+ // No public API exists to background an app. `suspend` is an
26
+ // undocumented UIApplication selector (accepted review risk).
27
+ // The UX degrades gracefully if this call is removed.
28
+ UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
29
+ }
30
+ }
31
+ }
32
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@rocapine/expo-widget-activation",
3
+ "version": "0.1.0",
4
+ "description": "Widget activation toolkit for Expo apps: WidgetKit installed-widget detection, app suspend, foreground detection hook, and a muted looping tutorial video player with PiP.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Rocapine/expo-widget-activation.git"
9
+ },
10
+ "main": "dist/index.js",
11
+ "module": "dist/index.mjs",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.mjs",
17
+ "require": "./dist/index.js"
18
+ },
19
+ "./video": {
20
+ "types": "./dist/video.d.ts",
21
+ "import": "./dist/video.mjs",
22
+ "require": "./dist/video.js"
23
+ },
24
+ "./expo-module.config.json": "./expo-module.config.json"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "ios",
30
+ "expo-module.config.json"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "clean": "rm -rf dist",
35
+ "type": "tsc --noEmit",
36
+ "test": "jest",
37
+ "prepack": "npm run build"
38
+ },
39
+ "peerDependencies": {
40
+ "expo": "*",
41
+ "expo-modules-core": "*",
42
+ "expo-video": "*",
43
+ "react": "*",
44
+ "react-native": "*"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "expo-video": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@types/jest": "^29.5.12",
53
+ "@types/react": "*",
54
+ "@types/react-test-renderer": "^19.1.0",
55
+ "expo": "^57.0.4",
56
+ "expo-modules-core": "^2.0.0",
57
+ "expo-video": "^57.0.0",
58
+ "jest": "^29.7.0",
59
+ "react": "^19.0.0",
60
+ "react-native": "^0.86.0",
61
+ "react-test-renderer": "^19.0.0",
62
+ "ts-jest": "^29.1.2",
63
+ "tsup": "^8.3.5",
64
+ "typescript": "^5.4.0"
65
+ }
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { useEffect } from "react";
2
+ import { AppState, Platform } from "react-native";
3
+ import { requireNativeModule } from "expo-modules-core";
4
+
5
+ export interface InstalledWidget {
6
+ kind: string;
7
+ family: string;
8
+ }
9
+
10
+ interface WidgetCenterModuleType {
11
+ getInstalledWidgets(): Promise<InstalledWidget[]>;
12
+ suspendApp(): void;
13
+ }
14
+
15
+ let widgetCenterModule: WidgetCenterModuleType | null = null;
16
+
17
+ if (Platform.OS === "ios") {
18
+ try {
19
+ // Jest and other non-native environments can report iOS without the bridge linked.
20
+ widgetCenterModule =
21
+ requireNativeModule<WidgetCenterModuleType>("WidgetCenter");
22
+ } catch {
23
+ widgetCenterModule = null;
24
+ }
25
+ }
26
+
27
+ export async function getInstalledWidgets(): Promise<InstalledWidget[]> {
28
+ if (!widgetCenterModule) return [];
29
+ try {
30
+ return await widgetCenterModule.getInstalledWidgets();
31
+ } catch {
32
+ // Treat "can't read configurations" as "not installed": callers always
33
+ // keep a manual path available.
34
+ return [];
35
+ }
36
+ }
37
+
38
+ export function suspendApp(): void {
39
+ widgetCenterModule?.suspendApp();
40
+ }
41
+
42
+ export function isWidgetInstalled(
43
+ widgets: InstalledWidget[],
44
+ kind: string
45
+ ): boolean {
46
+ return widgets.some((widget) => widget.kind === kind);
47
+ }
48
+
49
+ export interface UseWidgetDetectionOptions {
50
+ enabled: boolean;
51
+ /** Returns true when the target widget is present in the installed set. */
52
+ isTarget: (widgets: InstalledWidget[]) => boolean;
53
+ onDetected: () => void;
54
+ }
55
+
56
+ /**
57
+ * While enabled, re-checks WidgetKit configurations on every foreground
58
+ * transition (the user may background/foreground several times before
59
+ * managing to add the widget) and fires onDetected once isTarget matches.
60
+ */
61
+ export function useWidgetDetection({
62
+ enabled,
63
+ isTarget,
64
+ onDetected,
65
+ }: UseWidgetDetectionOptions) {
66
+ useEffect(
67
+ function detectWidgetOnForeground() {
68
+ if (!enabled) return;
69
+
70
+ let cancelled = false;
71
+
72
+ async function check() {
73
+ const widgets = await getInstalledWidgets();
74
+ if (!cancelled && isTarget(widgets)) {
75
+ onDetected();
76
+ }
77
+ }
78
+
79
+ const subscription = AppState.addEventListener("change", (nextState) => {
80
+ if (nextState === "active") {
81
+ void check();
82
+ }
83
+ });
84
+
85
+ return () => {
86
+ cancelled = true;
87
+ subscription.remove();
88
+ };
89
+ },
90
+ [enabled, isTarget, onDetected]
91
+ );
92
+ }
package/src/video.tsx ADDED
@@ -0,0 +1,133 @@
1
+ import { useEventListener } from "expo";
2
+ import { useVideoPlayer, VideoView } from "expo-video";
3
+ import { forwardRef, useImperativeHandle, useRef } from "react";
4
+ import type { StyleProp, ViewStyle } from "react-native";
5
+
6
+ /** Playback position mapped over the played range [initialTime, duration] to 0..1, clamped. */
7
+ export function videoProgress(
8
+ currentTime: number,
9
+ initialTime: number,
10
+ duration: number
11
+ ): number {
12
+ if (duration <= 0 || duration <= initialTime) return 0;
13
+ const progress = (currentTime - initialTime) / (duration - initialTime);
14
+ return Math.max(0, Math.min(1, progress));
15
+ }
16
+
17
+ export interface TutorialVideoHandle {
18
+ startPictureInPicture: () => Promise<void>;
19
+ }
20
+
21
+ export interface TutorialVideoProps {
22
+ source: number; // require()d asset
23
+ /** Start position in seconds (e.g. skip the part covered by a previous step). */
24
+ initialTime?: number;
25
+ /** Playback speed multiplier (default 1). */
26
+ playbackRate?: number;
27
+ pipEnabled?: boolean;
28
+ /** Loop playback. Mutually exclusive with onEnded (a looping video never completes). */
29
+ loop?: boolean;
30
+ style?: StyleProp<ViewStyle>;
31
+ /** Reports playback position as 0..1 over the played range. */
32
+ onProgress?: (progress: number) => void;
33
+ /** Fires when playback reaches the end (loop off). */
34
+ onEnded?: () => void;
35
+ }
36
+
37
+ /**
38
+ * Muted tutorial player. It never pauses on backgrounding
39
+ * (staysActiveInBackground) so it has always progressed when the user comes
40
+ * back. With pipEnabled it detaches into PiP automatically when the app
41
+ * backgrounds (or programmatically via the handle); while in PiP the video
42
+ * loops over the full how-to, and playback resumes on screen when PiP ends.
43
+ */
44
+ export const TutorialVideo = forwardRef<
45
+ TutorialVideoHandle,
46
+ TutorialVideoProps
47
+ >(function TutorialVideo(
48
+ {
49
+ source,
50
+ initialTime,
51
+ playbackRate = 1,
52
+ pipEnabled = false,
53
+ loop = false,
54
+ style,
55
+ onProgress,
56
+ onEnded,
57
+ },
58
+ ref
59
+ ) {
60
+ const videoViewRef = useRef<VideoView>(null);
61
+
62
+ const player = useVideoPlayer(source, (player) => {
63
+ player.loop = loop;
64
+ player.muted = true;
65
+ player.staysActiveInBackground = true;
66
+ player.playbackRate = playbackRate;
67
+ player.timeUpdateEventInterval = 0.1;
68
+ if (initialTime !== undefined) {
69
+ player.currentTime = initialTime;
70
+ }
71
+ player.play();
72
+ });
73
+
74
+ const initialTimeValue = initialTime ?? 0;
75
+
76
+ useEventListener(player, "timeUpdate", ({ currentTime }) => {
77
+ onProgress?.(videoProgress(currentTime, initialTimeValue, player.duration));
78
+ });
79
+
80
+ useEventListener(player, "playToEnd", () => {
81
+ // expo-video emits playToEnd on every loop iteration (it emits before
82
+ // seeking back to zero). While looping — i.e. detached into PiP with the
83
+ // app backgrounded — an end event is not a completion: reporting it
84
+ // would tear down the UI out from under the user. Only report ends of
85
+ // the on-screen, loop-off pass.
86
+ if (player.loop) return;
87
+ onEnded?.();
88
+ });
89
+
90
+ useImperativeHandle(
91
+ ref,
92
+ () => ({
93
+ startPictureInPicture: async () => {
94
+ try {
95
+ await videoViewRef.current?.startPictureInPicture();
96
+ } catch {
97
+ // PiP unavailable (device/setting): the flow continues without it.
98
+ }
99
+ },
100
+ }),
101
+ []
102
+ );
103
+
104
+ return (
105
+ <VideoView
106
+ style={style ?? { flex: 1 }}
107
+ ref={videoViewRef}
108
+ player={player}
109
+ nativeControls={false}
110
+ contentFit="cover"
111
+ allowsPictureInPicture={pipEnabled}
112
+ startsPictureInPictureAutomatically={pipEnabled}
113
+ allowsVideoFrameAnalysis={false}
114
+ onPictureInPictureStart={() => {
115
+ // In the floating window the full how-to loops continuously. If the
116
+ // on-screen playback had already finished (loop is off there), a
117
+ // stale `loop = true` won't restart an ended player — rewind first.
118
+ if (
119
+ player.duration > 0 &&
120
+ player.currentTime >= player.duration - 0.1
121
+ ) {
122
+ player.currentTime = 0;
123
+ }
124
+ player.loop = true;
125
+ player.play();
126
+ }}
127
+ onPictureInPictureStop={() => {
128
+ player.loop = loop;
129
+ player.play();
130
+ }}
131
+ />
132
+ );
133
+ });