@g9tm/lynxcontrols-native 0.0.0-stage → 1.4.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.
Files changed (70) hide show
  1. package/README.md +85 -2
  2. package/android/build.gradle.kts +45 -0
  3. package/android/consumer-rules.pro +3 -0
  4. package/android/src/main/AndroidManifest.xml +5 -0
  5. package/android/src/main/java/com/g9lynx/g9native/G9Bridge.kt +67 -0
  6. package/android/src/main/java/com/g9lynx/g9native/G9HostModule.kt +159 -0
  7. package/android/src/main/java/com/g9lynx/g9native/G9SqliteModule.kt +49 -0
  8. package/android/src/main/java/com/g9lynx/g9native/generated/G9HostModuleSpec.java +48 -0
  9. package/android/src/main/java/com/g9lynx/g9native/generated/G9SqliteModuleSpec.java +41 -0
  10. package/android/src/main/java/com/g9lynx/g9native/host/core/G9PreferencesCore.kt +100 -0
  11. package/android/src/main/java/com/g9lynx/g9native/sqlite/androidx/AndroidxSqlEngine.kt +88 -0
  12. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqlEngine.kt +61 -0
  13. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteCore.kt +260 -0
  14. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteService.kt +131 -0
  15. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteTransport.kt +125 -0
  16. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteValues.kt +71 -0
  17. package/android/src/main/java/com/g9lynx/g9native/video/G9VideoUI.kt +214 -0
  18. package/android/src/main/java/com/g9lynx/g9native/video/core/G9VideoCore.kt +67 -0
  19. package/dist/.tsbuildinfo +1 -0
  20. package/dist/driver.d.ts +20 -0
  21. package/dist/driver.d.ts.map +1 -0
  22. package/dist/driver.js +159 -0
  23. package/dist/driver.js.map +1 -0
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +3 -0
  27. package/dist/media.d.ts +60 -0
  28. package/dist/media.d.ts.map +1 -0
  29. package/dist/media.jsx +119 -0
  30. package/dist/media.jsx.map +1 -0
  31. package/dist/protocol.d.ts +115 -0
  32. package/dist/protocol.d.ts.map +1 -0
  33. package/dist/protocol.js +1 -0
  34. package/dist/services.d.ts +36 -0
  35. package/dist/services.d.ts.map +1 -0
  36. package/dist/services.js +111 -0
  37. package/dist/services.js.map +1 -0
  38. package/dist/transport.d.ts +10 -0
  39. package/dist/transport.d.ts.map +1 -0
  40. package/dist/transport.js +77 -0
  41. package/dist/transport.js.map +1 -0
  42. package/generated/G9HostModule.ts +71 -0
  43. package/generated/G9SqliteModule.ts +68 -0
  44. package/ios/Tests/G9NativeTestSuite.h +25 -0
  45. package/ios/Tests/G9NativeTestSuite.m +560 -0
  46. package/ios/Tests/G9NativeTests.m +52 -0
  47. package/ios/g9lynx-native.podspec +29 -0
  48. package/ios/src/G9HostModule.h +14 -0
  49. package/ios/src/G9HostModule.m +121 -0
  50. package/ios/src/G9SqliteModule.h +14 -0
  51. package/ios/src/G9SqliteModule.m +167 -0
  52. package/ios/src/G9VideoUI.h +21 -0
  53. package/ios/src/G9VideoUI.m +250 -0
  54. package/ios/src/core/G9HostPaths.h +20 -0
  55. package/ios/src/core/G9HostPaths.m +23 -0
  56. package/ios/src/core/G9PreferencesCore.h +43 -0
  57. package/ios/src/core/G9PreferencesCore.m +114 -0
  58. package/ios/src/core/G9SqliteCore.h +62 -0
  59. package/ios/src/core/G9SqliteCore.m +497 -0
  60. package/ios/src/core/G9SqliteTransport.h +16 -0
  61. package/ios/src/core/G9SqliteTransport.m +85 -0
  62. package/ios/src/core/G9VideoCore.h +26 -0
  63. package/ios/src/core/G9VideoCore.m +37 -0
  64. package/ios/src/generated/G9HostModuleSpec.h +23 -0
  65. package/ios/src/generated/G9HostModuleSpec.m +2 -0
  66. package/ios/src/generated/G9SqliteModuleSpec.h +20 -0
  67. package/ios/src/generated/G9SqliteModuleSpec.m +2 -0
  68. package/lynx.lib.json +12 -0
  69. package/package.json +49 -4
  70. package/types/platform-native-module.d.ts +46 -0
@@ -0,0 +1,121 @@
1
+ #import "G9HostModule.h"
2
+ #import <UIKit/UIKit.h>
3
+ #import "core/G9HostPaths.h"
4
+ #import "core/G9PreferencesCore.h"
5
+
6
+ typedef void (^G9Reply)(id envelope);
7
+
8
+ static NSDictionary *G9Ok(id value) {
9
+ return @{@"ok" : @YES, @"value" : value ?: [NSNull null]};
10
+ }
11
+
12
+ @implementation G9HostModule
13
+
14
+ + (NSString *)name {
15
+ return @"G9HostModule";
16
+ }
17
+
18
+ + (NSDictionary<NSString *, NSString *> *)methodLookup {
19
+ return @{
20
+ @"capabilities" : NSStringFromSelector(@selector(capabilities:)),
21
+ @"getPreference" : NSStringFromSelector(@selector(getPreference:callback:)),
22
+ @"setPreference" : NSStringFromSelector(@selector(setPreference:kind:value:callback:)),
23
+ @"preferenceEntries" : NSStringFromSelector(@selector(preferenceEntries:callback:)),
24
+ @"safeInsets" : NSStringFromSelector(@selector(safeInsets:)),
25
+ @"moveToBack" : NSStringFromSelector(@selector(moveToBack)),
26
+ @"hideKeyboard" : NSStringFromSelector(@selector(hideKeyboard)),
27
+ @"setSoftInputMode" : NSStringFromSelector(@selector(setSoftInputMode:)),
28
+ @"hapticTick" : NSStringFromSelector(@selector(hapticTick)),
29
+ @"appDataDirectory" : NSStringFromSelector(@selector(appDataDirectory:)),
30
+ @"assetUri" : NSStringFromSelector(@selector(assetUri:callback:)),
31
+ };
32
+ }
33
+
34
+ + (G9PreferencesCore *)preferences {
35
+ static G9PreferencesCore *core;
36
+ static dispatch_once_t once;
37
+ dispatch_once(&once, ^{
38
+ core = [[G9PreferencesCore alloc] initWithBackend:[[G9UserDefaultsBackend alloc] initWithSuiteName:@"g9lynx.preferences"]];
39
+ });
40
+ return core;
41
+ }
42
+
43
+ - (void)capabilities:(id)callback {
44
+ ((G9Reply)callback)(G9Ok(@{@"haptics" : @YES, @"softInputMode" : @NO, @"video" : @YES, @"platform" : @"ios"}));
45
+ }
46
+
47
+ - (void)getPreference:(NSString *)key callback:(id)callback {
48
+ ((G9Reply)callback)(G9Ok([[G9HostModule preferences] get:key]));
49
+ }
50
+
51
+ - (void)setPreference:(NSString *)key kind:(NSString *)kind value:(nullable NSString *)value callback:(id)callback {
52
+ NSDictionary *error = nil;
53
+ BOOL ok = [[G9HostModule preferences] set:key kind:kind value:value error:&error];
54
+ ((G9Reply)callback)(ok ? G9Ok(nil) : @{@"ok" : @NO, @"error" : error});
55
+ }
56
+
57
+ - (void)preferenceEntries:(NSString *)prefix callback:(id)callback {
58
+ ((G9Reply)callback)(G9Ok([[G9HostModule preferences] entries:prefix]));
59
+ }
60
+
61
+ - (void)appDataDirectory:(id)callback {
62
+ ((G9Reply)callback)(G9Ok([G9HostPaths appDataDirectory]));
63
+ }
64
+
65
+ - (void)assetUri:(NSString *)path callback:(id)callback {
66
+ NSString *uri = [G9HostPaths assetURIForPath:path resourceRoot:NSBundle.mainBundle.resourcePath];
67
+ if (uri != nil) {
68
+ ((G9Reply)callback)(G9Ok(uri));
69
+ } else {
70
+ NSString *message = [NSString stringWithFormat:@"The app ships no asset \"%@\".", path];
71
+ ((G9Reply)callback)(@{@"ok" : @NO, @"error" : @{@"code" : @"not-found", @"message" : message}});
72
+ }
73
+ }
74
+
75
+ static UIWindow *G9KeyWindow(void) {
76
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
77
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
78
+ for (UIWindow *window in ((UIWindowScene *)scene).windows) {
79
+ if (window.isKeyWindow) return window;
80
+ }
81
+ }
82
+ return nil;
83
+ }
84
+
85
+ - (void)safeInsets:(id)callback {
86
+ dispatch_async(dispatch_get_main_queue(), ^{
87
+ UIWindow *window = G9KeyWindow();
88
+ UIEdgeInsets insets = window != nil ? window.safeAreaInsets : UIEdgeInsetsZero;
89
+ BOOL rtl = [UIView userInterfaceLayoutDirectionForSemanticContentAttribute:window.semanticContentAttribute] ==
90
+ UIUserInterfaceLayoutDirectionRightToLeft;
91
+ ((G9Reply)callback)(G9Ok(@{
92
+ @"top" : @(insets.top),
93
+ @"bottom" : @(insets.bottom),
94
+ @"start" : @(rtl ? insets.right : insets.left),
95
+ @"end" : @(rtl ? insets.left : insets.right),
96
+ }));
97
+ });
98
+ }
99
+
100
+ // iOS has no programmatic "leave the app"; the system owns it (specs/06 §7).
101
+ - (void)moveToBack {
102
+ }
103
+
104
+ - (void)hideKeyboard {
105
+ dispatch_async(dispatch_get_main_queue(), ^{
106
+ [UIApplication.sharedApplication sendAction:@selector(resignFirstResponder) to:nil from:nil forEvent:nil];
107
+ });
108
+ }
109
+
110
+ // Soft-input modes are an Android concept.
111
+ - (void)setSoftInputMode:(NSString *)mode {
112
+ }
113
+
114
+ - (void)hapticTick {
115
+ dispatch_async(dispatch_get_main_queue(), ^{
116
+ UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
117
+ [generator impactOccurred];
118
+ });
119
+ }
120
+
121
+ @end
@@ -0,0 +1,14 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <Lynx/LynxModule.h>
3
+ #import "generated/G9SqliteModuleSpec.h"
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ // Lynx 4.0.3 (the iOS pods) has no @LynxNativeModuleRegister: hosts register the module on their LynxConfig
8
+ // (`[config registerModule:G9SqliteModule.class]`, apps/ios-host/Sources/GalleryViewController.m).
9
+
10
+ /** `NativeModules.G9SqliteModule` on iOS (plan/specs/06 §5): G9SqliteCore over the system sqlite3, one serial queue per session. */
11
+ @interface G9SqliteModule : NSObject <G9SqliteModuleSpec>
12
+ @end
13
+
14
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,167 @@
1
+ #import "G9SqliteModule.h"
2
+ #import "core/G9SqliteCore.h"
3
+ #import "core/G9SqliteTransport.h"
4
+
5
+ typedef void (^G9Reply)(id envelope);
6
+
7
+ static const NSInteger G9QueueCapacity = 256;
8
+
9
+ /** One process-wide core and its queues, so every LynxView of the app shares the open databases. */
10
+ @interface G9SqliteService : NSObject
11
+ @property(nonatomic, readonly) G9SqliteCore *core;
12
+ + (instancetype)shared;
13
+ - (void)submit:(nullable NSString *)session callback:(id)callback work:(id (^)(NSDictionary **error))work;
14
+ - (void)retire:(NSString *)session;
15
+ @end
16
+
17
+ @implementation G9SqliteService {
18
+ NSMutableDictionary<NSString *, dispatch_queue_t> *_queues;
19
+ NSMutableDictionary<NSString *, NSNumber *> *_pending;
20
+ dispatch_queue_t _shared;
21
+ }
22
+
23
+ + (instancetype)shared {
24
+ static G9SqliteService *service;
25
+ static dispatch_once_t once;
26
+ dispatch_once(&once, ^{
27
+ service = [[G9SqliteService alloc] init];
28
+ });
29
+ return service;
30
+ }
31
+
32
+ - (instancetype)init {
33
+ if ((self = [super init])) {
34
+ _core = [[G9SqliteCore alloc] init];
35
+ _queues = [NSMutableDictionary dictionary];
36
+ _pending = [NSMutableDictionary dictionary];
37
+ _shared = dispatch_queue_create("g9.sqlite.open", DISPATCH_QUEUE_SERIAL);
38
+ }
39
+ return self;
40
+ }
41
+
42
+ - (dispatch_queue_t)queueFor:(NSString *)session {
43
+ if (session == nil) return _shared;
44
+ @synchronized(self) {
45
+ dispatch_queue_t queue = _queues[session];
46
+ if (queue == nil) {
47
+ queue = dispatch_queue_create([[@"g9.sqlite." stringByAppendingString:session] UTF8String], DISPATCH_QUEUE_SERIAL);
48
+ _queues[session] = queue;
49
+ }
50
+ return queue;
51
+ }
52
+ }
53
+
54
+ - (void)submit:(NSString *)session callback:(id)callback work:(id (^)(NSDictionary **error))work {
55
+ G9Reply reply = (G9Reply)callback;
56
+ NSString *key = session ?: @"";
57
+ @synchronized(self) {
58
+ NSInteger pending = [_pending[key] integerValue];
59
+ if (pending >= G9QueueCapacity) {
60
+ if (reply) reply(@{@"ok" : @NO, @"error" : G9SqliteErrorMake(@"busy", @"Too many queued requests for this database session.")});
61
+ return;
62
+ }
63
+ _pending[key] = @(pending + 1);
64
+ }
65
+ dispatch_async([self queueFor:session], ^{
66
+ NSDictionary *error = nil;
67
+ id value = nil;
68
+ @autoreleasepool {
69
+ value = work(&error);
70
+ }
71
+ @synchronized(self) {
72
+ self->_pending[key] = @([self->_pending[key] integerValue] - 1);
73
+ }
74
+ if (reply) reply(error != nil ? @{@"ok" : @NO, @"error" : error} : @{@"ok" : @YES, @"value" : value ?: [NSNull null]});
75
+ });
76
+ }
77
+
78
+ - (void)retire:(NSString *)session {
79
+ @synchronized(self) {
80
+ [_queues removeObjectForKey:session];
81
+ [_pending removeObjectForKey:session];
82
+ }
83
+ }
84
+
85
+ @end
86
+
87
+ @implementation G9SqliteModule
88
+
89
+ + (NSString *)name {
90
+ return @"G9SqliteModule";
91
+ }
92
+
93
+ + (NSDictionary<NSString *, NSString *> *)methodLookup {
94
+ return @{
95
+ @"capabilities" : NSStringFromSelector(@selector(capabilities:)),
96
+ @"open" : NSStringFromSelector(@selector(open:callback:)),
97
+ @"run" : NSStringFromSelector(@selector(run:statements:options:callback:)),
98
+ @"begin" : NSStringFromSelector(@selector(begin:mode:callback:)),
99
+ @"commit" : NSStringFromSelector(@selector(commit:callback:)),
100
+ @"rollback" : NSStringFromSelector(@selector(rollback:callback:)),
101
+ @"close" : NSStringFromSelector(@selector(close:callback:)),
102
+ @"interrupt" : NSStringFromSelector(@selector(interrupt:)),
103
+ };
104
+ }
105
+
106
+ - (G9SqliteService *)service {
107
+ return [G9SqliteService shared];
108
+ }
109
+
110
+ - (void)capabilities:(id)callback {
111
+ G9SqliteCore *core = self.service.core;
112
+ [self.service submit:nil callback:callback work:^id(NSDictionary **error) {
113
+ return [core capabilitiesWithError:error];
114
+ }];
115
+ }
116
+
117
+ - (void)open:(id)options callback:(id)callback {
118
+ G9SqliteCore *core = self.service.core;
119
+ [self.service submit:nil callback:callback work:^id(NSDictionary **error) {
120
+ return [core open:[options isKindOfClass:[NSDictionary class]] ? options : @{} error:error];
121
+ }];
122
+ }
123
+
124
+ - (void)run:(NSString *)session statements:(id)statements options:(id)options callback:(id)callback {
125
+ G9SqliteCore *core = self.service.core;
126
+ NSDictionary *opts = [options isKindOfClass:[NSDictionary class]] ? options : @{};
127
+ [self.service submit:session callback:callback work:^id(NSDictionary **error) {
128
+ NSArray *results = [core run:session statements:[statements isKindOfClass:[NSArray class]] ? statements : @[] options:opts error:error];
129
+ return results == nil ? nil : [G9SqliteTransport encode:results transport:opts[@"transport"]];
130
+ }];
131
+ }
132
+
133
+ - (void)begin:(NSString *)session mode:(NSString *)mode callback:(id)callback {
134
+ G9SqliteCore *core = self.service.core;
135
+ [self.service submit:session callback:callback work:^id(NSDictionary **error) {
136
+ return [core begin:session mode:mode error:error];
137
+ }];
138
+ }
139
+
140
+ - (void)commit:(NSString *)tx callback:(id)callback {
141
+ G9SqliteCore *core = self.service.core;
142
+ [self.service submit:[core sessionOfTx:tx] callback:callback work:^id(NSDictionary **error) {
143
+ return [core commit:tx error:error] ? [NSNull null] : nil;
144
+ }];
145
+ }
146
+
147
+ - (void)rollback:(NSString *)tx callback:(id)callback {
148
+ G9SqliteCore *core = self.service.core;
149
+ [self.service submit:[core sessionOfTx:tx] callback:callback work:^id(NSDictionary **error) {
150
+ return [core rollback:tx error:error] ? [NSNull null] : nil;
151
+ }];
152
+ }
153
+
154
+ - (void)close:(NSString *)session callback:(id)callback {
155
+ G9SqliteService *service = self.service;
156
+ [service submit:session callback:callback work:^id(NSDictionary **error) {
157
+ BOOL closed = [service.core close:session error:error];
158
+ [service retire:session];
159
+ return closed ? [NSNull null] : nil;
160
+ }];
161
+ }
162
+
163
+ - (void)interrupt:(NSString *)session {
164
+ [self.service.core interrupt:session];
165
+ }
166
+
167
+ @end
@@ -0,0 +1,21 @@
1
+ #import <Lynx/LynxUI.h>
2
+ #import <UIKit/UIKit.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+
6
+ /** The element's view: its backing layer is an `AVPlayerLayer`. */
7
+ @interface G9VideoView : UIView
8
+ @end
9
+
10
+ /**
11
+ * The `<g9-video>` element on iOS (plan/specs/06 §7), the same contract as Android's `G9VideoUI.kt`: muted looping
12
+ * playback (`AVQueuePlayer` + `AVPlayerLooper`) for the intro carousel. Props: `src` (`file://…`, `http(s)://…`),
13
+ * `paused` (default YES), `loop`, `muted` (default YES), `start-seconds`, `near-end-seconds`, `object-fit`
14
+ * (`cover` | `contain`). Events: `ready` (first frame displayable), `nearend` `{ seconds }`, `looped`, `error`
15
+ * `{ code, message }`. Self-registers as `g9-video` (`LYNX_REGISTER_UI`); a host may also call
16
+ * `[config registerUI:G9VideoUI.class withName:@"g9-video"]`.
17
+ */
18
+ @interface G9VideoUI : LynxUI <G9VideoView *>
19
+ @end
20
+
21
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,250 @@
1
+ #import "G9VideoUI.h"
2
+ #import <AVFoundation/AVFoundation.h>
3
+ #import <Lynx/LynxComponentRegistry.h>
4
+ #import <Lynx/LynxEvent.h>
5
+ #import <Lynx/LynxEventEmitter.h>
6
+ #import <Lynx/LynxPropsProcessor.h>
7
+ #import <Lynx/LynxUIContext.h>
8
+ #import "core/G9VideoCore.h"
9
+ #include <math.h>
10
+
11
+ static void *G9VideoKVO = &G9VideoKVO;
12
+
13
+ @implementation G9VideoView
14
+
15
+ + (Class)layerClass {
16
+ return AVPlayerLayer.class;
17
+ }
18
+
19
+ - (AVPlayerLayer *)playerLayer {
20
+ return (AVPlayerLayer *)self.layer;
21
+ }
22
+
23
+ @end
24
+
25
+ @implementation G9VideoUI {
26
+ AVQueuePlayer *_player;
27
+ AVPlayerLooper *_looper;
28
+ id _timeObserver;
29
+ NSString *_src;
30
+ NSString *_preparedSrc;
31
+ BOOL _prepared;
32
+ BOOL _paused;
33
+ BOOL _loop;
34
+ BOOL _preparedLoop;
35
+ BOOL _muted;
36
+ BOOL _cover;
37
+ BOOL _startApplied;
38
+ BOOL _readySent;
39
+ BOOL _applyScheduled;
40
+ double _startSeconds;
41
+ NSInteger _loopCount;
42
+ G9VideoLoopTracker *_tracker;
43
+ }
44
+
45
+ LYNX_REGISTER_UI("g9-video")
46
+
47
+ - (instancetype)init {
48
+ if ((self = [super init])) {
49
+ _paused = YES;
50
+ _loop = YES;
51
+ _muted = YES;
52
+ _cover = YES;
53
+ _tracker = [[G9VideoLoopTracker alloc] init];
54
+ }
55
+ return self;
56
+ }
57
+
58
+ - (G9VideoView *)createView {
59
+ G9VideoView *view = [[G9VideoView alloc] init];
60
+ view.clipsToBounds = YES;
61
+ view.userInteractionEnabled = NO;
62
+ ((AVPlayerLayer *)view.layer).videoGravity = AVLayerVideoGravityResizeAspectFill;
63
+ return view;
64
+ }
65
+
66
+ LYNX_PROP_SETTER("src", setSrc, NSString *) {
67
+ _src = (requestReset || value.length == 0) ? nil : [value copy];
68
+ [self scheduleApply];
69
+ }
70
+
71
+ LYNX_PROP_SETTER("paused", setPaused, BOOL) {
72
+ _paused = requestReset ? YES : value;
73
+ [self scheduleApply];
74
+ }
75
+
76
+ LYNX_PROP_SETTER("loop", setLoop, BOOL) {
77
+ _loop = requestReset ? YES : value;
78
+ [self scheduleApply];
79
+ }
80
+
81
+ LYNX_PROP_SETTER("muted", setMuted, BOOL) {
82
+ _muted = requestReset ? YES : value;
83
+ [self scheduleApply];
84
+ }
85
+
86
+ LYNX_PROP_SETTER("start-seconds", setStartSeconds, double) {
87
+ _startSeconds = requestReset ? 0 : value;
88
+ }
89
+
90
+ LYNX_PROP_SETTER("near-end-seconds", setNearEndSeconds, double) {
91
+ _tracker.nearEndSeconds = requestReset ? 0 : value;
92
+ }
93
+
94
+ LYNX_PROP_SETTER("object-fit", setObjectFit, NSString *) {
95
+ _cover = requestReset || ![value isEqualToString:@"contain"];
96
+ [self scheduleApply];
97
+ }
98
+
99
+ // Props arrive one setter at a time; apply them together once per main-queue turn.
100
+ - (void)scheduleApply {
101
+ if (_applyScheduled) return;
102
+ _applyScheduled = YES;
103
+ __weak G9VideoUI *weakSelf = self;
104
+ dispatch_async(dispatch_get_main_queue(), ^{
105
+ [weakSelf apply];
106
+ });
107
+ }
108
+
109
+ - (void)apply {
110
+ _applyScheduled = NO;
111
+ ((AVPlayerLayer *)self.view.layer).videoGravity =
112
+ _cover ? AVLayerVideoGravityResizeAspectFill : AVLayerVideoGravityResizeAspect;
113
+ BOOL sourceChanged = !(_src == _preparedSrc || [_src isEqualToString:_preparedSrc]);
114
+ if (!_prepared || sourceChanged || _loop != _preparedLoop) [self prepare];
115
+ if (_player == nil) return;
116
+ _player.muted = _muted;
117
+ if (_paused) {
118
+ [_player pause];
119
+ } else {
120
+ [_player play];
121
+ }
122
+ }
123
+
124
+ - (void)prepare {
125
+ [self teardown];
126
+ _prepared = YES;
127
+ _preparedSrc = _src;
128
+ _preparedLoop = _loop;
129
+ if (_src == nil) return;
130
+ NSURL *url = [NSURL URLWithString:_src];
131
+ if (url == nil) {
132
+ [self send:@"error" detail:@{@"code" : @"invalid-src", @"message" : @"The video source is not a URL."}];
133
+ return;
134
+ }
135
+ _startApplied = NO;
136
+ _readySent = NO;
137
+ _loopCount = 0;
138
+ [_tracker looped];
139
+ AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url];
140
+ _player = [AVQueuePlayer queuePlayerWithItems:@[]];
141
+ _player.muted = _muted;
142
+ if (_loop) {
143
+ _looper = [AVPlayerLooper playerLooperWithPlayer:_player templateItem:item];
144
+ [_looper addObserver:self forKeyPath:@"loopCount" options:NSKeyValueObservingOptionNew context:G9VideoKVO];
145
+ [_looper addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:G9VideoKVO];
146
+ } else {
147
+ [_player insertItem:item afterItem:nil];
148
+ }
149
+ [_player addObserver:self forKeyPath:@"currentItem.status" options:NSKeyValueObservingOptionNew context:G9VideoKVO];
150
+ AVPlayerLayer *layer = (AVPlayerLayer *)self.view.layer;
151
+ layer.player = _player;
152
+ [layer addObserver:self forKeyPath:@"readyForDisplay" options:NSKeyValueObservingOptionNew context:G9VideoKVO];
153
+ __weak G9VideoUI *weakSelf = self;
154
+ _timeObserver = [_player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(0.1, 600)
155
+ queue:dispatch_get_main_queue()
156
+ usingBlock:^(CMTime time) {
157
+ [weakSelf tick:time];
158
+ }];
159
+ }
160
+
161
+ - (void)tick:(CMTime)time {
162
+ AVPlayerItem *item = _player.currentItem;
163
+ if (item == nil) return;
164
+ double duration = CMTimeGetSeconds(item.duration);
165
+ double position = CMTimeGetSeconds(time);
166
+ if (!isfinite(duration) || !isfinite(position)) return;
167
+ NSNumber *left = [_tracker progressPositionMs:position * 1000.0 durationMs:duration * 1000.0];
168
+ if (left != nil) [self send:@"nearend" detail:@{@"seconds" : left}];
169
+ }
170
+
171
+ - (void)observeValueForKeyPath:(NSString *)keyPath
172
+ ofObject:(id)object
173
+ change:(NSDictionary<NSKeyValueChangeKey, id> *)change
174
+ context:(void *)context {
175
+ if (context != G9VideoKVO) {
176
+ [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
177
+ return;
178
+ }
179
+ dispatch_async(dispatch_get_main_queue(), ^{
180
+ [self handleChange:keyPath of:object];
181
+ });
182
+ }
183
+
184
+ - (void)handleChange:(NSString *)keyPath of:(id)object {
185
+ if (_player == nil) return;
186
+ if ([keyPath isEqualToString:@"readyForDisplay"]) {
187
+ if (!_readySent && ((AVPlayerLayer *)self.view.layer).readyForDisplay) {
188
+ _readySent = YES;
189
+ [self send:@"ready" detail:@{}];
190
+ }
191
+ } else if ([keyPath isEqualToString:@"loopCount"]) {
192
+ if (_looper.loopCount > _loopCount) {
193
+ _loopCount = _looper.loopCount;
194
+ [_tracker looped];
195
+ [self send:@"looped" detail:@{}];
196
+ }
197
+ } else if ([keyPath isEqualToString:@"status"] && object == _looper) {
198
+ if (_looper.status == AVPlayerLooperStatusFailed) [self fail:_looper.error];
199
+ } else if ([keyPath isEqualToString:@"currentItem.status"]) {
200
+ AVPlayerItem *item = _player.currentItem;
201
+ if (item.status == AVPlayerItemStatusFailed) {
202
+ [self fail:item.error];
203
+ } else if (item.status == AVPlayerItemStatusReadyToPlay && !_startApplied) {
204
+ _startApplied = YES;
205
+ double duration = CMTimeGetSeconds(item.duration);
206
+ NSNumber *start = [G9VideoMath startPositionMsForSeconds:_startSeconds
207
+ durationMs:isfinite(duration) ? duration * 1000.0 : 0];
208
+ if (start != nil) {
209
+ [_player seekToTime:CMTimeMakeWithSeconds(start.doubleValue / 1000.0, 600)
210
+ toleranceBefore:kCMTimeZero
211
+ toleranceAfter:kCMTimeZero];
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ - (void)fail:(NSError *)error {
218
+ NSString *message = error.localizedDescription ?: @"The video could not be played.";
219
+ [self send:@"error" detail:@{@"code" : [NSString stringWithFormat:@"%ld", (long)error.code], @"message" : message}];
220
+ }
221
+
222
+ - (void)send:(NSString *)name detail:(NSDictionary *)detail {
223
+ LynxDetailEvent *event = [[LynxDetailEvent alloc] initWithName:name targetSign:self.sign detail:detail];
224
+ [self.context.eventEmitter dispatchCustomEvent:event];
225
+ }
226
+
227
+ - (void)teardown {
228
+ if (_player == nil) return;
229
+ AVPlayerLayer *layer = (AVPlayerLayer *)self.view.layer;
230
+ [layer removeObserver:self forKeyPath:@"readyForDisplay" context:G9VideoKVO];
231
+ [_player removeObserver:self forKeyPath:@"currentItem.status" context:G9VideoKVO];
232
+ if (_looper != nil) {
233
+ [_looper removeObserver:self forKeyPath:@"loopCount" context:G9VideoKVO];
234
+ [_looper removeObserver:self forKeyPath:@"status" context:G9VideoKVO];
235
+ [_looper disableLooping];
236
+ _looper = nil;
237
+ }
238
+ if (_timeObserver != nil) [_player removeTimeObserver:_timeObserver];
239
+ _timeObserver = nil;
240
+ [_player pause];
241
+ [_player removeAllItems];
242
+ layer.player = nil;
243
+ _player = nil;
244
+ }
245
+
246
+ - (void)dealloc {
247
+ [self teardown];
248
+ }
249
+
250
+ @end
@@ -0,0 +1,20 @@
1
+ // Where an iOS app's files are (plan/specs/06 §7): the app data directory and bundled assets. Foundation only, so the
2
+ // Linux harness (`npm run test:ios-core`) runs it.
3
+ #import <Foundation/Foundation.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ @interface G9HostPaths : NSObject
8
+ /**
9
+ * The app's private data directory, absolute (MAUI `FileSystem.AppDataDirectory` on iOS: the user-domain `Library`
10
+ * directory), created if missing, so an app keeps the files its MAUI version wrote.
11
+ */
12
+ + (NSString *)appDataDirectory;
13
+ /**
14
+ * The `file://` URI of `path` under `resourceRoot` (the main bundle's resources), or nil when the app does not ship
15
+ * it or the path escapes the root (`..`).
16
+ */
17
+ + (nullable NSString *)assetURIForPath:(NSString *)path resourceRoot:(NSString *)resourceRoot;
18
+ @end
19
+
20
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,23 @@
1
+ #import "G9HostPaths.h"
2
+
3
+ @implementation G9HostPaths
4
+
5
+ + (NSString *)appDataDirectory {
6
+ NSArray *found = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES);
7
+ NSString *path = [found count] > 0 ? [found objectAtIndex:0] : nil;
8
+ if (path == nil) path = [NSHomeDirectory() stringByAppendingPathComponent:@"Library"];
9
+ [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:NULL];
10
+ return path;
11
+ }
12
+
13
+ + (nullable NSString *)assetURIForPath:(NSString *)path resourceRoot:(NSString *)resourceRoot {
14
+ NSString *relative = path;
15
+ while ([relative hasPrefix:@"/"]) relative = [relative substringFromIndex:1];
16
+ if ([relative length] == 0 || [[relative pathComponents] containsObject:@".."]) return nil;
17
+ NSString *full = [resourceRoot stringByAppendingPathComponent:relative];
18
+ BOOL isDir = NO;
19
+ if (![[NSFileManager defaultManager] fileExistsAtPath:full isDirectory:&isDir] || isDir) return nil;
20
+ return [[NSURL fileURLWithPath:full] absoluteString];
21
+ }
22
+
23
+ @end
@@ -0,0 +1,43 @@
1
+ // The G9PreferenceStore operations of G9HostModule (plan/specs/06 §7), as on Android: typed values (a 32-bit integer is
2
+ // not readable as a string and vice versa), `null` string removes, prefix enumeration. Foundation only.
3
+ #import <Foundation/Foundation.h>
4
+
5
+ NS_ASSUME_NONNULL_BEGIN
6
+
7
+ /** Where values live: NSUserDefaults in the module, a dictionary in tests. */
8
+ @protocol G9PreferenceBackend <NSObject>
9
+ - (NSDictionary *)allValues;
10
+ - (void)setValue:(id)value forPreference:(NSString *)key;
11
+ - (void)removePreference:(NSString *)key;
12
+ @end
13
+
14
+ @interface G9PreferencesCore : NSObject {
15
+ @private
16
+ id<G9PreferenceBackend> _backend;
17
+ }
18
+ - (instancetype)initWithBackend:(id<G9PreferenceBackend>)backend;
19
+ /** `{ type: 'int' | 'string', value }` or NSNull. */
20
+ - (id)get:(NSString *)key;
21
+ /** `kind`: `int` (decimal text of a 32-bit integer), `string` (nil removes) or `remove`. NO with `error` on bad input. */
22
+ - (BOOL)set:(NSString *)key kind:(NSString *)kind value:(nullable NSString *)value error:(NSDictionary *_Nullable *_Nullable)error;
23
+ /** `[[key, value], …]` for keys starting with `prefix`, sorted by key. */
24
+ - (NSArray *)entries:(NSString *)prefix;
25
+ @end
26
+
27
+ /** NSUserDefaults with one suite per app (`g9lynx.preferences`). */
28
+ @interface G9UserDefaultsBackend : NSObject <G9PreferenceBackend> {
29
+ @private
30
+ NSUserDefaults *_defaults;
31
+ NSString *_suite;
32
+ }
33
+ - (instancetype)initWithSuiteName:(NSString *)suite;
34
+ @end
35
+
36
+ /** An in-memory backend (tests). */
37
+ @interface G9MemoryPreferenceBackend : NSObject <G9PreferenceBackend> {
38
+ @private
39
+ NSMutableDictionary *_values;
40
+ }
41
+ @end
42
+
43
+ NS_ASSUME_NONNULL_END