@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 +15 -0
- package/dist/index.d.mts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +61 -0
- package/dist/index.mjs.map +1 -0
- package/dist/video.d.mts +33 -0
- package/dist/video.d.ts +33 -0
- package/dist/video.js +108 -0
- package/dist/video.js.map +1 -0
- package/dist/video.mjs +82 -0
- package/dist/video.mjs.map +1 -0
- package/expo-module.config.json +6 -0
- package/ios/RocapineWidgetActivation.podspec +20 -0
- package/ios/WidgetCenterModule.swift +32 -0
- package/package.json +66 -0
- package/src/index.ts +92 -0
- package/src/video.tsx +133 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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":[]}
|
package/dist/video.d.mts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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,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
|
+
});
|