Author SHA1 Message Date
True Zhuanjia 691aa9c899 liquid glass for soft keyboard toolbar 2026-03-15 11:43:20 +08:00
True Zhuanjia 0dd419a5a3 tweak toolbox view 2026-03-14 14:15:16 +08:00
True Zhuanjia 1269c46f56 UI improvements: widget profile selector table 2026-03-14 11:50:07 +08:00
True Zhuanjia f8badd51c5 deal with osw profile selector table navigation bar 2026-03-12 18:10:39 +08:00
True Zhuanjia ed8bf5af9e deal with separator color 2026-03-12 16:45:50 +08:00
True Zhuanjia b2521de84e refactor themeManager 2026-03-12 16:15:52 +08:00
True Zhuanjia 165b5e6638 tweak settings menu navigation bar height 2026-03-12 10:19:08 +08:00
True Zhuanjia e742466409 app view and app view navigation bar 2026-03-12 09:54:11 +08:00
True Zhuanjia 201bf5b447 liquid glass: settings menu navigation bar 2026-03-11 22:26:50 +08:00
True Zhuanjia 7bed913a1e Merge branch 'Integration' into iOS26UI 2026-03-11 15:36:44 +08:00
True Zhuanjia e067fd8fa2 tweak menu section header 2026-03-09 12:09:06 +08:00
True Zhuanjia ba7d5feb81 enable liquid glass 2026-03-07 10:12:14 +08:00
True Zhuanjia 4a9e7ad2a1 iOS26 UI adaption: host view navigation bar 2026-03-07 10:08:17 +08:00
True Zhuanjia 4b56a44a47 UI improvements 2026-03-06 17:29:38 +08:00
True Zhuanjia bc98b43de2 secure folder unfolding fix at the end of animation 2026-03-05 19:39:28 +08:00
True Zhuanjia 16b3df5279 fixed: unexpected triggering of buttons collected by folder 2026-03-05 15:11:15 +08:00
True Zhuanjia d202f2f6e3 - remove auto codec option
- set default codec to HEVC
2026-03-05 11:10:46 +08:00
True Zhuanjia 142474eac8 - fixed: widget layout broken by windowing, entering background, orientationChange+PiP 2026-03-04 22:58:20 +08:00
True Zhuanjia acc89dba39 enable track point for onscreen widget edit mode 2026-03-04 13:06:22 +08:00
True Zhuanjia 8cd366d2cd update UI string 2026-02-28 18:44:24 +08:00
True Zhuanjia 2d67c1b22b update UI string 2026-02-28 12:30:32 +08:00
True Zhuanjia 77b0ef55c9 UI string update 2026-02-28 10:44:49 +08:00
True Zhuanjia d76f404a4c UI tip update 2026-02-27 00:48:46 +08:00
True Zhuanjia d0b45a019f minor fixes 2026-02-27 00:08:28 +08:00
True Zhuanjia 0728e999eb - add necessary UI tips
- restore "view all apps" for host card
- minor fixes
2026-02-26 22:32:37 +08:00
True Zhuanjia f325e9a206 default setting update 2026-02-26 18:39:16 +08:00
True Zhuanjia 49b03e65b2 feat: hover mode selector 2026-02-26 17:18:21 +08:00
True Zhuanjia 2bc18e2f02 add disable hovering switch 2026-02-26 00:29:57 +08:00
True Zhuanjia dadb292676 handle hovering automatically 2026-02-25 22:06:09 +08:00
True Zhuanjia 2c9902ce77 feat: auto hover 2026-02-25 00:38:24 +08:00
True Zhuanjia 6d36219e88 - fixed: unable to setup updated pressure curve mapping after switching widget profile during streaming 2026-02-24 19:47:20 +08:00
True Zhuanjia 470862dbe0 - fixed: unable to setup updated pressure curve mapping during streaming
- flexible pressure curve start point
- larger pressure curve view
2026-02-24 16:49:31 +08:00
True Zhuanjia f6017824ee default settings update 2026-02-24 00:32:35 +08:00
True Zhuanjia adeb77cf65 fixed cursor flickering on host 2026-02-23 22:57:46 +08:00
True Zhuanjia 9bc080815a update copyright 2026-02-23 20:48:25 +08:00
True Zhuanjia d03eebb5ba default settings update 2026-02-23 20:24:17 +08:00
True Zhuanjia ca453d8059 - tweak setting stack sequence
- auto set fullColorRangeSwitch
2026-02-23 19:37:17 +08:00
True Zhuanjia cb8126d201 UI fixes 2026-02-19 14:51:10 +08:00
True Zhuanjia 1626f30bb6 lowest latency option of queueSize slider 2026-02-19 13:23:30 +08:00
True Zhuanjia 47b5ec2678 default setting update flag 2026-02-19 01:39:18 +08:00
True Zhuanjia ccd032834d - restore asyncDequeue switch
- tweak frame buffering
2026-02-19 01:31:55 +08:00
True Zhuanjia c7ffd51cec pencil pro widget template import changes 2026-02-18 13:01:08 +08:00
True Zhuanjia 38f8c50d7f native touch track point fix 2026-02-18 08:44:56 +08:00
True Zhuanjia fd653b87af allow sdr workaround on av1 2026-02-18 07:59:52 +08:00
True Zhuanjia 9bf02e477b UI string update 2026-02-14 19:59:55 +08:00
True Zhuanjia 4440c668f1 revert pencil force tweak 2026-02-14 13:55:53 +08:00
True Zhuanjia 63d5417a2c widget profile accessibility 2026-02-14 12:43:31 +08:00
True Zhuanjia 28d9a0faf2 update aboutView 2026-02-14 02:07:28 +08:00
True Zhuanjia 04845df09c enable onscreen widget for pencil pro pack 2026-02-14 01:01:48 +08:00
True Zhuanjia 85df511a23 pencil pro widget profile 2026-02-14 00:14:25 +08:00
True Zhuanjia 4ecb8bfede - fixed: onscreen widget level
- flush frames when entering background
2026-02-13 23:51:31 +08:00
True Zhuanjia 6008865a41 functional button rule update 2026-02-13 23:19:10 +08:00
True Zhuanjia 02926b2c6b widget profile localization 2026-02-13 22:55:00 +08:00
True Zhuanjia 49c5fd308e button sliding fix 2026-02-13 21:31:05 +08:00
True Zhuanjia 7eeff3bc35 hide queueSize stack during streaming 2026-02-13 14:30:25 +08:00
True Zhuanjia 6e99889681 update profile template binaries 2026-02-13 14:19:47 +08:00
True Zhuanjia 8cbd09fe9a update default profile localization 2026-02-12 23:42:36 +08:00
True Zhuanjia edc703c534 allow default profile overwritten by stream view changes 2026-02-12 23:24:35 +08:00
True Zhuanjia f85d545c17 slid&Hold folder stream view guideline 2026-02-12 23:12:23 +08:00
True Zhuanjia 0830173e59 - double tap to make slid&Hold folder movable
- folder state restore improvements
- default profile location ready
2026-02-12 22:54:20 +08:00
True Zhuanjia 0e21e3a9ed Merge commit 'a9ede7a904b1fed1984c28803cf179b41ed257f6' into Integration 2026-02-12 16:03:27 +08:00
True Zhuanjia 6356427454 enable track point for all on screen widgets 2026-02-12 15:51:02 +08:00
True Zhuanjia 4dc6220f28 touch track point improvements 2026-02-12 15:33:43 +08:00
True Zhuanjia 03fcfaff28 sdr performance workaround fix 2026-02-12 14:04:42 +08:00
True Zhuanjia d5c96f77cb highlight touchpad widget while being folded & unfolded 2026-02-11 23:24:17 +08:00
True Zhuanjia bb6f2f0c0d feat: track point for native touch 2026-02-11 22:56:50 +08:00
True Zhuanjia 8d54ed9316 feat: on screen widget touch track point 2026-02-11 21:43:33 +08:00
True Zhuanjia 44123c74d9 crash fixes 2026-02-11 18:54:45 +08:00
True Zhuanjia fdf2548fe3 remove simple & complete legacy on screen controls 2026-02-11 17:04:00 +08:00
True Zhuanjia bee72bd854 - better widget profile update strategy
- feat: restore widget templates
2026-02-11 16:41:46 +08:00
True Zhuanjia 8ec8f32935 crash fixes 2026-02-11 15:00:23 +08:00
True Zhuanjia 1d33feec8b sdr performance workaround fix 2026-02-11 11:43:00 +08:00
True Zhuanjia 80af6a9636 movable button stream view guideline displayed without dragging & moving 2026-02-11 11:26:02 +08:00
True Zhuanjia 94eb4dd8a0 try to fix again:
fixed: squeeze shortcut keys can't be held down
2026-02-11 10:51:47 +08:00
True Zhuanjia d3ce55cbd6 hide sdr performance workaround stack during streaming 2026-02-10 20:16:59 +08:00
True Zhuanjia 0b942caa98 fixed: squeeze shortcut keys can't be held down 2026-02-10 20:12:15 +08:00
True Zhuanjia afc6289a56 - disable sdr performance workaround for av1
- UI string changes
2026-02-10 19:54:53 +08:00
True Zhuanjia 79684598ab learn more action for rendering backend tip 2026-02-10 17:17:35 +08:00
True Zhuanjia b901f2f506 UI string updates 2026-02-10 16:52:35 +08:00
True Zhuanjia 14294009f0 feat: pencil squeeze shortcut 2026-02-10 15:55:36 +08:00
True Zhuanjia adbebd23c8 temporarily disable pencil in-app-purchase for iOS14 and lower OS 2026-02-09 22:43:02 +08:00
True Zhuanjia 3973e13706 feat: disablePencilSlideGestureSwitch 2026-02-09 20:59:41 +08:00
True Zhuanjia e56e68dd6f external display mode settings menu UI string 2026-02-09 17:28:56 +08:00
True Zhuanjia c3fcff8d29 functional button fixes 2026-02-09 16:41:48 +08:00
True Zhuanjia 0152f48886 add mouseButton support for pencil side tap & toolbox menu 2026-02-09 16:19:42 +08:00
True Zhuanjia bbd2e37067 - rename:
balanced mode to standard,
performance mode to metal.

- renderer message text update & menu action fix
2026-02-09 14:50:59 +08:00
True Zhuanjia 8377b5062a default settings update 2026-02-09 09:50:59 +08:00
True Zhuanjia 086f1af974 runtime default setting update changes 2026-02-09 00:16:39 +08:00
True Zhuanjia 652e989927 frame queue tweaks 2026-02-08 23:52:07 +08:00
True Zhuanjia 5480e15595 resume video rendering when app returns from background in legacy pacing mode 2026-02-07 18:05:03 +08:00
True Zhuanjia 375670beb4 sdr performance workaround 2026-02-07 17:00:25 +08:00
True Zhuanjia fa164f0e02 - reduce pressure curve tool tips
- fixed crash in pressure curve tool drawing stage
- initial pencil move force tweak
- frame queue tweaks demo
2026-02-06 15:27:29 +08:00
True Zhuanjia a9ede7a904 fixed: touchPad double tap button not released 2026-02-03 16:13:00 +08:00
True Zhuanjia fa32d157ca load pencil section again 2026-02-02 15:52:48 +08:00
True Zhuanjia 10839f88d8 hide pencil section for 3.2.9 2026-02-02 15:06:51 +08:00
True Zhuanjia 8fa909dea8 tell apart regular & pencil TF 2026-02-02 12:54:59 +08:00
True Zhuanjia 94250624a8 fix renamed var 2026-02-02 11:59:16 +08:00
True Zhuanjia 6f264441ab Merge branch 'Integration' into stylus3
# Conflicts:
#	VoidLink/ViewControllers/StreamFrameViewController.m
2026-02-02 11:56:47 +08:00
True Zhuanjia 03cf463182 dummy event fix 2026-02-02 11:53:50 +08:00
True Zhuanjia 3e29501b23 safetimer for oc 2026-02-02 10:38:38 +08:00
True Zhuanjia 600b882929 restoring fold/unfold states 2026-02-01 16:02:57 +08:00
True Zhuanjia be69af925a folder button minor improvements 2026-02-01 11:26:39 +08:00
True Zhuanjia d14ee640c5 - exclusive folder fix
- other minor fixes
2026-02-01 10:20:12 +08:00
True Zhuanjia ac6c688054 feat: "folder" onscreen button 2026-01-31 20:59:24 +08:00
True Zhuanjia 8f8e86ab4f - on screen button “folder” prototype
- overlapped buttons triggered at touch began
- memory management fixes
- reduce toolbox view & widget layout tool animation
- fast widget profile loading
- toolbox & widget profile selector quick dismiss
2026-01-29 10:06:04 +08:00
True Zhuanjia ab1d6bfa90 storekit2 in app purchase implementation 2026-01-14 20:47:29 +08:00
True Zhuanjia e038601b37 host info updated tip 2026-01-14 10:41:20 +08:00
TrueZhuanjia 008beb9444 Merge branch 'Integration' into stylus3 2026-01-09 11:21:25 +08:00
TrueZhuanjia 835d9c9ef6 fixed: unable to display ipv6 address correctly by long pressing host card view 2026-01-09 11:19:32 +08:00
TrueZhuanjia e29e5bd650 fixed: app crash caused by detecting hosts with alternative IPs 2026-01-09 10:48:24 +08:00
TrueZhuanjia fecdbec31c fixed: unable to save pressure curve to oscProfile in certain cases 2026-01-08 20:28:18 +08:00
TrueZhuanjia 66fdb607d8 Merge branch 'Integration' into stylus3 2026-01-08 18:26:21 +08:00
TrueZhuanjia 6d4f192cbe pressure curve implementation 2026-01-08 16:51:41 +08:00
TrueZhuanjia 4a59af7c20 fixed: app crash caused by controller button string utility 2026-01-07 10:50:12 +08:00
TrueZhuanjia 156b363049 Merge branch 'Integration' into stylus3 2025-12-31 10:55:51 +08:00
TrueZhuanjia c68540c169 allow touchpad widget border width to be zero 2025-12-31 10:08:32 +08:00
TrueZhuanjia f89372e265 Merge branch 'Integration' into stylus3 2025-12-29 17:26:59 +08:00
TrueZhuanjia c134dffec2 turn off unnecessary logs 2025-12-29 16:43:28 +08:00
TrueZhuanjia c2d4527d7e separate X & Y decelerationRate slider 2025-12-29 16:41:07 +08:00
TrueZhuanjia 2071b89d18 new preset on-screen widget profiles 20251229 2025-12-29 16:34:29 +08:00
TrueZhuanjia 46902afdc4 tweak wasdPad center icon 2025-12-29 14:48:33 +08:00
TrueZhuanjia d23e640977 minor fixes 2025-12-28 22:30:40 +08:00
TrueZhuanjia 9c18ab4142 prewarm soft keyboard on app launch 2025-12-28 22:19:30 +08:00
TrueZhuanjia 22e270c4df improve on-screen touchPad stroke 2025-12-28 22:09:15 +08:00
TrueZhuanjia 16bd288e70 preset profile editing 2025-12-28 20:50:21 +08:00
TrueZhuanjia 39643153df Merge branch 'Integration' into stylus3 2025-12-27 20:44:19 +08:00
TrueZhuanjia 2cf9fdfd40 fixed: unable to send touchPad widget to bottom in streamview 2025-12-27 20:43:10 +08:00
TrueZhuanjia f5226562ae Merge branch 'Integration' into stylus3 2025-12-27 14:52:19 +08:00
TrueZhuanjia 32ad5d2f8a highlight border when moving on-screen widgets in layout tool 2025-12-27 14:41:23 +08:00
TrueZhuanjia 245f47f660 always send touchPad widgets to bottom 2025-12-27 14:32:41 +08:00
TrueZhuanjia 17e3fd07f9 quick double tap button for lsWheel/rsWheel 2025-12-27 14:15:51 +08:00
TrueZhuanjia cdbe480863 - directionPad improvements
- feat: highlight alpha tweak
2025-12-27 12:54:46 +08:00
TrueZhuanjia 189ad76f38 - change stroke style, use rounded font style for on-screen widget label 2025-12-26 20:33:21 +08:00
TrueZhuanjia d8528fdc81 - feat: new on-screen widget lsWheel/rsWheel, direction wheel for RPG games.
- feat: direct walkmode test for lsWheel/rsWheel
- feat: direction minimum offset test for all on-screen widgets, gyro->stick mapping & physical controller.
2025-12-26 16:17:55 +08:00
TrueZhuanjia c257a8cab0 Merge branch 'Integration' into stylus3 2025-12-23 01:35:46 +08:00
TrueZhuanjia 2912a3a5cb feat: multi-IP storage for host (tested with zeroTier) 2025-12-23 01:15:37 +08:00
TrueZhuanjia 75f1d4d392 restore content offset after settings menu being loaded for the first time 2025-12-22 15:35:46 +08:00
TrueZhuanjia a6159cc785 Merge branch 'Integration' into stylus3 2025-12-22 10:53:14 +08:00
TrueZhuanjia 38affb4981 add stereo(SDL) audio option 2025-12-22 10:51:33 +08:00
TrueZhuanjia e64090d88f feat: ability to resume mic redirection after a phone call 2025-12-20 20:19:08 +08:00
TrueZhuanjia ef60aeeead fixed: in stereo mode, audio playback can't be resumed after a phone call is ended 2025-12-19 22:27:05 +08:00
TrueZhuanjia 25112b2c27 Merge branch 'Integration' into stylus3 2025-12-13 20:40:57 +08:00
TrueZhuanjia c0b1326dbd fixed: unable to turn on-screen widget on/off during streaming 2025-12-13 20:39:43 +08:00
TrueZhuanjia f77e533dd6 Merge branch 'Integration' into stylus3 2025-12-12 23:47:47 +08:00
TrueZhuanjia 4b001f9366 vertical decelerationRate for inertial touchPads (trackball/LsvPad/RsvPad) 2025-12-12 23:28:19 +08:00
TrueZhuanjia 41eadce89a synthesize rsvPad inertia + gyro input convergently 2025-12-12 21:31:12 +08:00
TrueZhuanjia 46194a5aa0 fixed: tap to toggle button not release after being cleared
reload on-screen widgets only when necessary
2025-12-11 16:40:47 +08:00
TrueZhuanjia 3630880454 - fixed: mic redirection was enabled by default
- fixed: micHandler timer memory leak
- tweak touchPad scroll inertia
2025-12-10 10:38:42 +08:00
TrueZhuanjia a59ffacd3a Merge branch 'Integration' into stylus3 2025-12-09 22:50:45 +08:00
TrueZhuanjia 03817bcfd7 inertial scroll for touchpad gesture 2025-12-09 22:49:34 +08:00
TrueZhuanjia 2c4751bef9 Merge branch 'Integration' into stylus3 2025-12-09 16:40:58 +08:00
TrueZhuanjia 7caa778ff8 - fixed memory leaks caused by on-screen button auto-tap timer
- feat: Inertial RsvPad/LsvPad
2025-12-09 16:38:28 +08:00
TrueZhuanjia 3977d342ba disable pencil tick based on timestamp 2025-12-07 10:13:25 +08:00
TrueZhuanjia 4e83421104 on-screen button manual hover for low-end iPads 2025-12-06 11:22:19 +08:00
TrueZhuanjia adafacbf81 - feat: on-screen button "oscHome" 2025-12-05 10:28:08 +08:00
TrueZhuanjia 053893005b rename varialbles 2025-12-05 10:15:53 +08:00
TrueZhuanjia 838af88d64 minor change 2025-12-04 17:56:14 +08:00
TrueZhuanjia 573ed07b4f Merge branch 'Integration' into stylus3
full color range switch
2025-12-03 13:24:22 +08:00
TrueZhuanjia aad8673ff0 UI tweaks for full color range switch 2025-12-03 13:21:55 +08:00
TrueZhuanJia b6a58ea8ab Merge pull request #160 from Acaki/fix/Metal_AV1_SDR
Fix Metal AV1 SDR color range issue
2025-12-03 08:29:24 +08:00
TrueZhuanjia 8c7bf18b65 tweak touchUp delay 2025-12-02 22:09:00 +08:00
Acaki 3823c95582 feat: add toggle for full range and disable toggles for unsupported codecs 2025-12-02 21:26:09 +08:00
TrueZhuanjia ad0f3dca30 TF bundle ID 2025-12-02 20:36:21 +08:00
TrueZhuanjia 5248bbe9c3 default tickInterval to 1500 2025-12-02 17:39:52 +08:00
TrueZhuanjia 1262a7b41f advancedPencilSupport-demo1 2025-12-02 17:15:56 +08:00
Acaki 85611bcab5 refactor: request limited range for connection 2025-12-02 15:04:15 +08:00
Acaki 46b5d9522d refactor: force limited range for AV1 only 2025-12-02 02:40:03 +08:00
TrueZhuanjia 6dc9ee569b improve widget relocation 2025-12-01 19:16:25 +08:00
TrueZhuanjia 9916f64065 fixed: unable to move mouse cursor under touchpad mode or on mousepad widget when finger moves slowly 2025-12-01 18:51:58 +08:00
Acaki 0ad6b13fae feat: add keyboard shortcut to disconnect & quit app 2025-12-01 17:12:41 +08:00
Acaki 5bbdb76d21 fix: workaround for Metal AV1 darker image 2025-12-01 16:38:47 +08:00
TrueZhuanjia cbf42b1e52 - rounded shape stick boost compensation for physical, gyro mapped & onscreen stick inputs
- add setting stack tips
2025-11-27 22:35:58 +08:00
TrueZhuanjia 72867d855d - minimum offset slider for stick touchpads (rspad rsvpad lspad lsvpad)
- prevents edit button to update onscreen widgets to a different widget type (e.g. touchpad -> button)
2025-11-27 15:06:41 +08:00
TrueZhuanjia 7d5b984157 controller mouse stack info tag crash fix 2025-11-27 10:38:25 +08:00
TrueZhuanjia a3d5771b14 - unlcok motion control section
- controller gyro button stack refactored to motion control section, saved with osc (widget) profile
- feat: reverse gyro-hold button switch
- DS4 gyro activated by default if controller gyro button is disabled
-  synth physical stick input switch
2025-11-27 00:26:53 +08:00
TrueZhuanjia 1338b4fb85 synthesize physical stick input switch for built-in gyro motion control 2025-11-26 16:41:08 +08:00
TrueZhuanjia 379d965769 - feat: controller gyro button (toggle & hold)
- feat: synthesize physical stick input with gyro
2025-11-26 11:48:34 +08:00
TrueZhuanjia d7bc73250d localization fix 2025-11-24 13:07:35 +08:00
TrueZhuanjia 349daef47f UITouch vector util 2025-11-24 08:57:07 +08:00
TrueZhuanjia 138658c635 feat: map controller to mouse with self-defined sticks, buttons, curve expo, pointer velocity. 2025-11-23 13:57:52 +08:00
TrueZhuanjia e058839faf separated controller section in settings menu 2025-11-19 14:50:39 +08:00
TrueZhuanjia d4d5146f66 controller->mouse demo 2025-11-19 14:35:56 +08:00
TrueZhuanjia 85f4838067 fixed: gyro minimum stick offset slider doesn't work on iPad 2025-11-17 00:20:43 +08:00
TrueZhuanjia f23108a3ba fixed some app theme issues 2025-11-15 19:30:53 +08:00
TrueZhuanjia 1623fc34cf fixed: settings menu motion control section some times can't be expanded 2025-11-09 11:18:24 +08:00
TrueZhuanjia d244c9bd1f minor tweak for abs touch 2025-11-08 14:58:42 +08:00
TrueZhuanjia e53e997cd4 tweak favorite setting parentStack spacing 2025-11-08 12:04:30 +08:00
TrueZhuanjia 5c646ef3d2 - touchpad gesture tweaks and improvements
- cancel realtime switch for single point touch gesture passthrough
- cancel remembering settings menu content offset
2025-11-08 11:34:54 +08:00
TrueZhuanjia f01d36d4a0 minor changes 2025-11-08 03:18:09 +08:00
TrueZhuanjia 37fc7e2cb4 fix setting stack long press crash 2025-11-08 03:01:42 +08:00
TrueZhuanjia ea68353328 - optimize touch pad gesture
- relative touch minor fix
2025-11-08 00:42:26 +08:00
TrueZhuanjia da4bfb851c remove test code 2025-11-07 23:45:20 +08:00
TrueZhuanjia 6a1f85975f tweak touch pad gesture sensitivity 2025-11-07 23:41:50 +08:00
TrueZhuanjia 700552bd75 - fixes some UI memory leaks
- clear some obsolete codes
2025-11-07 23:21:53 +08:00
TrueZhuanjia 5750b99af3 - fixed: memory leak and UI stuck caused by MenuSenctionView
- feat: memorize content offset for settings menu
- optimize gesture passthrough for relative touch & absolute touch
- feat: passthroughGesturesSwitch, ctrlDownForPinchSwitch, leftClickDelaySlider, touchModeSelector2 in experimental section
2025-11-07 17:34:19 +08:00
TrueZhuanjia cc35e6b3a4 touchmode indicator for exprerimental section 2025-11-06 21:05:34 +08:00
TrueZhuanjia 3b4800eb78 - touchPad mode optimization 2025-11-06 20:36:09 +08:00
TrueZhuanjia a2855ef522 feat: touchPad gesture sensitivities 2025-11-06 20:19:06 +08:00
TrueZhuanjia 935406b75b - slide distance threshold slider for relative touch & "mousePad", that fixes mouse cursor jump 2025-11-06 16:36:48 +08:00
TrueZhuanjia e933c5de60 - enhanced relative touch (touchpad mode & "mousePad" widget) pointer accuracy
- touchpad gesture simulation for touchPad & single point mode
2025-11-06 11:52:13 +08:00
TrueZhuanjia a23a5491ae feat: functional button "AbsTchDrag" 2025-11-05 11:51:29 +08:00
TrueZhuanjia 1ad8f51b7a feat: widget "ABSMOUSE" 2025-11-05 08:59:39 +08:00
TrueZhuanjia 4d1619346d update c module for "ABSMOUSE" widget 2025-11-04 21:56:24 +08:00
TrueZhuanjia df8c273cb9 - feed integral for roll->left stick motion control
- feat: roll->left stick motion control multi-level sensitivity
2025-11-04 16:01:21 +08:00
TrueZhuanjia 6eca97b4aa fixed: default on-screen widget borderAlpha offset 2025-11-04 11:03:36 +08:00
TrueZhuanjia 39f8398b10 feat: on screen widget "discreteWheel" 2025-11-04 10:48:20 +08:00
TrueZhuanjia e32a356cb5 feat: optimized single point (absolute touch) for OSRS & regular use cases 2025-11-03 23:58:49 +08:00
TrueZhuanjia e0c556da10 feat: mute in background switch 2025-11-03 16:18:21 +08:00
TrueZhuanjia a3f9e07cf7 feat: duck other apps switch in audio section 2025-11-03 11:25:57 +08:00
TrueZhuanjia ff40e55cd1 feat: append mouse left click after touch up as an option for OSRS (Old School RuneScape) or similar games that requires double tap on touchscreen 2025-10-31 15:45:55 +08:00
TrueZhuanjia e548bda73a stereo to system audio engine, multi-channel audio to SDL 2025-10-30 21:35:27 +08:00
TrueZhuanjia cf11b06f3e multi-audioChannel test 2025-10-30 15:41:10 +08:00
TrueZhuanjia a799c39aff set "useBuiltinMic" to true by default 2025-10-29 09:59:06 +08:00
TrueZhuanjia ff230b3433 refactor audio engine init to Connection.m 2025-10-29 09:36:16 +08:00
TrueZhuanjia a9f4db52b5 audio session config test 2025-10-29 00:16:24 +08:00
TrueZhuanjia 071e2a764a feat: audio engine selector 2025-10-28 23:33:42 +08:00
TrueZhuanjia e050c914bb Merge branch 'systemAudioEngineTest' into Integration 2025-10-28 21:55:57 +08:00
TrueZhuanjia cd9ef74f3f add multi-channel audio support 2025-10-28 21:54:16 +08:00
TrueZhuanjia 644687a50c optimize direction pad haptic feedback 2025-10-28 20:24:48 +08:00
TrueZhuanjia e7132e3371 - feat: highlight size tweak
- fixed: legacy onscreen controls unavailable in touch disabled mode
2025-10-28 16:41:39 +08:00
TrueZhuanjia 7223ab3be5 cancel haptic feedback for legacy on-screen control direction pad 2025-10-28 08:47:04 +08:00
TrueZhuanjia 99952281cb fixed: legacy on-screen control layers pass through native touch events 2025-10-28 08:37:26 +08:00
TrueZhuanjia 9c518dfeb6 fixed: direction pad on-screen widgets losing haptic feedback on iPhone 2025-10-28 08:18:31 +08:00
TrueZhuanjia 28e2f13e87 test: replace SDL with system audio engine to support audio playing when screen is dimmed out 2025-10-27 17:12:45 +08:00
TrueZhuanjia 4a9e109de5 stop video render when app enters background without pip enabled for balanced mode 2025-10-25 13:57:36 +08:00
TrueZhuanjia f5b56ca67f Fixed: widget profile, widget tool entry doesn't work for single point touch 2025-10-22 11:43:26 +08:00
TrueZhuanjia b7ad4d3562 Fixed: on-screen widget default border alpha issue 2025-10-20 19:45:33 +08:00
TrueZhuanjia 94a8caa5df fixed: unable to typing non-ascii characters by default iOS soft keyboards 2025-10-20 15:56:45 +08:00
TrueZhuanjia b650ac0ca3 show soft keyboard toolbar by default 2025-10-20 11:50:05 +08:00
TrueZhuanjia 9dda594519 feat: on-screen widget border alpha tweak 2025-10-19 11:04:56 +08:00
TrueZhuanjia 0ea6248773 minor fix for settings menu 2025-10-19 00:14:45 +08:00
TrueZhuanjia fee003b824 feat: app theme selector 2025-10-18 23:30:40 +08:00
TrueZhuanjia cd415cc639 feat: setup autotap timer by text input instead of slider. 2025-10-18 19:14:21 +08:00
TrueZhuanjia 10ceac2ce3 feat: label alpha tweak for on-screen widgets 2025-10-17 20:39:06 +08:00
TrueZhuanjia 86a0ff46ee - dynamic update theme fix for safeAreaPadding on iPhone 2025-10-17 16:27:55 +08:00
TrueZhuanjia 3887ad0c4c - iOS 14 UI minor fix for settings menu 2025-10-17 15:02:02 +08:00
TrueZhuanjia 58f534fcc8 minor fix 2025-10-15 22:15:07 +08:00
TrueZhuanjia f8e14006c1 try to reduce on-screen widget performance overheads 2025-10-15 21:43:55 +08:00
TrueZhuanjia a959a346b0 fixed: motion control configs not loaded correctly from oscProfile 2025-10-15 09:53:28 +08:00
TrueZhuanjia 65459b3f4e update preset widget profiles 2025-10-15 09:35:40 +08:00
TrueZhuanjia 2216899204 minor tweak 2025-10-14 23:10:25 +08:00
TrueZhuanjia 5fb4887c67 fixed: settings menu safeArea adaption some times not working on iPhone 2025-10-14 23:05:25 +08:00
TrueZhuanjia 6ff2add9dd - feat: edge sliding sensitivity for edge sliding gestures
- update flag for new preset widget profiles
2025-10-14 22:34:28 +08:00
TrueZhuanjia c963c1254d update preset widget profiles 2025-10-14 16:43:36 +08:00
TrueZhuanjia 8afce40e81 - feat: functional buttons "toolbox" "widgetTool" "widgetProfiles" "softKeyboard" 2025-10-14 15:49:06 +08:00
TrueZhuanjia e44504bc48 - feat: hybrid form for mousePad & LT/RT Pad 2025-10-14 14:22:44 +08:00
TrueZhuanjia 11b79dda5b - feat: yaw & pitch factor for each on-screen "gyro" button for seamlessly toggling among different level of gyro sensitivities. 2025-10-14 01:37:22 +08:00
TrueZhuanjia 8cee8b679c new preset on-screen widget templates with gyro & settings button 2025-10-12 23:21:54 +08:00
TrueZhuanjia 1cb9360d83 refactor "gyroOff" to "gyroPause" 2025-10-12 22:01:38 +08:00
TrueZhuanjia 297f5ad7e6 fixed: direction pad on-screen widget (Wasdpad, dPad ...) not responsive on first touch move 2025-10-12 21:28:21 +08:00
TrueZhuanjia 3f2a011d7b - feat: gyro-off on-screen button, the opposite of "gyro" button
- fixed: auto tap slider disappeared for regular on-screen buttons
2025-10-12 20:41:33 +08:00
TrueZhuanjia dd0c36bb20 feat: haptic engine selector 2025-10-12 16:49:10 +08:00
TrueZhuanjia aff1bdfe89 - feat: on screen widget "LTAPD" & "RTPAD" (left trigger pad & right trigger pad)
- try to fix single-tap not responsive on some iPhone by "single tap sensitivity option" in settings menu
2025-10-11 15:31:41 +08:00
TrueZhuanJia 00533f5d3b Merge pull request #132 from twilightgod/iphone-haptics
Fall back to phone haptics engine if controller doesn't support it
2025-10-09 17:12:19 +08:00
TrueZhuanjia eff682bc05 feat: minimum stick offset for gyro -> stick mapping 2025-10-09 09:58:57 +08:00
TrueZhuanjia 16a210efa2 feat: motion control - direct mapping from gyro to mouse or controller sticks, with on-screen button "gyro" for toggling 2025-10-07 23:09:06 +08:00
TrueZhuanjia 99a2a4ca1a minor fixes 2025-10-04 17:22:32 +08:00
TrueZhuanjia 67af68fcf1 - motion control demo1
- feat: movable on-screen functional buttons
2025-10-04 16:44:43 +08:00
TrueZhuanjia b96e2d1bdf fixed: built-in gyro mapping doesn't work on iOS12 & iOS13 2025-10-02 12:25:31 +08:00
TrueZhuanjia 2bb0ce086f - feat: tap to toggle on-screen button triggering
- fixed: special overlay button relocation
2025-09-29 17:27:49 +08:00
TrueZhuanjia 9bc518dc68 set button-type widget default properties 2025-09-29 10:44:26 +08:00
TrueZhuanjia f60433ff3f try to fix: built-in gyro doesn't work on iOS12 & iOS13 2025-09-28 10:59:01 +08:00
TrueZhuanjia b4493707ca Merge branch 'menuSectionFoldStateMemorize' into Integration 2025-09-28 10:35:34 +08:00
TrueZhuanjia 0fe10d8654 feat: remember menu section fold state 2025-09-28 10:34:40 +08:00
Rex Xiong 87d9590f3b Update HapticContext.m 2025-09-27 13:35:19 -07:00
Rex Xiong eeff9b74da Fallback to phone haptics engine if controller doesn't support it 2025-09-27 13:13:26 -07:00
TrueZhuanjia ceebd19c0e Merge branch 'aliasKeyboardType' into Integration 2025-09-26 09:18:00 +08:00
TrueZhuanjia 00e0566dd4 story board UI string fix 2025-09-25 20:00:21 +08:00
TrueZhuanjia acd4a04076 fixed: settings menu accidentally resized on iPad 2025-09-25 16:22:27 +08:00
TrueZhuanjia ac0aa29578 feat: mic volume 2025-09-25 13:30:30 +08:00
TrueZhuanjia 9a053d9ed8 fixed: mic redirection not working on iOS 12 2025-09-24 22:43:14 +08:00
TrueZhuanjia 9124a1fee5 use Deque for mic audio ringBuffering 2025-09-24 16:50:14 +08:00
TrueZhuanjia 858e7c7440 feat: use built-in mic 2025-09-24 12:49:25 +08:00
TrueZhuanjia e5669e2a30 feat: local volume 2025-09-23 23:51:21 +08:00
TrueZhuanjia 431b32b680 try to fix broken native touch & broken on screen widget layout when orientation is locked on iPhone iOS 16/17 control center 2025-09-23 20:55:15 +08:00
TrueZhuanjia 7dce11b4c4 add libupos-sim.a file reference 2025-09-22 23:04:58 +08:00
TrueZhuanjia 9fca3a5984 storyboard & settings menu UI fixes 2025-09-22 21:42:02 +08:00
TrueZhuanjia 733d5c38f3 - fixed: non-AWDL stuttering fix doesn't work when settings menu expands during streaming 2025-09-22 15:49:40 +08:00
HsunLu 3834b5a604 Fix: allow default keyboard for alias input (support Zhuyin) 2025-09-22 14:40:11 +08:00
TrueZhuanjia 07e36029d0 tweak relative touch handler 2025-09-22 13:18:25 +08:00
TrueZhuanjia 689221f10e send dummy event to prevent stuttering 2025-09-21 18:35:56 +08:00
TrueZhuanjia 60dacdf05f feat: dynamic theme update 2025-09-18 16:53:17 +08:00
TrueZhuanjia 1614bb0609 Merge branch 'dynamicTheme' into Integration
# Conflicts:
#	VoidLink/ViewControllers/SettingsViewController.m
2025-09-18 11:02:15 +08:00
TrueZhuanjia a386867dd0 fixed: stream view interrupted by mic stream 2025-09-16 08:44:38 +08:00
TrueZhuanjia e177d6eaea perfGraph tips 2025-09-15 13:15:04 +08:00
TrueZhuanjia 464492529e fixed possible crash in micHandler 2025-09-15 11:37:50 +08:00
TrueZhuanjia b950882846 tweak stats overlay 2025-09-15 10:54:53 +08:00
TrueZhuanjia 63808c9e9c use safeTimer for micHandler 2025-09-15 10:39:20 +08:00
TrueZhuanjia 76d138d64d minor change on storyboard 2025-09-15 09:33:47 +08:00
TrueZhuanjia 1232644268 minor fixes 2025-09-14 10:07:50 +08:00
TrueZhuanjia 2e776baec9 fixed potential crash in mousePad widget 2025-09-14 03:09:11 +08:00
TrueZhuanjia 8fb4829aef further fix widget layout with orientation change issue 2025-09-13 22:57:20 +08:00
TrueZhuanjia fcbc844587 switch to default profile after importing widget templates 2025-09-13 22:33:34 +08:00
TrueZhuanjia aecb1163e3 minor UI fixes & tweaks 2025-09-13 21:55:02 +08:00
TrueZhuanjia 0e51a49898 tweak statsOverlay 2025-09-13 21:21:57 +08:00
TrueZhuanjia adce543c45 hostView settings menu gesture improvement 2025-09-13 18:34:49 +08:00
TrueZhuanjia 83a32f96e6 update preset on screen widget templates 2025-09-13 18:01:18 +08:00
TrueZhuanjia 6c72f0331b feat: implements button visual feedback switch for legacy osc 2025-09-13 17:33:40 +08:00
TrueZhuanjia 92f1fbf33b fixed: unpredictable on screen widget size after orientation change in layout tool. now widget size stays consistent. 2025-09-13 17:13:11 +08:00
TrueZhuanjia 424fe2c2f0 fixed: allow widget profiles with identical names and shows the checkmark at the correct profile 2025-09-12 22:47:10 +08:00
TrueZhuanjia f5d629c62c replace widget templates 2025-09-12 22:16:19 +08:00
TrueZhuanjia 19fa975b6d - fixed: widget height adjusting bug 2025-09-12 21:58:25 +08:00
TrueZhuanjia a4951309d3 widget layout tool minor fixes 2025-09-12 17:03:40 +08:00
TrueZhuanjia 3ec53c29d4 - minor UI fix for touch mode selector 2025-09-12 13:14:38 +08:00
TrueZhuanjia 3fcaafc42b iOS 14 settings menu UI bug fix 2025-09-12 12:58:06 +08:00
TrueZhuanjia 5318acc427 minor fix for setting menu loading speed 2025-09-12 10:13:48 +08:00
TrueZhuanjia daa4fc80eb fixed: broken touch input caused by Absolute Touch 2025-09-11 23:14:58 +08:00
TrueZhuanjia c094d74d8e - feat: allow touchPad label to be display if it's different from touchPad cmdString 2025-09-11 21:39:55 +08:00
TrueZhuanjia 5418936816 - feat: mouseWheel on-screen widget 2025-09-11 21:33:15 +08:00
TrueZhuanjia c3b7679268 - fixed: widget height changes unexpectedly after being tapped 2025-09-11 19:49:36 +08:00
TrueZhuanjia d12b83d1ef - Fixed: mousepad unable to perform 2-finger right click in hovering mode
- Minor changes & improvements for mousePad widget
2025-09-11 17:42:17 +08:00
TrueZhuanjia 32b78d1a4c - move performance graph setting stack to experimental section 2025-09-11 15:02:55 +08:00
TrueZhuanjia 71f66fe8c0 - UI: highlighting emerging setting stack 2025-09-11 14:51:39 +08:00
TrueZhuanjia d054bbe35f - feat: on-screen button visual feedback switch
- feat: touch input disabled mode (while allowing on-screen widget input)
- fixed: swapAbxy stack dynamic UI
2025-09-11 11:37:14 +08:00
TrueZhuanjia bd1c4e3cc9 - update mic permission tip 2025-09-10 21:27:23 +08:00
TrueZhuanjia 928b765700 compatibility fix from c-module 2025-09-10 20:47:35 +08:00
TrueZhuanjia 45acc28384 Tap gesture fix 2025-09-10 12:39:26 +08:00
TrueZhuanjia 4e65abf6b1 - feat: auto tap for on-screen widget.
- fixed: on-screen widget button stuck.
2025-09-10 03:02:28 +08:00
TrueZhuanjia d818f8a7ce allows on-screen widget vibration during mic redirection 2025-09-08 20:24:11 +08:00
TrueZhuanjia c0850ce9d2 Merge branch 'micDemo' into Integration 2025-09-08 16:52:45 +08:00
TrueZhuanjia c3941df7e9 - feat: mic redirection (with Sunshine-Foundation) 2025-09-08 16:47:27 +08:00
TrueZhuanjia b1b46b3ec0 Merge commit 'metal renderer crash fix' into Integration 2025-09-02 12:36:50 +08:00
TrueZhuanjia dff6679a90 lib opus file reference 2025-09-02 08:54:53 +08:00
Acaki 4ab21aed0e fix: crash on background timer expired with Metal renderer 2025-09-01 22:51:41 +08:00
TrueZhuanjia d67a40f155 c-module source file reference update 2025-09-01 22:04:55 +08:00
TrueZhuanjia b506917382 update c-module for mic implementation 2025-09-01 10:41:51 +08:00
TrueZhuanjia 944f7422b3 set maximum bitrate to 800Mbps 2025-08-31 11:12:48 +08:00
TrueZhuanjia 4843c046d7 fixed: bitrate slider disabled when sunshine server does not support dynamic bitrate 2025-08-31 09:14:52 +08:00
TrueZhuanjia 61df4e7e61 a few ui string tweaks 2025-08-30 19:22:09 +08:00
TrueZhuanjia 48558316d1 Merge branch 'perfMode' into Integration 2025-08-30 18:57:59 +08:00
TrueZhuanjia 5903b358ee - unify pixel format to MTLPixelFormatRGBA16Float
- move renderingBackEnd to experimental section, add a few tips for performance mode
2025-08-30 18:54:30 +08:00
TrueZhuanjia 6881098ca2 Fixed: failed to launch app on iPhone when there're specific number of hosts presented no the host view. 2025-08-30 11:34:29 +08:00
TrueZhuanjia 4d783649f5 cancel iOS13 availability check for metal renderer 2025-08-28 23:34:26 +08:00
Acaki 2b40224b2b refactor: replace MTLPixelFormatBGR10A2Unorm with MTLPixelFormatBGRA10_XR for older devices 2025-08-28 21:31:14 +08:00
Acaki e4d6be3bb3 Revert "fix: crash when host is on hdr but client is not on Metal lower iOS versions"
This reverts commit 6c0b1550fd.
2025-08-28 21:29:51 +08:00
Acaki 6c0b1550fd fix: crash when host is on hdr but client is not on Metal lower iOS versions 2025-08-28 13:28:28 +08:00
TrueZhuanjia 967fbe8072 feat: no click option for mousePad on-screen widget 2025-08-28 13:23:35 +08:00
Acaki c7dd7a2c21 fix: metal renderer packed format crash on emulator and lower iOS version 2025-08-28 00:07:56 +08:00
TrueZhuanjia 49ed0e43e9 reduce on-screen widget visual effect performance cost 2025-08-27 22:35:47 +08:00
TrueZhuanjia 6bae985e2c ui & string tweaks 2025-08-27 20:20:48 +08:00
Acaki 39d7d9e213 feat: add back lowest latency mode for AVSampleBuffer 2025-08-27 15:53:00 +08:00
Acaki eb3501e090 Revert "feat: bring back CAMetalDisplayLink"
This reverts commit b6c029df
2025-08-27 15:16:24 +08:00
TrueZhuanjia 83566ad4e1 try to reduce directionPad on-screen widget performance cost 2025-08-27 14:04:45 +08:00
TrueZhuanjia 19e78d5eb6 Merge branch 'dynamicBitrate' into Integration 2025-08-26 14:45:04 +08:00
TrueZhuanjia 31ab333f5b realtime bitrate adjustment with Sunshine-Foundation (experimental) 2025-08-25 10:07:38 +08:00
xzzpig ff8cb20cae add support for dynamic theme update 2025-07-30 01:20:36 +08:00
127 changed files with 26766 additions and 5000 deletions
+136 -11
View File
@@ -3,19 +3,40 @@
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
180345E12E819877004AF36F /* libopus-sim.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 180345E02E819877004AF36F /* libopus-sim.a */; };
180E6E372E93BFDB0067C73A /* AlertControllerUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 180E6E362E93BFDB0067C73A /* AlertControllerUtil.swift */; };
1817B61E2E6FD04D000D938D /* SafeTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1817B61D2E6FD04D000D938D /* SafeTimer.swift */; };
182760F72E83E2EA00EC1C5C /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 182760F62E83E2EA00EC1C5C /* DequeModule */; };
182760FA2E83E57D00EC1C5C /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 182760F92E83E57D00EC1C5C /* DequeModule */; };
182760FD2E83E68B00EC1C5C /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 182760FC2E83E68B00EC1C5C /* Collections */; };
1829CE3F2E2154DF0074A99A /* SceneDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 1829CE3E2E2154DF0074A99A /* SceneDelegate.m */; };
18341A042DEB662C0094E5F2 /* FixedTintImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 18341A032DEB662C0094E5F2 /* FixedTintImageView.m */; };
18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18416EA32EBB3DCE00AE0124 /* UITouchUtil.swift */; };
1850C82A2E667E4000845308 /* MicHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1850C8292E667E4000845308 /* MicHandler.swift */; };
18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18729EDD2E8BD68800101194 /* UUIDHelper.swift */; };
18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 18780CC62F10D66000B90483 /* StoreKit.framework */; };
18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18780CC92F10D6CC00B90483 /* IAPManager.swift */; };
187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */ = {isa = PBXBuildFile; fileRef = 187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */; };
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1912E24C33100108730 /* AboutViewController.swift */; };
1888C1932E24C41000108730 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1888C1902E24C2C800108730 /* AboutView.swift */; };
188973D32E386A670025E48C /* widgetTemplatesIPhone.bin in Resources */ = {isa = PBXBuildFile; fileRef = 188973D22E386A670025E48C /* widgetTemplatesIPhone.bin */; };
18951D1D2EBB6D2300A86E12 /* TouchPadGestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18951D1C2EBB6D2300A86E12 /* TouchPadGestureHandler.swift */; };
189AD29A2DE7446000E24B88 /* HostCollectionViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 189AD2992DE7446000E24B88 /* HostCollectionViewController.m */; };
18A0EE252EFB9B5A00BA853A /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = 18A0EE242EFB9B5A00BA853A /* SVGKit */; };
18A0EE272EFB9B5A00BA853A /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 18A0EE262EFB9B5A00BA853A /* SVGKitSwift */; };
18A0EE2C2EFBCCFD00BA853A /* GraphicUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A0EE2B2EFBCCFD00BA853A /* GraphicUtils.swift */; };
18A765BD2ECD51FB004784AA /* ControllerUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A765BC2ECD51FB004784AA /* ControllerUtil.swift */; };
18B317032EE67A3A009B62C2 /* InertialScroller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B317022EE67A39009B62C2 /* InertialScroller.swift */; };
18D43D7F2F050240008C326F /* PressureCurve.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D43D7E2F050240008C326F /* PressureCurve.swift */; };
18E4BCEA2E891895000A8657 /* MotionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E4BCE92E891895000A8657 /* MotionHandler.swift */; };
18FCADAF2ECAC23200787A96 /* PencilHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FCADAE2ECAC23200787A96 /* PencilHandler.swift */; };
2A7113FD2F5A6C65001D852D /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */; };
2AC1E3832F507D80004F76C0 /* GenericUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AC1E3822F507D80004F76C0 /* GenericUtils.swift */; };
840751B32BF49C050096B657 /* CustomTapGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 840751B22BF49C050096B657 /* CustomTapGestureRecognizer.m */; };
841BAC922DE35D6B00DA639E /* ThemeManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 841BAC912DE35D6B00DA639E /* ThemeManager.m */; };
84203F232C5F24C00032EEED /* OnScreenWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84203F222C5F24C00032EEED /* OnScreenWidgetView.swift */; };
84351B0E2C29ACCB00A77989 /* LayoutOnScreenControls.m in Sources */ = {isa = PBXBuildFile; fileRef = 84351B072C29ACCA00A77989 /* LayoutOnScreenControls.m */; };
84351B0F2C29ACCB00A77989 /* OnScreenButtonState.m in Sources */ = {isa = PBXBuildFile; fileRef = 84351B092C29ACCA00A77989 /* OnScreenButtonState.m */; };
@@ -151,23 +172,38 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
180345E02E819877004AF36F /* libopus-sim.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libopus-sim.a"; sourceTree = "<group>"; };
180E6E362E93BFDB0067C73A /* AlertControllerUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertControllerUtil.swift; sourceTree = "<group>"; };
1817B61D2E6FD04D000D938D /* SafeTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeTimer.swift; sourceTree = "<group>"; };
1829CE3D2E2154DF0074A99A /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SceneDelegate.h; sourceTree = "<group>"; };
1829CE3E2E2154DF0074A99A /* SceneDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SceneDelegate.m; sourceTree = "<group>"; };
18341A022DEB65AB0094E5F2 /* FixedTintImageView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FixedTintImageView.h; sourceTree = "<group>"; };
18341A032DEB662C0094E5F2 /* FixedTintImageView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FixedTintImageView.m; sourceTree = "<group>"; };
18416EA32EBB3DCE00AE0124 /* UITouchUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITouchUtil.swift; sourceTree = "<group>"; };
1850C8292E667E4000845308 /* MicHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicHandler.swift; sourceTree = "<group>"; };
18729EDD2E8BD68800101194 /* UUIDHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UUIDHelper.swift; sourceTree = "<group>"; };
18780CC62F10D66000B90483 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
18780CC92F10D6CC00B90483 /* IAPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPManager.swift; sourceTree = "<group>"; };
187F4B232E04633100EECC59 /* WidgetPanelStackView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WidgetPanelStackView.h; sourceTree = "<group>"; };
187F4B272E04658A00EECC59 /* WidgetPanelStackView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WidgetPanelStackView.m; sourceTree = "<group>"; };
1888C1902E24C2C800108730 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
1888C1912E24C33100108730 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = "<group>"; };
188973D22E386A670025E48C /* widgetTemplatesIPhone.bin */ = {isa = PBXFileReference; lastKnownFileType = archive.macbinary; name = widgetTemplatesIPhone.bin; path = PresetConfigs/widgetTemplatesIPhone.bin; sourceTree = "<group>"; };
18951D1C2EBB6D2300A86E12 /* TouchPadGestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchPadGestureHandler.swift; sourceTree = "<group>"; };
189AD2962DE743B700E24B88 /* HostCollectionViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HostCollectionViewController.h; sourceTree = "<group>"; };
189AD2992DE7446000E24B88 /* HostCollectionViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HostCollectionViewController.m; sourceTree = "<group>"; };
18A0EE2B2EFBCCFD00BA853A /* GraphicUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphicUtils.swift; sourceTree = "<group>"; };
18A765BC2ECD51FB004784AA /* ControllerUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerUtil.swift; sourceTree = "<group>"; };
18B317022EE67A39009B62C2 /* InertialScroller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InertialScroller.swift; sourceTree = "<group>"; };
18BF141D2E0D7A00009DEE4F /* VoidLink v1.0.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "VoidLink v1.0.xcdatamodel"; sourceTree = "<group>"; };
18D43D7E2F050240008C326F /* PressureCurve.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressureCurve.swift; sourceTree = "<group>"; };
18E4BCE92E891895000A8657 /* MotionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionHandler.swift; sourceTree = "<group>"; };
18FCADAE2ECAC23200787A96 /* PencilHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PencilHandler.swift; sourceTree = "<group>"; };
2A7113FC2F5A6C65001D852D /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
2AC1E3822F507D80004F76C0 /* GenericUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericUtils.swift; sourceTree = "<group>"; };
693B3A9A218638CD00982F7B /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; };
840751B12BF49B5A0096B657 /* CustomTapGestureRecognizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CustomTapGestureRecognizer.h; sourceTree = "<group>"; };
840751B22BF49C050096B657 /* CustomTapGestureRecognizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CustomTapGestureRecognizer.m; sourceTree = "<group>"; };
841BAC8F2DE35BEC00DA639E /* ThemeManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ThemeManager.h; sourceTree = "<group>"; };
841BAC912DE35D6B00DA639E /* ThemeManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ThemeManager.m; sourceTree = "<group>"; };
84203F222C5F24C00032EEED /* OnScreenWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnScreenWidgetView.swift; sourceTree = "<group>"; };
84351B062C29ACCA00A77989 /* OnScreenButtonState.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OnScreenButtonState.h; sourceTree = "<group>"; };
84351B072C29ACCA00A77989 /* LayoutOnScreenControls.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LayoutOnScreenControls.m; sourceTree = "<group>"; };
@@ -378,23 +414,34 @@
FBDE86E519F82297001C18A8 /* UIAppView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UIAppView.m; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
18A0EE282EFBCBFB00BA853A /* SVGs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SVGs; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
FB290CEB19B2C406004C83CF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
18A0EE272EFB9B5A00BA853A /* SVGKitSwift in Frameworks */,
18A0EE252EFB9B5A00BA853A /* SVGKit in Frameworks */,
9890CF6B203B7EE1006C4B06 /* libxml2.tbd in Frameworks */,
98CFB82F1CAD481B0048EF74 /* libmoonlight-common.a in Frameworks */,
FB8946ED19F6AFE800339C8A /* libopus.a in Frameworks */,
98882A082AF60F7400C5A11C /* libavutil.a in Frameworks */,
180345E12E819877004AF36F /* libopus-sim.a in Frameworks */,
182760F72E83E2EA00EC1C5C /* DequeModule in Frameworks */,
182760FD2E83E68B00EC1C5C /* Collections in Frameworks */,
FB290CF419B2C406004C83CF /* CoreGraphics.framework in Frameworks */,
FB290CF819B2C406004C83CF /* CoreData.framework in Frameworks */,
98181BED2791281100E43572 /* CoreMotion.framework in Frameworks */,
F74BEF9C2C1A705600224667 /* OpenSSL in Frameworks */,
98882A072AF60F7200C5A11C /* libavformat.a in Frameworks */,
FB290CF619B2C406004C83CF /* UIKit.framework in Frameworks */,
18780CC72F10D66000B90483 /* StoreKit.framework in Frameworks */,
FB290CF219B2C406004C83CF /* Foundation.framework in Frameworks */,
FB4679011A57048000377732 /* CoreFoundation.framework in Frameworks */,
182760FA2E83E57D00EC1C5C /* DequeModule in Frameworks */,
98181BE92791277400E43572 /* libSDL2.a in Frameworks */,
98882A062AF60F7000C5A11C /* libavcodec.a in Frameworks */,
);
@@ -559,6 +606,7 @@
FB290CF019B2C406004C83CF /* Frameworks */ = {
isa = PBXGroup;
children = (
18780CC62F10D66000B90483 /* StoreKit.framework */,
98882A032AF60F5300C5A11C /* libavcodec.a */,
98882A052AF60F5300C5A11C /* libavformat.a */,
98882A0A2AF60F9000C5A11C /* libavcodec.a */,
@@ -612,16 +660,17 @@
FB290D0319B2C406004C83CF /* AppDelegate.m */,
848F45682DDCD0D600581E36 /* HostCardView.h */,
848F45692DDCD12500581E36 /* HostCardView.m */,
841BAC8F2DE35BEC00DA639E /* ThemeManager.h */,
841BAC912DE35D6B00DA639E /* ThemeManager.m */,
2A7113FC2F5A6C65001D852D /* ThemeManager.swift */,
847D2FD82DDA00C800937A62 /* MenuSectionView.h */,
847D2FDA2DDA015F00937A62 /* MenuSectionView.m */,
FBDE86E419F82297001C18A8 /* UIAppView.h */,
FBDE86E519F82297001C18A8 /* UIAppView.m */,
180E6E362E93BFDB0067C73A /* AlertControllerUtil.swift */,
1888C1902E24C2C800108730 /* AboutView.swift */,
1888C1912E24C33100108730 /* AboutViewController.swift */,
FB89463719F6473800339C8A /* Launch Screen.xib */,
FB290D0819B2C406004C83CF /* Images.xcassets */,
18A0EE282EFBCBFB00BA853A /* SVGs */,
FB290D0519B2C406004C83CF /* Limelight.xcdatamodeld */,
FB1D59981BBCCD7E00F482CA /* AppCollectionView.h */,
FB1D59991BBCCD7E00F482CA /* AppCollectionView.m */,
@@ -677,18 +726,27 @@
9827E7A7251436EA00F25707 /* HapticContext.h */,
840751B12BF49B5A0096B657 /* CustomTapGestureRecognizer.h */,
840751B22BF49C050096B657 /* CustomTapGestureRecognizer.m */,
9819CC12254F107A008A7C8E /* RelativeTouchHandler.h */,
843CC7F42C1E778000BBC475 /* NativeTouchHandler.h */,
844CD0E82C7F847E004861A7 /* PureNativeTouchHandler.h */,
844CD0EA2C7F8500004861A7 /* PureNativeTouchHandler.m */,
843CC7F42C1E778000BBC475 /* NativeTouchHandler.h */,
843CC7F52C1E7A1700BBC475 /* NativeTouchHandler.m */,
9819CC12254F107A008A7C8E /* RelativeTouchHandler.h */,
9819CC13254F107A008A7C8E /* RelativeTouchHandler.m */,
9819CC20254F180F008A7C8E /* AbsoluteTouchHandler.h */,
9819CC21254F180F008A7C8E /* AbsoluteTouchHandler.m */,
988FCD3F293B091B003050E2 /* KeyboardInputField.h */,
988FCD40293B091B003050E2 /* KeyboardInputField.m */,
18D43D7E2F050240008C326F /* PressureCurve.swift */,
18FCADAE2ECAC23200787A96 /* PencilHandler.swift */,
18416EA32EBB3DCE00AE0124 /* UITouchUtil.swift */,
18B317022EE67A39009B62C2 /* InertialScroller.swift */,
18951D1C2EBB6D2300A86E12 /* TouchPadGestureHandler.swift */,
18A765BC2ECD51FB004784AA /* ControllerUtil.swift */,
1850C8292E667E4000845308 /* MicHandler.swift */,
18E4BCE92E891895000A8657 /* MotionHandler.swift */,
84E1CDEB2C4F67D300547904 /* CommandManager.swift */,
84203F222C5F24C00032EEED /* OnScreenWidgetView.swift */,
1817B61D2E6FD04D000D938D /* SafeTimer.swift */,
);
path = Input;
sourceTree = "<group>";
@@ -728,6 +786,10 @@
FB89461E19F646E200339C8A /* Utility */ = {
isa = PBXGroup;
children = (
18729EDD2E8BD68800101194 /* UUIDHelper.swift */,
2AC1E3822F507D80004F76C0 /* GenericUtils.swift */,
18A0EE2B2EFBCCFD00BA853A /* GraphicUtils.swift */,
18780CC92F10D6CC00B90483 /* IAPManager.swift */,
97E20DD42E2164A9000D5DCA /* BandwidthTracker.h */,
97E20DD52E2164A9000D5DCA /* BandwidthTracker.m */,
97E20DD62E2164A9000D5DCA /* FloatBuffer.h */,
@@ -809,6 +871,7 @@
FB8946E919F6AFB800339C8A /* lib */ = {
isa = PBXGroup;
children = (
180345E02E819877004AF36F /* libopus-sim.a */,
FB8946EA19F6AFB800339C8A /* libopus.a */,
);
path = lib;
@@ -888,6 +951,9 @@
dependencies = (
98AB2E861CAD468B0089BB98 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
18A0EE282EFBCBFB00BA853A /* SVGs */,
);
name = VoidLink;
productName = Limelight;
productReference = FB290CEE19B2C406004C83CF /* VoidLink.app */;
@@ -922,6 +988,8 @@
mainGroup = FB290CE519B2C406004C83CF;
packageReferences = (
F74BEF9A2C1A705600224667 /* XCRemoteSwiftPackageReference "OpenSSL-Package" */,
182760F52E83E2EA00EC1C5C /* XCRemoteSwiftPackageReference "swift-collections" */,
18A0EE232EFB9B5A00BA853A /* XCRemoteSwiftPackageReference "SVGKit" */,
);
productRefGroup = FB290CEF19B2C406004C83CF /* Products */;
projectDirPath = "";
@@ -985,7 +1053,9 @@
FB1A674D2131E65900507771 /* KeyboardSupport.m in Sources */,
97E20DDF2E2164A9000D5DCA /* Frame.m in Sources */,
97E20DE02E2164A9000D5DCA /* FrameQueue.m in Sources */,
18951D1D2EBB6D2300A86E12 /* TouchPadGestureHandler.swift in Sources */,
97E20DE12E2164A9000D5DCA /* FloatBuffer.m in Sources */,
18FCADAF2ECAC23200787A96 /* PencilHandler.swift in Sources */,
97E20DE22E2164A9000D5DCA /* PaddedLabel.m in Sources */,
97E20DE32E2164A9000D5DCA /* BandwidthTracker.m in Sources */,
84591A512BE1625E006AAB10 /* CustomEdgeSlideGestureRecognizer.m in Sources */,
@@ -1005,8 +1075,10 @@
97E21BC12E21882D000D5DCA /* imgui_widgets.cpp in Sources */,
97E21BC22E21882D000D5DCA /* imgui.cpp in Sources */,
97E21BC32E21882D000D5DCA /* imgui_draw.cpp in Sources */,
18A765BD2ECD51FB004784AA /* ControllerUtil.swift in Sources */,
84E1CDEC2C4F67D300547904 /* CommandManager.swift in Sources */,
847D2FDB2DDA015F00937A62 /* MenuSectionView.m in Sources */,
18A0EE2C2EFBCCFD00BA853A /* GraphicUtils.swift in Sources */,
FB53E1431BE5DC4400CD6ECE /* IdManager.m in Sources */,
189AD29A2DE7446000E24B88 /* HostCollectionViewController.m in Sources */,
9897B6A1221260EF00966419 /* VoidController.m in Sources */,
@@ -1014,14 +1086,18 @@
FB89462919F646E200339C8A /* mkcert.c in Sources */,
FB9AFD281A7C84ED00872C98 /* HttpResponse.m in Sources */,
FB89463019F646E200339C8A /* StreamConfiguration.m in Sources */,
1850C82A2E667E4000845308 /* MicHandler.swift in Sources */,
FBD3495319FF36FB002D2A60 /* SWRevealViewController.m in Sources */,
84203F232C5F24C00032EEED /* OnScreenWidgetView.swift in Sources */,
2AC1E3832F507D80004F76C0 /* GenericUtils.swift in Sources */,
97E21BB52E2186A3000D5DCA /* imgui_impl_metal.mm in Sources */,
9777AAF32E2CF7A400D7FBAF /* Shaders-Linear.metal in Sources */,
84FD0B512C31338D006B5BE5 /* LocalizationHelper.m in Sources */,
97E20DEA2E216532000D5DCA /* SceneDelegate.m in Sources */,
1817B61E2E6FD04D000D938D /* SafeTimer.swift in Sources */,
FBD3495019FF2174002D2A60 /* SettingsViewController.m in Sources */,
843CC7F02C1E757D00BBC475 /* NativeTouchPointer.m in Sources */,
2A7113FD2F5A6C65001D852D /* ThemeManager.swift in Sources */,
FB89462C19F646E200339C8A /* HttpManager.m in Sources */,
FB89462D19F646E200339C8A /* MDNSManager.m in Sources */,
84351B0F2C29ACCB00A77989 /* OnScreenButtonState.m in Sources */,
@@ -1029,7 +1105,8 @@
840751B32BF49C050096B657 /* CustomTapGestureRecognizer.m in Sources */,
187F4B282E04658A00EECC59 /* WidgetPanelStackView.m in Sources */,
FB4678FA1A55FFAD00377732 /* DiscoveryManager.m in Sources */,
841BAC922DE35D6B00DA639E /* ThemeManager.m in Sources */,
18D43D7F2F050240008C326F /* PressureCurve.swift in Sources */,
180E6E372E93BFDB0067C73A /* AlertControllerUtil.swift in Sources */,
98D5856D1C0EA79600F6CC00 /* TemporaryHost.m in Sources */,
FB89463519F646E200339C8A /* MainFrameViewController.m in Sources */,
FBD1C8E21A8AD71400C6703C /* Logger.m in Sources */,
@@ -1048,6 +1125,7 @@
84351B2C2C29AD6100A77989 /* OSCProfilesTableViewController.m in Sources */,
98D585701C0ED0E800F6CC00 /* TemporarySettings.m in Sources */,
1829CE3F2E2154DF0074A99A /* SceneDelegate.m in Sources */,
18729EDE2E8BD68800101194 /* UUIDHelper.swift in Sources */,
FB89462819F646E200339C8A /* CryptoManager.m in Sources */,
DC1F5A07206436B20037755F /* ConnectionHelper.m in Sources */,
FB89462E19F646E200339C8A /* PairManager.m in Sources */,
@@ -1065,13 +1143,17 @@
1888C1922E24C33100108730 /* AboutViewController.swift in Sources */,
FBD349621A0089F6002D2A60 /* DataManager.m in Sources */,
97E20DE72E216510000D5DCA /* ImGuiRenderer.mm in Sources */,
18B317032EE67A3A009B62C2 /* InertialScroller.swift in Sources */,
FB4A23B81A9D3637004D2EF2 /* LoadingFrameViewController.m in Sources */,
FB9AFD3A1A7E05CE00872C98 /* ServerInfoResponse.m in Sources */,
84351B2B2C29AD6100A77989 /* LayoutOnScreenControlsViewController.m in Sources */,
84351B5C2C29ADBE00A77989 /* ProfileTableViewCell.m in Sources */,
18341A042DEB662C0094E5F2 /* FixedTintImageView.m in Sources */,
18416EA42EBB3DCE00AE0124 /* UITouchUtil.swift in Sources */,
FB89463119F646E200339C8A /* StreamManager.m in Sources */,
18780CCA2F10D6CC00B90483 /* IAPManager.swift in Sources */,
84351B2D2C29AD6100A77989 /* ToolBarContainerView.m in Sources */,
18E4BCEA2E891895000A8657 /* MotionHandler.swift in Sources */,
988FCD41293B091B003050E2 /* KeyboardInputField.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -1260,7 +1342,7 @@
"$(PROJECT_DIR)/Limelight/Database",
);
INFOPLIST_FILE = "VoidLink/Limelight-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VoidLink - beta";
INFOPLIST_KEY_CFBundleDisplayName = VoidLink;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -1271,6 +1353,7 @@
"$(inherited)",
"$(PROJECT_DIR)/third_party/imgui/examples/libs/glfw/lib-vc2010-32",
"$(PROJECT_DIR)/third_party/imgui/examples/libs/glfw/lib-vc2010-64",
"$(PROJECT_DIR)/libs/opus/lib",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(inherited)",
@@ -1325,7 +1408,7 @@
"$(PROJECT_DIR)/Limelight/Database",
);
INFOPLIST_FILE = "VoidLink/Limelight-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VoidLink - beta";
INFOPLIST_KEY_CFBundleDisplayName = VoidLink;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = (
@@ -1336,6 +1419,7 @@
"$(inherited)",
"$(PROJECT_DIR)/third_party/imgui/examples/libs/glfw/lib-vc2010-32",
"$(PROJECT_DIR)/third_party/imgui/examples/libs/glfw/lib-vc2010-64",
"$(PROJECT_DIR)/libs/opus/lib",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(inherited)",
@@ -1388,6 +1472,22 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
182760F52E83E2EA00EC1C5C /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.4;
};
};
18A0EE232EFB9B5A00BA853A /* XCRemoteSwiftPackageReference "SVGKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SVGKit/SVGKit.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 3.0.0;
};
};
F74BEF9A2C1A705600224667 /* XCRemoteSwiftPackageReference "OpenSSL-Package" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/krzyzanowskim/OpenSSL-Package.git";
@@ -1399,6 +1499,31 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
182760F62E83E2EA00EC1C5C /* DequeModule */ = {
isa = XCSwiftPackageProductDependency;
package = 182760F52E83E2EA00EC1C5C /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = DequeModule;
};
182760F92E83E57D00EC1C5C /* DequeModule */ = {
isa = XCSwiftPackageProductDependency;
package = 182760F52E83E2EA00EC1C5C /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = DequeModule;
};
182760FC2E83E68B00EC1C5C /* Collections */ = {
isa = XCSwiftPackageProductDependency;
package = 182760F52E83E2EA00EC1C5C /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = Collections;
};
18A0EE242EFB9B5A00BA853A /* SVGKit */ = {
isa = XCSwiftPackageProductDependency;
package = 18A0EE232EFB9B5A00BA853A /* XCRemoteSwiftPackageReference "SVGKit" */;
productName = SVGKit;
};
18A0EE262EFB9B5A00BA853A /* SVGKitSwift */ = {
isa = XCSwiftPackageProductDependency;
package = 18A0EE232EFB9B5A00BA853A /* XCRemoteSwiftPackageReference "SVGKit" */;
productName = SVGKitSwift;
};
F74BEF9B2C1A705600224667 /* OpenSSL */ = {
isa = XCSwiftPackageProductDependency;
package = F74BEF9A2C1A705600224667 /* XCRemoteSwiftPackageReference "OpenSSL-Package" */;
@@ -1,6 +1,15 @@
{
"originHash" : "f17bf6f1131c7255a75ffd6a989b69573cb49c332e612dffb429905d8d7afe72",
"originHash" : "d1fc50a28d75017e32553df2b233f8a7e2cf1542da32728ce6722b200f745608",
"pins" : [
{
"identity" : "cocoalumberjack",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git",
"state" : {
"revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114",
"version" : "3.9.0"
}
},
{
"identity" : "openssl-package",
"kind" : "remoteSourceControl",
@@ -9,6 +18,33 @@
"revision" : "b9eb055fdf73e595cb4b9f665d13dc85975bf80a",
"version" : "3.3.2000"
}
},
{
"identity" : "svgkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SVGKit/SVGKit.git",
"state" : {
"revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666",
"version" : "3.0.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
"version" : "1.2.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
"version" : "1.8.0"
}
}
],
"version" : 3
BIN
View File
Binary file not shown.
+23 -13
View File
@@ -16,12 +16,12 @@ public struct AboutView: View {
public var aboutVC:UIViewController
public var body: some View {
VStack(spacing: 10) {
VStack(spacing: 7) {
// App 图标
Image(uiImage: UIImage(named: "AppIconMedium") ?? UIImage())
// .resizable()
.resizable(capInsets:EdgeInsets(top: 3.5, leading: 3.5, bottom: 3.5, trailing: 3.5))
.frame(width: 100, height: 100)
.frame(width: 90, height: 90)
.clipShape(RoundedRectangle(cornerRadius: 28))
// App 名称
@@ -35,31 +35,41 @@ public struct AboutView: View {
.font(.subheadline)
.foregroundColor(.gray)
}
// 说明文字
Text(SwiftLocalizationHelper.localizedString(forKey: "From the player community, to the player community."))
.multilineTextAlignment(.center)
.lineLimit(nil)
.frame(maxWidth: 570) // ✅ 避免 Text 被拉得太宽无法换行
if Locale.preferredLanguages.first!.hasPrefix("zh-Hans") {
if #available(iOS 14.0, *) {
Text("闽ICP备17012590号-3A\n主办单位:福州创图信息技术有限公司").font(.caption2)
Text("闽ICP备17012590号-3A 主办单位:福州创图信息技术有限公司").font(.caption2)
.multilineTextAlignment(.center)
.lineLimit(nil)
.frame(maxWidth: 570) // ✅ 避免 Text 被拉得太宽无法换行
.padding()
//.padding()
} else {
Text("闽ICP备17012590号-3A\n主办单位:福州创图信息技术有限公司").font(.caption)
Text("闽ICP备17012590号-3A 主办单位:福州创图信息技术有限公司").font(.caption)
.multilineTextAlignment(.center)
.lineLimit(nil)
.frame(maxWidth: 570) // ✅ 避免 Text 被拉得太宽无法换行
.padding()
//.padding()
}
}
// 说明文字
Text(SwiftLocalizationHelper.localizedString(forKey: "From the player community, to the player community."))
.multilineTextAlignment(.center)
.font(Font.caption.italic())
.lineLimit(nil)
.frame(maxWidth: 570) // ✅ 避免 Text 被拉得太宽无法换行
.padding()
Text(SwiftLocalizationHelper.localizedString(forKey: "VoidLink delivers better performance now!"))
.multilineTextAlignment(.center)
.font(Font.callout.bold())
.lineLimit(nil)
.frame(maxWidth: 570) // ✅ 避免 Text 被拉得太宽无法换行
// 链接按钮
if #available(iOS 14.0, *) {
Link(SwiftLocalizationHelper.localizedString(forKey: "Join us"), destination: URL(string: SwiftLocalizationHelper.localizedString(forKey: "supportLink"))!)
Link(SwiftLocalizationHelper.localizedString(forKey: "Learn more & join us"), destination: URL(string: SwiftLocalizationHelper.localizedString(forKey: "supportLink"))!)
.padding(.top, 10)
Spacer()
// OK 按钮
+1 -1
View File
@@ -29,7 +29,7 @@ import SwiftUI
])
// 🎯 设置弹窗大小
preferredContentSize = CGSize(width: 530, height: 450)
preferredContentSize = CGSize(width: 530, height: 430)
// ⚠️ 一定要设置以下样式才会生效 preferredContentSize
modalPresentationStyle = .formSheet
+88
View File
@@ -0,0 +1,88 @@
//
// AlertControllerUtil.swift
// VoidLink
//
// Created by Weimin on 2025/10/6.
// Copyright © 2025 True砖家 @ Bilibili. All rights reserved.
//
import UIKit
@objc class AlertControllerUtil: NSObject {
/// 类方法,直接在任何 UIViewController 上显示倒计时弹窗
/// - Parameters:
/// - viewController: 要显示弹窗的控制器
/// - title: 弹窗标题
/// - message: 弹窗内容
/// - buttonTitle: 按钮初始文字(倒计时结束后显示)
/// - countdown: 倒计时秒数
/// - completion: 点击确认或倒计时结束的回调
@objc public static var actionCancelled:Bool = false
@objc public static var alertController:UIAlertController = UIAlertController()
@objc public static var cancelButtonString:String = "Cancel"
@objc public static var autoCompletion:Bool = false
@objc class func showAlert(
in viewController: UIViewController,
title: String?,
message: String?,
withCancel: Bool,
buttonTitle: String,
countdown: Int,
action: (() -> Void)? = nil,
completion: (() -> Void)? = nil
) {
var remainingSeconds = countdown
alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let confirmAction = UIAlertAction(title: "\(remainingSeconds)", style: .default) { _ in
actionCancelled = false
completion?()
cancelButtonString = "Cancel"
}
let cancelAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: cancelButtonString), style: .cancel) { _ in
actionCancelled = true
completion?()
cancelButtonString = "Cancel"
}
confirmAction.isEnabled = false
if withCancel {alertController.addAction(cancelAction)}
if buttonTitle != "" && !autoCompletion {alertController.addAction(confirmAction)}
if(countdown == 0) {
confirmAction.setValue(buttonTitle, forKey: "title")
confirmAction.isEnabled = buttonTitle != ""
}
viewController.present(alertController, animated: true, completion: action)
if(countdown == 0) {return}
// 倒计时
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
timer.schedule(deadline: .now(), repeating: 1.0, leeway: .milliseconds(50))
timer.setEventHandler {
remainingSeconds -= 1
if remainingSeconds <= 0 {
timer.cancel()
confirmAction.isEnabled = true
confirmAction.setValue(buttonTitle, forKey: "title")
if autoCompletion {
autoCompletion = false
completion?()
alertController.dismiss(animated: false)
return
}
} else {
confirmAction.setValue("\(remainingSeconds)", forKey: "title")
}
}
timer.resume()
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+67 -10
View File
@@ -16,15 +16,17 @@
@interface DataManager : NSObject
typedef NS_ENUM(NSUInteger, UINavigationBarHeight) {
UINavigationBarHeightIPad = 50,
UINavigationBarHeightIPhone = 44
typedef NS_ENUM(NSInteger, ControllerGyroSwitchMode) {
ControllerGyroSwitchDisabled,
ControllerGyroSwitchPressToToggle,
ControllerGyroSwitchHoldDown
};
typedef NS_ENUM(NSInteger, TouchMode) {
RelativeTouch,
NativeTouch,
AbsoluteTouch,
TouchDisabled,
NativeTouchOnly
};
@@ -42,7 +44,20 @@ typedef NS_ENUM(NSInteger, GyroMode) {
AlwaysController
};
typedef NS_ENUM(NSInteger, HapticEnginePreference) {
HapticEngineAuto,
RumbleDevice,
LeftRightSwapped,
RumbleOff
};
typedef NS_ENUM(NSInteger, ControllerMouseStick) {
LeftStickToMouse,
RightStickToMouse,
};
typedef NS_ENUM(NSInteger, FramePacingMode) {
FramePacingModeOff,
FramePacingModeLegacy,
FramePacingModeQueue
};
@@ -53,7 +68,18 @@ typedef NS_ENUM(NSInteger, SettingsMenuMode) {
RemoveSettingItem,
};
- (void) saveSettingsWithBitrate:(NSInteger)bitrate
typedef NS_ENUM(NSInteger, WidgetSizeTransition) {
keepWidgetSize,
transitionWithOrientation,
};
typedef NS_ENUM(NSInteger, PencilTickMode) {
PencilTickDisabled,
ManualTick
};
- (void) saveSettings:(Settings*)settings
withBitrate:(NSInteger)bitrate
framerate:(NSInteger)framerate
height:(NSInteger)height
width:(NSInteger)width
@@ -69,25 +95,32 @@ typedef NS_ENUM(NSInteger, SettingsMenuMode) {
touchPointerVelocityFactor:(CGFloat)touchPointerVelocityFactor
mousePointerVelocityFactor:(CGFloat)mousePointerVelocityFactor
gyroSensitivity:(CGFloat)gyroSensitivity
localVolume:(CGFloat)localVolume
micVolume:(CGFloat)micVolume
touchMoveEventInterval:(NSInteger)touchMoveEventInterval
reverseMouseWheelDirection:(BOOL)reverseMouseWheelDirection
asyncNativeTouchPriority:(NSInteger)asyncNativeTouchPriority
asyncNativeTouchPriority:(NSInteger)asyncNativeTouchPriority
liftStreamViewForKeyboard:(BOOL)liftStreamViewForKeyboard
showKeyboardToolbar:(BOOL)showKeyboardToolbar
optimizeGames:(BOOL)optimizeGames
multiController:(BOOL)multiController
buttonVisualFeedback:(BOOL)buttonVisualFeedback
touchPointTracking:(BOOL)touchPointTracking
swapABXYButtons:(BOOL)swapABXYButtons
audioOnPC:(BOOL)audioOnPC
redirectMic:(BOOL)redirectMic
useBuiltinMic:(BOOL)useBuiltinMic
preferredCodec:(uint32_t)preferredCodec
enableYUV444:(BOOL)enableYUV444
enableYUV444:(BOOL)enableYUV444
enablePIP:(BOOL)enablePIP
fullColorRange:(BOOL)fullColorRange
enableHdr:(BOOL)enableHdr
btMouseSupport:(BOOL)btMouseSupport
// absoluteTouchMode:(BOOL)absoluteTouchMode
touchMode:(NSInteger)touchMode
statsOverlayLevel:(NSInteger)statsOverlayLevel
statsOverlayEnabled:(BOOL)statsOverlayEnabled
unlockDisplayOrientation:(BOOL)unlockDisplayOrientation
statsOverlayEnabled:(BOOL)statsOverlayEnabled
unlockDisplayOrientation:(BOOL)unlockDisplayOrientation
resolutionSelected:(NSInteger)resolutionSelected
externalDisplayMode:(NSInteger)externalDisplayMode
localMousePointerMode:(NSInteger)localMousePointerMode
@@ -95,8 +128,32 @@ typedef NS_ENUM(NSInteger, SettingsMenuMode) {
enableGraphs:(BOOL)enableGraphs
graphOpacity:(NSInteger)graphOpacity
renderingBackend:(NSInteger)renderingBackend
framePacingMode:(NSInteger)framePacingMode
backgroundSessionTimer:(NSInteger)backgroundSessionTimer;
framePacingMode:(NSInteger)framePacingMode
sendDummyEvent:(BOOL)sendDummyEvent
rememberFoldState:(BOOL)rememberFoldState
singleTapSensitivy:(CGFloat)singleTapSensitivy
hapticEngine:(NSInteger)hapticEngine
edgeSlidingSensitivity:(CGFloat)edgeSlidingSensitivity
audioEngine:(NSInteger)audioEngine
delayLeftClick:(BOOL)delayLeftClick
duckOtherApps:(BOOL)duckOtherApps
muteInBackground:(BOOL)muteInBackground
relativeTouchSlideThreshold:(CGFloat)relativeTouchSlideThreshold
enablePinch:(BOOL)enablePinch
scrollSensitivity:(CGFloat)scrollSensitivity
pinchSensitivity:(CGFloat)pinchSensitivity
ctrlDownForPinch:(BOOL)ctrlDownForPinch
leftClickDelayMs:(CGFloat)leftClickDelayMs
settingsMenuOffset:(CGFloat)settingsMenuOffset
passthroughGestures:(BOOL)passthroughGestures
mapControllerToMouse:(BOOL)mapControllerToMouse
controllerMousePointerVelocity:(CGFloat)controllerMousePointerVelocity
controllerMouseExpo:(CGFloat)controllerMouseExpo
controllerGyroSwitchMode:(NSInteger)controllerGyroSwitchMode
enableFrameTimebase:(BOOL)enableFrameTimebase
asyncFrameDequeue:(BOOL)asyncFrameDequeue
sdrPerformanceWorkaround:(BOOL)sdrPerformanceWorkaround
backgroundSessionTimer:(NSInteger)backgroundSessionTimer;
- (NSArray*) getHosts;
- (void) updateHost:(TemporaryHost*)host;
+71 -8
View File
@@ -55,7 +55,8 @@
return uid;
}
- (void) saveSettingsWithBitrate:(NSInteger)bitrate
- (void) saveSettings:(Settings*)settings
withBitrate:(NSInteger)bitrate
framerate:(NSInteger)framerate
height:(NSInteger)height
width:(NSInteger)width
@@ -71,25 +72,32 @@
touchPointerVelocityFactor:(CGFloat)touchPointerVelocityFactor
mousePointerVelocityFactor:(CGFloat)mousePointerVelocityFactor
gyroSensitivity:(CGFloat)gyroSensitivity
localVolume:(CGFloat)localVolume
micVolume:(CGFloat)micVolume
touchMoveEventInterval:(NSInteger)touchMoveEventInterval
reverseMouseWheelDirection:(BOOL)reverseMouseWheelDirection
asyncNativeTouchPriority:(NSInteger)asyncNativeTouchPriority
asyncNativeTouchPriority:(NSInteger)asyncNativeTouchPriority
liftStreamViewForKeyboard:(BOOL)liftStreamViewForKeyboard
showKeyboardToolbar:(BOOL)showKeyboardToolbar
optimizeGames:(BOOL)optimizeGames
multiController:(BOOL)multiController
buttonVisualFeedback:(BOOL)buttonVisualFeedback
touchPointTracking:(BOOL)touchPointTracking
swapABXYButtons:(BOOL)swapABXYButtons
audioOnPC:(BOOL)audioOnPC
redirectMic:(BOOL)redirectMic
useBuiltinMic:(BOOL)useBuiltinMic
preferredCodec:(uint32_t)preferredCodec
enableYUV444:(BOOL)enableYUV444
enableYUV444:(BOOL)enableYUV444
enablePIP:(BOOL)enablePIP
fullColorRange:(BOOL)fullColorRange
enableHdr:(BOOL)enableHdr
btMouseSupport:(BOOL)btMouseSupport
// absoluteTouchMode:(BOOL)absoluteTouchMode
touchMode:(NSInteger)touchMode
statsOverlayLevel:(NSInteger)statsOverlayLevel
statsOverlayEnabled:(BOOL)statsOverlayEnabled
unlockDisplayOrientation:(BOOL)unlockDisplayOrientation
statsOverlayEnabled:(BOOL)statsOverlayEnabled
unlockDisplayOrientation:(BOOL)unlockDisplayOrientation
resolutionSelected:(NSInteger)resolutionSelected
externalDisplayMode:(NSInteger)externalDisplayMode
localMousePointerMode:(NSInteger)localMousePointerMode
@@ -97,11 +105,36 @@
enableGraphs:(BOOL)enableGraphs
graphOpacity:(NSInteger)graphOpacity
renderingBackend:(NSInteger)renderingBackend
framePacingMode:(NSInteger)framePacingMode
framePacingMode:(NSInteger)framePacingMode
sendDummyEvent:(BOOL)sendDummyEvent
rememberFoldState:(BOOL)rememberFoldState
singleTapSensitivy:(CGFloat)singleTapSensitivy
hapticEngine:(NSInteger)hapticEngine
edgeSlidingSensitivity:(CGFloat)edgeSlidingSensitivity
audioEngine:(NSInteger)audioEngine
delayLeftClick:(BOOL)delayLeftClick
duckOtherApps:(BOOL)duckOtherApps
muteInBackground:(BOOL)muteInBackground
relativeTouchSlideThreshold:(CGFloat)relativeTouchSlideThreshold
enablePinch:(BOOL)enablePinch
scrollSensitivity:(CGFloat)scrollSensitivity
pinchSensitivity:(CGFloat)pinchSensitivity
ctrlDownForPinch:(BOOL)ctrlDownForPinch
leftClickDelayMs:(CGFloat)leftClickDelayMs
settingsMenuOffset:(CGFloat)settingsMenuOffset
passthroughGestures:(BOOL)passthroughGestures
mapControllerToMouse:(BOOL)mapControllerToMouse
controllerMousePointerVelocity:(CGFloat)controllerMousePointerVelocity
controllerMouseExpo:(CGFloat)controllerMouseExpo
controllerGyroSwitchMode:(NSInteger)controllerGyroSwitchMode
enableFrameTimebase:(BOOL)enableFrameTimebase
asyncFrameDequeue:(BOOL)asyncFrameDequeue
sdrPerformanceWorkaround:(BOOL)sdrPerformanceWorkaround
backgroundSessionTimer:(NSInteger)backgroundSessionTimer{
__block Settings* settingsToSave = settings;
[_managedObjectContext performBlockAndWait:^{
Settings* settingsToSave = [self retrieveSettings];
if(!settingsToSave) settingsToSave = [self retrieveSettings];
settingsToSave.framerate = [NSNumber numberWithInteger:framerate];
settingsToSave.bitrate = [NSNumber numberWithInteger:bitrate];
settingsToSave.height = [NSNumber numberWithInteger:height];
@@ -118,6 +151,8 @@
settingsToSave.touchPointerVelocityFactor = [NSNumber numberWithFloat:touchPointerVelocityFactor];
settingsToSave.mousePointerVelocityFactor = [NSNumber numberWithFloat:mousePointerVelocityFactor];
settingsToSave.gyroSensitivity = [NSNumber numberWithFloat:gyroSensitivity];
settingsToSave.localVolume = [NSNumber numberWithFloat:localVolume];
settingsToSave.micVolume = [NSNumber numberWithFloat:micVolume];
settingsToSave.touchMoveEventInterval = [NSNumber numberWithInteger:touchMoveEventInterval];
settingsToSave.reverseMouseWheelDirection = reverseMouseWheelDirection;
settingsToSave.asyncNativeTouchPriority = [NSNumber numberWithInteger:asyncNativeTouchPriority];
@@ -125,11 +160,17 @@
settingsToSave.showKeyboardToolbar = showKeyboardToolbar;
settingsToSave.optimizeGames = optimizeGames;
settingsToSave.multiController = multiController;
settingsToSave.buttonVisualFeedback = buttonVisualFeedback;
settingsToSave.touchPointTracking = touchPointTracking;
settingsToSave.swapABXYButtons = swapABXYButtons;
settingsToSave.playAudioOnPC = audioOnPC;
settingsToSave.redirectMic = redirectMic;
settingsToSave.useBuiltinMic = useBuiltinMic;
settingsToSave.preferredCodec = preferredCodec;
settingsToSave.enableYUV444 = enableYUV444;
settingsToSave.sdrPerformanceWorkaround = sdrPerformanceWorkaround;
settingsToSave.enablePIP = enablePIP;
settingsToSave.fullColorRange = fullColorRange;
settingsToSave.enableHdr = enableHdr;
settingsToSave.btMouseSupport = btMouseSupport;
// settingsToSave.absoluteTouchMode = absoluteTouchMode;
@@ -143,10 +184,32 @@
settingsToSave.backroundSessionTimer = [NSNumber numberWithInteger:backgroundSessionTimer];
settingsToSave.frameQueueSize = [NSNumber numberWithInteger:frameQueueSize];
settingsToSave.enableFrameTimebase = enableFrameTimebase;
settingsToSave.asyncFrameDequeue = asyncFrameDequeue;
settingsToSave.enableGraphs = enableGraphs;
settingsToSave.graphOpacity = [NSNumber numberWithInteger:graphOpacity];
settingsToSave.renderingBackend = [NSNumber numberWithInteger:renderingBackend];
settingsToSave.framePacingMode = [NSNumber numberWithInteger:framePacingMode];
settingsToSave.sendDummyEvent = sendDummyEvent;
settingsToSave.singleTapSensitivity = [NSNumber numberWithDouble:singleTapSensitivy];
settingsToSave.hapticEngine = [NSNumber numberWithInteger:hapticEngine];
settingsToSave.edgeSlidingSensitivity = [NSNumber numberWithFloat:edgeSlidingSensitivity];
settingsToSave.audioEngine = [NSNumber numberWithInteger:audioEngine];
settingsToSave.delayLeftClick = delayLeftClick;
settingsToSave.duckOtherApps = duckOtherApps;
settingsToSave.muteInBackground = muteInBackground;
settingsToSave.relativeTouchSlideThreshold = [NSNumber numberWithFloat:relativeTouchSlideThreshold];
settingsToSave.enablePinch = enablePinch;
settingsToSave.scrollSensitivity = [NSNumber numberWithFloat:scrollSensitivity];
settingsToSave.pinchSensitivity = [NSNumber numberWithFloat:pinchSensitivity];
settingsToSave.ctrlDownForPinch = ctrlDownForPinch;
settingsToSave.leftClickDelayMs = [NSNumber numberWithFloat:leftClickDelayMs];
settingsToSave.settingsMenuOffset = [NSNumber numberWithFloat:settingsMenuOffset];
settingsToSave.passthroughGestures = passthroughGestures;
settingsToSave.mapControllerToMouse = mapControllerToMouse;
settingsToSave.controllerMousePointerVelocity = [NSNumber numberWithFloat:controllerMousePointerVelocity];
settingsToSave.controllerMouseExpo = [NSNumber numberWithFloat:controllerMouseExpo];
settingsToSave.rememberFoldState = rememberFoldState;
[self saveData];
}];
}
+1
View File
@@ -31,6 +31,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (atomic, retain) NSString *name;
@property (atomic, retain) NSString *uuid;
@property (atomic, retain) NSSet *appList;
@property (atomic, retain) NSMutableSet * activeAddressPool;
- (id) initFromHost:(Host*)host;
+21 -1
View File
@@ -28,6 +28,17 @@
self = [self init];
self.address = host.address;
NSSet *classes = [NSSet setWithObjects: [NSMutableSet class], [NSString class], nil];
NSError *error = nil;
self.activeAddressPool = [NSKeyedUnarchiver unarchivedObjectOfClasses:classes fromData:host.activeAddressPool error:&error];
if(self.activeAddressPool){
for(NSString* address in _activeAddressPool){
NSLog(@"%@, persisted addr: %@", host.name, address);
}
}
else self.activeAddressPool = [[NSMutableSet alloc] init];
self.externalAddress = host.externalAddress;
self.localAddress = host.localAddress;
self.ipv6Address = host.ipv6Address;
@@ -57,7 +68,7 @@
return self;
}
- (void) propagateChangesToParent:(Host*)parentHost {
- (void) propagateChangesToParent:(Host*)parentHost { // update persisted host data
// Avoid overwriting existing data with nil if
// we don't have everything populated in the temporary
// host.
@@ -73,6 +84,15 @@
if (self.ipv6Address != nil) {
parentHost.ipv6Address = self.ipv6Address;
}
if(self.activeAddressPool.count>0){
NSError *error;
parentHost.activeAddressPool = [NSKeyedArchiver archivedDataWithRootObject:self.activeAddressPool requiringSecureCoding:YES error:&error];
}
else [self.activeAddressPool addObject:self.activeAddress];
// NSLog(@"Persisting activeAddressPool, pool count %lu ... %f, host: %@", (unsigned long)self.activeAddressPool.count, CACurrentMediaTime(), self.name);
// try to fix invalid mac happens in some cases
// 添加主机crash问题重点关注
if (!(self.mac == nil || [self.mac isEqualToString:@"00:00:00:00:00:00"])) parentHost.mac = self.mac;
+44
View File
@@ -33,6 +33,8 @@
@property (nonatomic, retain) NSNumber * touchMoveEventInterval;
@property (nonatomic, retain) NSNumber * touchPointerVelocityFactor;
@property (nonatomic, retain) NSNumber * gyroSensitivity;
@property (nonatomic, retain) NSNumber * localVolume;
@property (nonatomic, retain) NSNumber * micVolume;
@property (nonatomic, retain) NSNumber * emulatedControllerType;
@property (nonatomic, retain) NSNumber * mousePointerVelocityFactor;
@property (nonatomic, retain) NSNumber * pointerVelocityModeDivider;
@@ -40,6 +42,9 @@
@property (nonatomic, retain) NSNumber * resolutionSelected;
@property (nonatomic, retain) NSNumber * externalDisplayMode;
@property (nonatomic, retain) NSNumber * localMousePointerMode;
@property (nonatomic, retain) NSNumber * gyroBiasX;
@property (nonatomic, retain) NSNumber * gyroBiasY;
@property (nonatomic, retain) NSNumber * gyroBiasZ;
@property (nonatomic, retain) NSNumber * backgroundSessionTimer;
@property (nonatomic) enum {
CODEC_PREF_AUTO,
@@ -48,12 +53,18 @@
CODEC_PREF_AV1,
} preferredCodec;
@property (nonatomic) BOOL enableYUV444;
@property (nonatomic) BOOL sdrPerformanceWorkaround;
@property (nonatomic) BOOL enablePIP;
@property (nonatomic) BOOL fullColorRange;
@property (nonatomic) BOOL reverseMouseWheelDirection;
@property (nonatomic, retain) NSNumber * asyncNativeTouchPriority;
@property (nonatomic) BOOL multiController;
@property (nonatomic) BOOL buttonVisualFeedback;
@property (nonatomic) BOOL touchPointTracking;
@property (nonatomic) BOOL swapABXYButtons;
@property (nonatomic) BOOL playAudioOnPC;
@property (nonatomic) BOOL redirectMic;
@property (nonatomic) BOOL useBuiltinMic;
@property (nonatomic) BOOL optimizeGames;
@property (nonatomic) BOOL enableHdr;
@property (nonatomic) BOOL btMouseSupport;
@@ -65,10 +76,43 @@
@property (nonatomic) BOOL showKeyboardToolbar;
@property (nonatomic) BOOL unlockDisplayOrientation;
@property (nonatomic) BOOL enableGraphs;
@property (nonatomic) BOOL enableFrameTimebase;
@property (nonatomic) BOOL asyncFrameDequeue;
@property (nonatomic, retain) NSNumber * frameQueueSize;
@property (nonatomic, retain) NSNumber * graphOpacity;
@property (nonatomic, retain) NSNumber * renderingBackend;
@property (nonatomic) BOOL sendDummyEvent;
@property (nonatomic) BOOL rememberFoldState;
@property (nonatomic, retain) NSNumber * singleTapSensitivity;
@property (nonatomic, retain) NSNumber * hapticEngine;
@property (nonatomic, retain) NSNumber * appTheme;
@property (nonatomic, retain) NSNumber * edgeSlidingSensitivity;
@property (nonatomic, retain) NSNumber * audioEngine;
@property (nonatomic) BOOL delayLeftClick;
@property (nonatomic) BOOL duckOtherApps;
@property (nonatomic) BOOL muteInBackground;
@property (nonatomic) BOOL enablePinch;
@property (nonatomic) BOOL ctrlDownForPinch;
@property (nonatomic) BOOL passthroughGestures;
@property (nonatomic, retain) NSNumber * framePacingMode;
@property (nonatomic, retain) NSNumber * scrollSensitivity;
@property (nonatomic, retain) NSNumber * pinchSensitivity;
@property (nonatomic, retain) NSNumber * relativeTouchSlideThreshold;
@property (nonatomic, retain) NSNumber * leftClickDelayMs;
@property (nonatomic, retain) NSNumber * settingsMenuOffset;
@property (nonatomic, retain) NSNumber * controllerMouseLeftButton;
@property (nonatomic, retain) NSNumber * controllerMouseRightButton;
@property (nonatomic, retain) NSNumber * controllerMouseStick;
@property (nonatomic, retain) NSNumber * controllerMouseSwitch;
@property (nonatomic) BOOL mapControllerToMouse;
@property (nonatomic, retain) NSNumber * controllerMousePointerVelocity;
@property (nonatomic, retain) NSNumber * controllerMouseExpo;
// Pencil settings:
@property (nonatomic, retain) NSNumber * pencilTickMode;
@property (nonatomic, retain) NSNumber * pencilTickIntervalUs;
- (id) initFromSettings:(Settings*)settings;
+47 -10
View File
@@ -42,6 +42,7 @@
self.preferredCodec = (typeof(self.preferredCodec))[[NSUserDefaults standardUserDefaults] integerForKey:@"preferredCodec"];
self.enableYUV444 = [[NSUserDefaults standardUserDefaults] boolForKey:@"enableYUV444"];
self.enablePIP = [[NSUserDefaults standardUserDefaults] boolForKey:@"enablePIP"];
self.fullRange = [[NSUserDefaults standardUserDefaults] boolForKey:@"fullRange"];
self.frameQueueSize = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"frameQueueSize"]];
self.playAudioOnPC = [[NSUserDefaults standardUserDefaults] boolForKey:@"audioOnPC"];
self.enableHdr = [[NSUserDefaults standardUserDefaults] boolForKey:@"enableHdr"];
@@ -52,15 +53,7 @@
self.statsOverlay = [[NSUserDefaults standardUserDefaults] boolForKey:@"statsOverlay"];
self.enableGraphs = [[NSUserDefaults standardUserDefaults] boolForKey:@"enableGraphs"];
self.graphOpacity = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"graphOpacity"]];
// Set rendering backend based on iOS version and user preference
NSInteger savedBackend = [[NSUserDefaults standardUserDefaults] integerForKey:@"renderingBackend"];
if (@available(iOS 17.0, *)) {
// iOS 17+ can use Performance mode (Metal renderer)
self.renderingBackend = [NSNumber numberWithInteger:savedBackend];
} else {
// iOS < 17 must use Balanced mode (AVSB renderer)
self.renderingBackend = [NSNumber numberWithInteger:RENDER_AVSB];
}
self.renderingBackend = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"renderingBackend"]];
self.framePacingMode = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"framePacingMode"]];
NSInteger _screenSize = [[NSUserDefaults standardUserDefaults] integerForKey:@"streamResolution"];
@@ -95,12 +88,20 @@
self.audioConfig = settings.audioConfig;
self.preferredCodec = settings.preferredCodec;
self.enableYUV444 = settings.enableYUV444;
self.sdrPerformanceWorkaround = settings.sdrPerformanceWorkaround;
self.enablePIP = settings.enablePIP;
self.fullColorRange = settings.fullColorRange;
self.frameQueueSize = settings.frameQueueSize;
self.enableFrameTimebase = settings.enableFrameTimebase;
self.asyncFrameDequeue = settings.asyncFrameDequeue;
self.playAudioOnPC = settings.playAudioOnPC;
self.redirectMic = settings.redirectMic;
self.useBuiltinMic = settings.useBuiltinMic;
self.enableHdr = settings.enableHdr;
self.optimizeGames = settings.optimizeGames;
self.multiController = settings.multiController;
self.buttonVisualFeedback = settings.buttonVisualFeedback;
self.touchPointTracking = settings.touchPointTracking;
self.swapABXYButtons = settings.swapABXYButtons;
self.onscreenControls = settings.onscreenControls;
self.gyroMode = settings.gyroMode;
@@ -122,6 +123,8 @@
self.touchPointerVelocityFactor = settings.touchPointerVelocityFactor;
self.mousePointerVelocityFactor = settings.mousePointerVelocityFactor;
self.gyroSensitivity = settings.gyroSensitivity;
self.localVolume = settings.localVolume;
self.micVolume = settings.micVolume;
self.pointerVelocityModeDivider = settings.pointerVelocityModeDivider;
self.unlockDisplayOrientation = settings.unlockDisplayOrientation;
self.resolutionSelected = settings.resolutionSelected;
@@ -131,9 +134,43 @@
self.graphOpacity = settings.graphOpacity;
self.renderingBackend = settings.renderingBackend;
self.framePacingMode = settings.framePacingMode;
self.sendDummyEvent = settings.sendDummyEvent;
self.rememberFoldState = settings.rememberFoldState;
self.gyroBiasX = settings.gyroBiasX;
self.gyroBiasY = settings.gyroBiasY;
self.gyroBiasZ = settings.gyroBiasZ;
self.singleTapSensitivity = settings.singleTapSensitivity;
self.backgroundSessionTimer = settings.backroundSessionTimer;
#endif
self.edgeSlidingSensitivity = settings.edgeSlidingSensitivity;
self.appTheme = settings.appTheme;
self.hapticEngine = settings.hapticEngine;
self.uniqueId = settings.uniqueId;
self.audioEngine = settings.audioEngine;
self.delayLeftClick = settings.delayLeftClick;
self.duckOtherApps = settings.duckOtherApps;
self.muteInBackground = settings.muteInBackground;
self.relativeTouchSlideThreshold = settings.relativeTouchSlideThreshold;
self.enablePinch = settings.enablePinch;
self.scrollSensitivity = settings.scrollSensitivity;
self.pinchSensitivity = settings.pinchSensitivity;
self.leftClickDelayMs = settings.leftClickDelayMs;
self.ctrlDownForPinch = settings.ctrlDownForPinch;
self.settingsMenuOffset = settings.settingsMenuOffset;
self.passthroughGestures = settings.passthroughGestures;
self.mapControllerToMouse = settings.mapControllerToMouse;
self.controllerMouseLeftButton = settings.controllerMouseLeftButton;
self.controllerMouseRightButton = settings.controllerMouseRightButton;
self.controllerMouseSwitch = settings.controllerMouseSwitch;
self.controllerMouseStick = settings.controllerMouseStick;
self.controllerMousePointerVelocity = settings.controllerMousePointerVelocity;
self.controllerMouseExpo = settings.controllerMouseExpo;
// Pencil settings:
self.pencilTickMode = settings.pencilTickMode;
self.pencilTickIntervalUs = settings.pencilTickIntervalUs;
#endif
return self;
}
+1 -1
View File
@@ -28,6 +28,6 @@
- (void)resizeBySizeFactor:(CGFloat)factor;
- (id) initWithHost:(TemporaryHost*)host;
- (id) initWithHost:(TemporaryHost*)host andSizeFactor:(CGFloat)sizeFactor;
- (void)updateTheme;
@end
+38 -34
View File
@@ -8,7 +8,7 @@
#import "HostCardView.h"
#import "LocalizationHelper.h"
#import "ThemeManager.h"
#import "VoidLink-Swift.h"
#import "FixedTintImageView.h"
@@ -62,7 +62,7 @@ static const float REFRESH_CYCLE = 2.0f;
computerIconMonitorCenterYOffset = [self isIPhone] ? -2.75*_sizeFactor : -3.2*_sizeFactor;
iconAndButtonSpacing = 37*_sizeFactor;
buttonHeight = 39*_sizeFactor;
defaultBlue = [ThemeManager appPrimaryColor];
defaultBlue = ThemeManager.appPrimaryColor;
defaultGreen = [UIColor colorWithRed:52.0/255.0 green:199.0/255.0 blue:89.0/255.0 alpha:1.0];
// self.userIterfaceStyle = UIUserInterfaceStyleLight
@@ -165,7 +165,7 @@ static const float REFRESH_CYCLE = 2.0f;
- (void)setupUI {
self.userInteractionEnabled = YES;
self.backgroundColor = [ThemeManager widgetBackgroundColor]; // theme
self.backgroundColor = ThemeManager.widgetBackgroundColor; // theme
self.cardContentpadding = 13 * _sizeFactor;
self.clipsToBounds = YES;
self.translatesAutoresizingMaskIntoConstraints = NO;
@@ -296,7 +296,7 @@ static const float REFRESH_CYCLE = 2.0f;
self.appButton.translatesAutoresizingMaskIntoConstraints = NO;
self.appButton.frame = CGRectMake(20, 200, 150, buttonHeight);
[self.appButton setTitle:[LocalizationHelper localizedStringForKey:@"Applications"] forState:UIControlStateNormal];
[self.appButton setTitleColor:[ThemeManager textColorGray] forState:UIControlStateNormal]; //theme
[self.appButton setTitleColor:ThemeManager.textColorGray forState:UIControlStateNormal]; //theme
self.appButton.titleLabel.font = [UIFont systemFontOfSize:buttonLabelFontSize];
[self.appButton addTarget:self action:@selector(appButtonTapped) forControlEvents:UIControlEventPrimaryActionTriggered];
[self addSubview:self.appButton];
@@ -325,7 +325,6 @@ static const float REFRESH_CYCLE = 2.0f;
if (@available(iOS 13.0, *)) {
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:buttonHeight*0.263];
[self.launchButton setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:config] forState:UIControlStateNormal];
} else {
// Fallback on earlier versions
}
@@ -343,7 +342,7 @@ static const float REFRESH_CYCLE = 2.0f;
self.pairButton = [UIButton buttonWithType:UIButtonTypeSystem];
self.pairButton.translatesAutoresizingMaskIntoConstraints = NO;
self.pairButton.frame = CGRectMake(0, 0, 150, 50);
self.pairButton.backgroundColor = [ThemeManager textTintColorWithAlpha];
self.pairButton.backgroundColor = ThemeManager.textTintColorWithAlpha;
self.pairButton.layer.cornerRadius = 2*(uint16_t)(cardWidth*0.0377/2);
[self.pairButton setTitle:[LocalizationHelper localizedStringForKey:@" Pair with PIN"] forState:UIControlStateNormal];
[self.pairButton setTitleColor:defaultBlue forState:UIControlStateNormal]; // theme
@@ -356,7 +355,7 @@ static const float REFRESH_CYCLE = 2.0f;
} else {
// Fallback on earlier versions
}
self.pairButton.backgroundColor = [ThemeManager textTintColorWithAlpha];
self.pairButton.backgroundColor = ThemeManager.textTintColorWithAlpha;
[self.pairButton setTitleColor:defaultBlue forState:UIControlStateNormal]; // theme
[self.pairButton addTarget:self action:@selector(pairButtonTapped) forControlEvents:UIControlEventPrimaryActionTriggered];
@@ -377,7 +376,7 @@ static const float REFRESH_CYCLE = 2.0f;
self.wakeupButton = [UIButton buttonWithType:UIButtonTypeSystem];
self.wakeupButton.translatesAutoresizingMaskIntoConstraints = NO;
self.wakeupButton.frame = CGRectMake(0, 0, 150, 50);
self.wakeupButton.backgroundColor = [ThemeManager textTintColorWithAlpha];
self.wakeupButton.backgroundColor = ThemeManager.textTintColorWithAlpha;
self.wakeupButton.layer.cornerRadius = 2*(uint16_t)(cardWidth*0.0377/2);
[self.wakeupButton setTitle:[LocalizationHelper localizedStringForKey:@" Wake-on-LAN"] forState:UIControlStateNormal];
[self.wakeupButton setTitleColor:defaultBlue forState:UIControlStateNormal]; // theme
@@ -425,17 +424,15 @@ static const float REFRESH_CYCLE = 2.0f;
[self.transparentButton.bottomAnchor constraintEqualToAnchor:self.separatorLine.topAnchor],
]];
_widthConstraint.constant = _cardContentpadding*2 + appButtonWidth + 15*_sizeFactor + launchButtonWidth;
_heightConstraint.constant = _cardContentpadding*2 + _iconBackgroundView.frame.size.height + iconAndButtonSpacing + buttonHeight + 1;
_size = CGSizeMake(_widthConstraint.constant, _heightConstraint.constant);
[self updateTheme:ThemeManager.userInterfaceStyle];
[self updateTheme];
}
- (void)createBackgroundLayer{
backgroundLayer = [CAGradientLayer layer];
- (void)updateBackgroundLayerTheme{
UIColor *gradientColorDark = [UIColor colorWithRed:0.0 green:0.319 blue:0.64 alpha:1.0];
UIColor *gradientColorLight = [gradientColorDark colorWithAlphaComponent:0.52];
CGColorRef gradientColorRef = ThemeManager.userInterfaceStyle == UIUserInterfaceStyleDark ? gradientColorDark.CGColor : gradientColorLight.CGColor;
@@ -445,7 +442,11 @@ static const float REFRESH_CYCLE = 2.0f;
(__bridge id)[UIColor clearColor].CGColor,
(__bridge id)gradientColorRef
];
}
- (void)createBackgroundLayer{
backgroundLayer = [CAGradientLayer layer];
[self updateBackgroundLayerTheme];
backgroundLayer.locations = @[@0, @0.18, @0.5, @1];
backgroundLayer.startPoint = CGPointMake(0.25, 0.5);
backgroundLayer.endPoint = CGPointMake(0.75, 0.5);
@@ -469,12 +470,15 @@ static const float REFRESH_CYCLE = 2.0f;
}
- (void)updateTheme:(UIUserInterfaceStyle)userIterfaceStyle{
self.backgroundColor = [ThemeManager widgetBackgroundColor];
_hostNameLabel.textColor = [ThemeManager textColor];
[_appButton setTitleColor:[ThemeManager appPrimaryColor] forState:UIControlStateNormal];
_separatorLine.backgroundColor = [ThemeManager separatorColor];
- (void)updateTheme {
self.backgroundColor = ThemeManager.widgetBackgroundColor;
_hostNameLabel.textColor = ThemeManager.textColor;
[_appButton setTitleColor:ThemeManager.appPrimaryColor forState:UIControlStateNormal];
_separatorLine.backgroundColor = ThemeManager.hostCardSeparatorColor;
backgroundLayer.hidden = NO;
[self updateBackgroundLayerTheme];
[self updateContentsForHost:_host];
}
@@ -500,7 +504,7 @@ static const float REFRESH_CYCLE = 2.0f;
}
- (bool)isDarkTheme{
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
return ThemeManager.userInterfaceStyle == UIUserInterfaceStyleDark;
}
- (void) updateLoop {
@@ -555,7 +559,7 @@ static const float REFRESH_CYCLE = 2.0f;
[_launchButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; // theme
}
else {
_iconBackgroundView.backgroundColor = [ThemeManager appPrimaryColorWithAlpha];
_iconBackgroundView.backgroundColor = ThemeManager.appPrimaryColorWithAlpha;
UIColor* iconColor = [[UIColor whiteColor] colorWithAlphaComponent:[self isDarkTheme] ? 0.63 : 1];
_hostIconView.tintColor = iconColor;
lockIconView.tintColor = iconColor;
@@ -569,18 +573,18 @@ static const float REFRESH_CYCLE = 2.0f;
case StateOffline:
[_hostSpinner stopAnimating];
// _iconBackgroundView.backgroundColor = _userIterfaceStyle == UIUserInterfaceStyleDark ? [UIColor appBackgroundColorDark] : [UIColor appBackgroundColorLight];
_iconBackgroundView.backgroundColor = [ThemeManager appBackgroundColor];
_statusLabel.textColor = [ThemeManager textColorGray];
_iconBackgroundView.backgroundColor = ThemeManager.offlineHostIconBackgroundColor;
_statusLabel.textColor = ThemeManager.textColorGray;
_statusLabel.text = [LocalizationHelper localizedStringForKey: @"Offline"];
_statusIcon.tintColor = [ThemeManager textColorGray];
_statusIcon.tintColor = ThemeManager.textColorGray;
if (@available(iOS 13.0, *)) {
_statusIcon.image = [UIImage systemImageNamed:@"exclamationmark.triangle.fill"];
} else {
self.statusIcon.hidden = YES;
}
_hostIconView.tintColor = [ThemeManager lowProfileGray];
lockIconView.tintColor = [ThemeManager lowProfileGray];
_hostIconView.tintColor = ThemeManager.lowProfileGray;
lockIconView.tintColor = ThemeManager.lowProfileGray;
lockIconView.hidden = hostPaired;
_appButton.hidden = YES;
_launchButton.hidden = YES;
@@ -590,24 +594,24 @@ static const float REFRESH_CYCLE = 2.0f;
if (@available(iOS 13.0, *)) {
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:buttonHeight/3.45 weight:UIImageSymbolWeightBold];
UIImage *templateImage = [UIImage systemImageNamed:@"power" withConfiguration:config];
UIImage *coloredImage = [templateImage imageWithTintColor:hasValidMac ? defaultBlue : [ThemeManager textColorGray] renderingMode:UIImageRenderingModeAlwaysOriginal];
UIImage *coloredImage = [templateImage imageWithTintColor:hasValidMac ? defaultBlue : ThemeManager.textColorGray renderingMode:UIImageRenderingModeAlwaysOriginal];
[self.wakeupButton setImage:coloredImage forState:UIControlStateNormal];
} else {
// Fallback on earlier versions
}
self.wakeupButton.backgroundColor = hasValidMac ? [ThemeManager textTintColorWithAlpha] : [[ThemeManager textColorGray] colorWithAlphaComponent:0.2];
[self.wakeupButton setTitleColor: hasValidMac ? defaultBlue : [ThemeManager textColorGray] forState:UIControlStateNormal];
self.wakeupButton.backgroundColor = hasValidMac ? ThemeManager.textTintColorWithAlpha : [ThemeManager.textColorGray colorWithAlphaComponent:0.2];
[self.wakeupButton setTitleColor: hasValidMac ? defaultBlue : ThemeManager.textColorGray forState:UIControlStateNormal];
break;
case StateUnknown:
_hostSpinner.color = [UIColor whiteColor];
[_hostSpinner startAnimating];
// _iconBackgroundView.backgroundColor = _userIterfaceStyle == UIUserInterfaceStyleDark ? [UIColor appBackgroundColorDark] : [UIColor appBackgroundColorLight];
_iconBackgroundView.backgroundColor = [ThemeManager appPrimaryColorWithAlpha];
_statusLabel.textColor = [ThemeManager textColorGray];
_iconBackgroundView.backgroundColor = ThemeManager.appPrimaryColorWithAlpha;
_statusLabel.textColor = ThemeManager.textColorGray;
_statusLabel.text = [LocalizationHelper localizedStringForKey: @"Detecting..."];
_statusIcon.tintColor = [ThemeManager textColorGray];
_statusIcon.tintColor = ThemeManager.textColorGray;
if (@available(iOS 13.0, *)) {
_statusIcon.image = [UIImage systemImageNamed:@"antenna.radiowaves.left.and.right"];
} else {
@@ -624,13 +628,13 @@ static const float REFRESH_CYCLE = 2.0f;
if (@available(iOS 13.0, *)) {
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:buttonHeight/3.45 weight:UIImageSymbolWeightBold];
UIImage *templateImage = [UIImage systemImageNamed:@"power" withConfiguration:config];
UIImage *coloredImage = [templateImage imageWithTintColor:hasValidMac ? defaultBlue : [ThemeManager textColorGray] renderingMode:UIImageRenderingModeAlwaysOriginal];
UIImage *coloredImage = [templateImage imageWithTintColor:hasValidMac ? defaultBlue : ThemeManager.textColorGray renderingMode:UIImageRenderingModeAlwaysOriginal];
[self.wakeupButton setImage:coloredImage forState:UIControlStateNormal];
} else {
// Fallback on earlier versions
}
self.wakeupButton.backgroundColor = hasValidMac ? [ThemeManager textTintColorWithAlpha] : [[ThemeManager textColorGray] colorWithAlphaComponent:0.2];
[self.wakeupButton setTitleColor: hasValidMac ? defaultBlue : [ThemeManager textColorGray] forState:UIControlStateNormal];
self.wakeupButton.backgroundColor = hasValidMac ? ThemeManager.textTintColorWithAlpha : [ThemeManager.textColorGray colorWithAlphaComponent:0.2];
[self.wakeupButton setTitleColor: hasValidMac ? defaultBlue : ThemeManager.textColorGray forState:UIControlStateNormal];
break;
default:break;
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "StickWheel.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" fill="none" viewBox="0 0 120 120">
<g clip-path="url(#a)">
<g filter="url(#b)">
<path fill="#fff" fill-opacity="1"
d="M67.25 10.437c.099.075.215.123.338.142a50 50 0 0 1 27.233 13.54.74.74 0 0 1 .004 1.056l-.776.775a.76.76 0 0 1-1.065.004 47.405 47.405 0 0 0-65.97 0 .76.76 0 0 1-1.064-.004l-.776-.775a.74.74 0 0 1 .004-1.057A50 50 0 0 1 52.41 10.58a.76.76 0 0 0 .339-.142l6.8-5.1a.75.75 0 0 1 .9 0z"/>
</g>
<g filter="url(#c)">
<path fill="#fff" fill-opacity="1"
d="M61.955 6.804c2.487 2.295 5.657 3.663 8.96 4.402a50 50 0 0 1 23.51 12.533c.52.493.52 1.316.013 1.823a1.326 1.326 0 0 1-1.849.013 47.403 47.403 0 0 0-65.179 0 1.326 1.326 0 0 1-1.848-.013 1.27 1.27 0 0 1 .012-1.823 50 50 0 0 1 23.512-12.533c3.302-.739 6.47-2.106 8.958-4.401L60 5z"
/>
</g>
</g>
<defs>
<!-- defs stay the same -->
<filter id="b" width="82.089" height="32.983" x="18.955" y="-.813" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur result="effect1_foregroundBlur_211_1847" stdDeviation="3"/>
</filter>
<filter id="c" width="69.634" height="22.444" x="25.183" y="5" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_211_1847"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow_211_1847" result="shape"/>
</filter>
<clipPath id="a">
<path fill="#fff" d="M0 0h120v120H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "gyroscope.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 341-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 22.7531 22.4167">
<g>
<rect height="22.4167" opacity="0" width="22.7531" x="0" y="0"/>
<path d="M4.66382 17.7636C6.51148 19.6044 10.4016 18.2265 14.3104 14.3211C18.2231 10.4016 19.6044 6.51148 17.7568 4.67065C15.8832 2.7976 12.0151 4.17898 8.08824 8.09898C4.17557 12.0224 2.79027 15.8832 4.66382 17.7636ZM5.99819 16.4151C4.94393 15.3574 6.33263 12.5482 9.42944 9.44409C12.5521 6.3214 15.3647 4.94734 16.4151 6.0016C17.4694 7.04854 16.0841 9.85382 12.9687 12.9833C9.86457 16.0841 7.04854 17.4728 5.99819 16.4151ZM17.7456 17.7602C19.6118 15.8832 18.2304 12.0258 14.3104 8.09507C10.4016 4.17898 6.52613 2.7976 4.64917 4.66724C2.80493 6.51148 4.19022 10.4016 8.1024 14.3138C12.0151 18.2265 15.8979 19.5971 17.7456 17.7602ZM16.4044 16.4151C15.3608 17.4728 12.5482 16.0841 9.42944 12.9687C6.31798 9.85724 4.94393 7.04854 5.99086 6.0016C7.04121 4.94734 9.85724 6.3214 12.9687 9.44018C16.0767 12.5516 17.4694 15.3608 16.4044 16.4151ZM19.0764 4.59024L21.8772 1.7895C22.2092 1.45357 22.2092 0.882298 21.8772 0.550279C21.5271 0.203607 20.97 0.210933 20.6272 0.550279L17.7762 3.39741ZM3.61603 17.5507L0.550279 20.6272C0.200191 20.9665 0.210933 21.5237 0.550279 21.863C0.878882 22.2058 1.43941 22.2097 1.7895 21.8737L5.03247 18.6342ZM11.203 22.406C17.3909 22.406 22.4094 17.3875 22.4094 11.203C22.4094 5.01849 17.3909 0 11.203 0C5.01849 0 0 5.01849 0 11.203C0 17.3875 5.01849 22.406 11.203 22.406ZM11.203 19.9444C6.36936 19.9444 2.46154 16.0366 2.46154 11.203C2.46154 6.36936 6.36936 2.46154 11.203 2.46154C16.0366 2.46154 19.9478 6.36936 19.9478 11.203C19.9478 16.0366 16.0366 19.9444 11.203 19.9444Z" fill="white" fill-opacity="0.85"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
View File
Binary file not shown.
+3 -1
View File
@@ -11,8 +11,10 @@
NS_ASSUME_NONNULL_BEGIN
@interface AbsoluteTouchHandler : UIResponder
@property (class, nonatomic, assign) int mouseButtonForCursorMove;
-(id)initWithView:(StreamView*)view;
- (id)initWithView:(StreamView*)view andSettings:(TemporarySettings*)settings;
- (void)pauseLeftButtonDrag;
@end
+178 -30
View File
@@ -10,6 +10,7 @@
//
#import "AbsoluteTouchHandler.h"
#import "VoidLink-Swift.h"
#include <Limelight.h>
@@ -17,7 +18,7 @@
#define LONG_PRESS_ACTIVATION_DELAY 0.650f
// How far the finger can move before it cancels a right click
#define LONG_PRESS_ACTIVATION_DELTA 0.01f
#define LONG_PRESS_ACTIVATION_DELTA 0.02f
// How long the double tap deadzone stays in effect between touch up and touch down
#define DOUBLE_TAP_DEAD_ZONE_DELAY 0.250f
@@ -25,8 +26,13 @@
// How far the finger can move before it can override the double tap deadzone
#define DOUBLE_TAP_DEAD_ZONE_DELTA 0.025f
static int mouseButtonForCursorMove = BUTTON_LEFT;
@implementation AbsoluteTouchHandler {
StreamView* streamView;
__weak StreamView* streamView;
bool multiTouchesDetected;
bool passthroughGestures;
NSTimer* longPressTimer;
UITouch* lastTouchDown;
@@ -34,37 +40,83 @@
UITouch* lastTouchUp;
CGPoint lastTouchUpLocation;
UITouch* capturedTouch;
CGPoint touchBeganLocation;
CGPoint movingTouchLocation;
NSTimeInterval touchBeganTimeStamp;
NSTimeInterval leftClickTimeThreshold;
// upper screen edge check
bool touchPointSpawnedAtUpperScreenEdge;
CGFloat slideGestureVerticalThreshold;
CGFloat screenWidthWithThreshold;
CGFloat EDGE_TOLERANCE;
CGFloat _edgeTolerance;
bool _delayMouseLeftClick;
NSTimeInterval leftClickDelay;
bool dragButtonDown;
UInt8 currentTouchesCount;
bool rightButtonClicked;
}
- (id)initWithView:(StreamView*)view {
- (id)initWithView:(StreamView*)view andSettings:(TemporarySettings*)settings {
self = [self init];
self->streamView = view;
multiTouchesDetected = false;
passthroughGestures = settings.passthroughGestures;
_delayMouseLeftClick = settings.delayLeftClick;
// _delayMouseLeftClick = true;
dragButtonDown = false;
leftClickTimeThreshold = 0.1;
// upper screen check
EDGE_TOLERANCE = 10.0;
_edgeTolerance = settings.edgeSlidingSensitivity.floatValue;
slideGestureVerticalThreshold = CGRectGetHeight([[UIScreen mainScreen] bounds]) * 0.4;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - EDGE_TOLERANCE;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - _edgeTolerance;
self->touchPointSpawnedAtUpperScreenEdge = false;
leftClickDelay = ((CGFloat)settings.leftClickDelayMs.intValue)/1000;
return self;
}
- (void)onLongPressStart:(NSTimer*)timer {
// Raise the left click and start a right click
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
if(multiTouchesDetected) return;
if([self touchDidntMoveOnScreen:movingTouchLocation]){
if(_delayMouseLeftClick){
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
if(mouseButtonForCursorMove!=BUTTON_LEFT) LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, mouseButtonForCursorMove);
}
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
dispatch_time_t delayShort = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC));
dispatch_after(delayShort, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
self->rightButtonClicked = true;
});
}
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
rightButtonClicked = false;
if([UITouchUtil touchesIn:streamView from:event].count>=2){
multiTouchesDetected = true;
[longPressTimer invalidate];
longPressTimer = nil;
return;
}
CGPoint initialPoint = [[touches anyObject] locationInView:streamView];
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < EDGE_TOLERANCE || initialPoint.x > screenWidthWithThreshold)) {
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < _edgeTolerance || initialPoint.x > screenWidthWithThreshold)) {
self->touchPointSpawnedAtUpperScreenEdge = true;
return; // we're done here. this touch event will not be sent to the remote PC.
}
@@ -72,22 +124,27 @@
touchPointSpawnedAtUpperScreenEdge = false; // reset this flag immediately if we get a touch event passing the check above, this fixes irresponsive touch after closing the command tool menu.
// Ignore touch down events with more than one finger
/*
if ([[event allTouches] count] > 1) {
return;
}
}*/
UITouch* touch = [touches anyObject];
CGPoint touchLocation = [touch locationInView:streamView];
capturedTouch = [touches anyObject];
CGPoint touchLocation = [capturedTouch locationInView:streamView];
touchBeganTimeStamp = capturedTouch.timestamp;
// Don't reposition for finger down events within the deadzone. This makes double-clicking easier.
if (touch.timestamp - lastTouchUp.timestamp > DOUBLE_TAP_DEAD_ZONE_DELAY ||
if (capturedTouch.timestamp - lastTouchUp.timestamp > DOUBLE_TAP_DEAD_ZONE_DELAY ||
sqrt(pow((touchLocation.x / streamView.bounds.size.width) - (lastTouchUpLocation.x / streamView.bounds.size.width), 2) +
pow((touchLocation.y / streamView.bounds.size.height) - (lastTouchUpLocation.y / streamView.bounds.size.height), 2)) > DOUBLE_TAP_DEAD_ZONE_DELTA) {
[streamView updateCursorLocation:touchLocation isMouse:NO];
if(!multiTouchesDetected) [streamView updateCursorLocation:touchLocation isMouse:NO];
}
// Press the left button down
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
if(!_delayMouseLeftClick){
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT); //deprecated
}
// Start the long press timer
longPressTimer = [NSTimer scheduledTimerWithTimeInterval:LONG_PRESS_ACTIVATION_DELAY
@@ -96,8 +153,21 @@
userInfo:nil
repeats:NO];
lastTouchDown = touch;
lastTouchDown = capturedTouch;
lastTouchDownLocation = touchLocation;
movingTouchLocation = touchLocation;
touchBeganLocation = touchLocation;
currentTouchesCount = [UITouchUtil touchesIn:streamView from:event].count;
}
- (void)pauseLeftButtonDrag{
if(dragButtonDown){
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC));
dispatch_after(delay, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, mouseButtonForCursorMove);
});
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
@@ -105,42 +175,90 @@
if(touchPointSpawnedAtUpperScreenEdge) return; // we're done here. this touch event will not be sent to the remote PC.
// Ignore touch move events with more than one finger
/*
if ([[event allTouches] count] > 1) {
return;
}*/
NSSet* currentTouches = [UITouchUtil touchesIn:streamView from:event];
currentTouchesCount = currentTouches.count;
if(currentTouchesCount == 2){
if(mouseButtonForCursorMove!=BUTTON_LEFT) LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, mouseButtonForCursorMove);
if(passthroughGestures) [TouchPadGestureHandler handleGestureIn:streamView with:event];
}
if(currentTouchesCount > 1) return;
UITouch* touch = [touches anyObject];
CGPoint touchLocation = [touch locationInView:streamView];
if(![currentTouches containsObject:capturedTouch]) return;
if (sqrt(pow((touchLocation.x / streamView.bounds.size.width) - (lastTouchDownLocation.x / streamView.bounds.size.width), 2) +
pow((touchLocation.y / streamView.bounds.size.height) - (lastTouchDownLocation.y / streamView.bounds.size.height), 2)) > LONG_PRESS_ACTIVATION_DELTA) {
movingTouchLocation = [capturedTouch locationInView:streamView];
if (sqrt(pow((movingTouchLocation.x / streamView.bounds.size.width) - (lastTouchDownLocation.x / streamView.bounds.size.width), 2) +
pow((movingTouchLocation.y / streamView.bounds.size.height) - (lastTouchDownLocation.y / streamView.bounds.size.height), 2)) > LONG_PRESS_ACTIVATION_DELTA) {
// Moved too far since touch down. Cancel the long press timer.
[longPressTimer invalidate];
longPressTimer = nil;
NSTimeInterval dragDelay = mouseButtonForCursorMove == BUTTON_LEFT ? leftClickTimeThreshold : 0;
if(_delayMouseLeftClick && (CACurrentMediaTime()-touchBeganTimeStamp>dragDelay) && !dragButtonDown){
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, mouseButtonForCursorMove);
dragButtonDown = true;
}
}
[streamView updateCursorLocation:[[touches anyObject] locationInView:streamView] isMouse:NO];
if(!rightButtonClicked) [streamView updateCursorLocation:movingTouchLocation isMouse:NO];
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
[longPressTimer invalidate];
longPressTimer = nil;
if(TouchPadGestureHandler.ctrlDown) LiSendKeyboardEvent(CommandManager.keyboardButtonMappings[@"CTRL"].shortValue,KEY_ACTION_UP,0);
if(touchPointSpawnedAtUpperScreenEdge) return; // we're done here. this touch event will not be sent to the remote PC.
if(multiTouchesDetected) {
if([UITouchUtil touchesIn:streamView from:event].count == touches.count) multiTouchesDetected = false;
return;
};
if([UITouchUtil touchesIn:streamView from:event].count == touches.count) multiTouchesDetected = false;
// Only fire this logic if all touches have ended
if ([[event allTouches] count] == [touches count]) {
if ([touches containsObject:capturedTouch]) {
// Cancel the long press timer
[longPressTimer invalidate];
longPressTimer = nil;
// Left button up on finger up
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
// Raise right button too in case we triggered a long press gesture
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
// Remember this last touch for touch-down deadzoning
CGPoint touchEndLocation = [capturedTouch locationInView:streamView];
if(_delayMouseLeftClick){
if(CACurrentMediaTime()-touchBeganTimeStamp<leftClickTimeThreshold) {
if(CACurrentMediaTime()-lastTouchUp.timestamp<0.15
&& ![self isAdjacentPoints:touchEndLocation from:lastTouchUpLocation tolerance:30]) [streamView updateCursorLocation:touchEndLocation isMouse:NO];
if([self touchDidntMoveOnScreen:touchEndLocation] && !rightButtonClicked) [self sendShortMouseLeftButtonClickEvent];
}
else if(!rightButtonClicked){
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
if(mouseButtonForCursorMove!=BUTTON_LEFT) LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, mouseButtonForCursorMove);
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
}
}
else{
// Left button up on finger up
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
// Raise right button too in case we triggered a long press gesture
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
}
lastTouchUp = [touches anyObject];
lastTouchUpLocation = [lastTouchUp locationInView:streamView];
dragButtonDown = false;
}
}
@@ -149,4 +267,34 @@
[self touchesEnded:touches withEvent:event];
}
- (void)sendShortMouseLeftButtonClickEvent{
dispatch_time_t delayShort = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(leftClickDelay * NSEC_PER_SEC));
dispatch_time_t delayLong = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.03 * NSEC_PER_SEC));
dispatch_after(delayShort, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
dispatch_after(delayLong, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
});
});
}
- (bool)touchDidntMoveOnScreen:(CGPoint)touchLocation{
return [self isAdjacentPoints:touchLocation from:touchBeganLocation tolerance:3];
}
- (BOOL)isAdjacentPoints:(CGPoint)currentPoint from:(CGPoint)originalPoint tolerance:(CGFloat)tolerance {
bool isAdjacent = hypotf(originalPoint.x - currentPoint.x, originalPoint.y - currentPoint.y) <= hypot(tolerance, tolerance);
return isAdjacent;
}
+ (int)mouseButtonForCursorMove {
return mouseButtonForCursorMove;
}
+ (void)setMouseButtonForCursorMove:(int)button {
mouseButtonForCursorMove = button;
}
@end
+90 -32
View File
@@ -61,7 +61,9 @@ import UIKit
"M_X1" : BUTTON_X1,
"MX1" : BUTTON_X1,
"M_X2" : BUTTON_X2,
"MX2" : BUTTON_X2
"MX2" : BUTTON_X2,
"WHEELUP" : 0xFF,
"WHEELDOWN" : -0xFF,
]
@@ -96,6 +98,7 @@ import UIKit
"PADDLE3" : PADDLE3_FLAG,
"PADDLE4" : PADDLE4_FLAG,
"MISC" : MISC_FLAG,
"OSCHOME" : SPECIAL_FLAG,
"OSCL2" : 0,
"L2" : 0,
"LT" : 0,
@@ -115,16 +118,49 @@ import UIKit
"OSCBACK"
]
@objc public static let touchPadCmds: [String] = ["LSVPAD", "RSVPAD", "LSPAD", "RSPAD", "DS4TOUCH", "MOUSEPAD", "DPAD", "TRACKBALL", "WASDPAD", "ARROWPAD"]
@objc public static let touchPadCmds: [String] = ["LSVPAD", "RSVPAD", "LSPAD", "RSPAD", "LSWHEEL", "RSWHEEL", "LTPAD", "RTPAD", "DS4TOUCH", "MOUSEPAD", "ABSMOUSE", "MOUSEWHEEL", "WHEEL", "DISCRETEWHEEL", "DSWHEEL", "DPAD", "TRACKBALL", "WASDPAD", "ARROWPAD"]
@objc public static let mousePadWithButtonActions: [String] = ["MOUSEPAD", "ABSMOUSE"]
@objc public static let mousePad: [String] = ["MOUSEPAD", "ABSMOUSE", "TRACKBALL"]
@objc public static let directionPads: [String] = ["DPAD", "WASDPAD", "ARROWPAD"]
@objc public static let stickTouchPads: [String] = ["LSVPAD", "RSVPAD", "LSPAD", "RSPAD"]
@objc public static let nonVectorStickPads: [String] = ["LSPAD", "RSPAD"]
@objc public static let specialOverlayButtonCmds: [String] = ["SETTINGS"]
@objc public static let stickWheels: [String] = ["LSWHEEL", "RSWHEEL"]
@objc public static let vectorTouchPads: [String] = ["LSVPAD", "RSVPAD", "MOUSEPAD", "TRACKBALL"]
@objc public static let inertialTouchPads: [String] = ["LSVPAD", "RSVPAD", "TRACKBALL"]
@objc public static let verticalTouchPads: [String] = ["LTPAD", "RTPAD", "MOUSEWHEEL", "WHEEL", "DISCRETEWHEEL", "DSWHEEL"]
@objc public static let bidirectionalVerticalTouchPads: [String] = ["LTPAD", "RTPAD", "MOUSEWHEEL", "WHEEL", "DISCRETEWHEEL", "DSWHEEL"]
@objc public static let functionalButtonCmds: [String] = [
"SETTINGS",
"TOOLBOX",
"WIDGETTOOL",
"WIDGETPROFILES",
"PROFILES",
"PICKPROFILE",
"PICKPRFL",
"SOFTKEYBOARD",
"ABSTCHDRAG",
"FOLDER",
"PRESSURECURVE",
"PENCILHOVER",
"BRUSH",
"ERASER",
"NOSINGLETOUCH"
]
@objc public static let pencilProButtonCmds: [String] = [
"PENCILHOVER",
"BRUSH",
"ERASER",
"NOSINGLETOUCH"
]
@objc public static let motionControlButtonCmds: [String] = ["GYRO","GYROPAUSE","ACCEL","MOTION"]
// @objc public static let specialGameWidgets: [String] = ["YSRSV", "YSLT", "YSRT", "YSRB", "YSB", "YSRT2", "YSRB2", "YSB2", "YSEM", "YSML", "YSMR", "YSWASD"]
static let keyboardButtonMappings: [String: Int16] = [
@objc public static let keyboardButtonMappings: [String: Int16] = [
// Windows Key Codes
"NULL": 0xFF,
"BRUSH": 0xFF,
"ERASER": 0xFF,
"CTRL": 0x11, // VK_CONTROL
"SHIFT": 0x10, // VK_SHIFT
"ALT": 0x12, // VK_MENU
@@ -383,7 +419,7 @@ import UIKit
let defaults = UserDefaults.standard
//if true { // save default entries if the data is empty.
if defaults.data(forKey: "savedCommands") == nil { // save default entries if the data is empty.
var defaultCommands: [RemoteCommand] = [
let defaultCommands: [RemoteCommand] = [
RemoteCommand(cmdString: "WIN", alias: "WIN"),
RemoteCommand(cmdString: "F11", alias: "F11"),
RemoteCommand(cmdString: "ESC", alias: "ESC"),
@@ -410,22 +446,25 @@ import UIKit
}
// extractKeyStrings from keyboardCMDString
@objc public func extractKeyStringsFromComboCommand(from input: String) -> [String]? {
let keys = CommandManager.keyboardButtonMappings.keys.joined(separator: "|")
@objc public func extractAutoReleaseButtonStrings(from cmd: String) -> [String]? {
let cmd = cmd.uppercased()
let mergedKeys = (Set(CommandManager.keyboardButtonMappings.keys)
.union(Set(CommandManager.mouseButtonMappings.keys)))
let keys = mergedKeys.joined(separator: "|")
let pattern = "^(?:(\(keys))(?:\\+(\(keys))*)*)$"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
print("Failed to create regex")
return nil
}
let range = NSRange(location: 0, length: input.utf16.count)
guard let match = regex.firstMatch(in: input, options: [], range: range) else {
print("No match found for input: \(input)")
let range = NSRange(location: 0, length: cmd.utf16.count)
guard let match = regex.firstMatch(in: cmd, options: [], range: range) else {
print("No match found for input: \(cmd)")
return nil
}
// print("Regex matched for input: \(input)")
let matchedString = (input as NSString).substring(with: match.range(at: 0))
let matchedString = (cmd as NSString).substring(with: match.range(at: 0))
let keyStrings = matchedString.split(separator: "+").map { String($0) }
guard !keyStrings.isEmpty else {
@@ -436,7 +475,8 @@ import UIKit
var validKeyStrings: [String] = []
for key in keyStrings {
if CommandManager.keyboardButtonMappings.keys.contains(key) {
if (CommandManager.keyboardButtonMappings.keys.contains(key)
|| CommandManager.mouseButtonMappings.keys.contains(key)) {
validKeyStrings.append(key)
} else {
print(" '\(key)' is not defined in key mappings")
@@ -458,10 +498,13 @@ import UIKit
}
//super combo key button strings
@objc public func extractSinglCmdStringsFromComboKeys(from input: String) -> [String]? {
@objc public func extractCmdStrings(from input: String) -> [String]? {
let input = input.uppercased()
let combinedStrings = [CommandManager.keyboardButtonMappings.keys.map { $0 as String },
CommandManager.oscButtonMappings.keys.map { $0 as String },
CommandManager.mouseButtonMappings.keys.map { $0 as String },
CommandManager.functionalButtonCmds.map { $0 as String },
CommandManager.motionControlButtonCmds.map { $0 as String },
CommandManager.touchPadCmds.map { $0 as String }
]
.lazy
@@ -512,7 +555,7 @@ import UIKit
@objc public func addCommand(_ command: RemoteCommand) -> Bool {
command.cmdString = command.cmdString.uppercased() // convert all letters to upper case
if(command.alias.trimmingCharacters(in: .whitespacesAndNewlines).count == 0) {command.alias = command.cmdString} // copy cmd string as alias when alias is empty
let keyStrings = extractKeyStringsFromComboCommand(from: command.cmdString)
let keyStrings = extractAutoReleaseButtonStrings(from: command.cmdString)
if (keyStrings == nil) {return false} // in case of non-keyboard command strings, return false
commands.append(command)
saveCommands()
@@ -551,38 +594,55 @@ import UIKit
}
}
private func saveCommands() {
if let data = try? NSKeyedArchiver.archivedData(withRootObject: commands, requiringSecureCoding: false) {
UserDefaults.standard.set(data, forKey: "savedCommands")
}
}
@objc public func sendKeyComboCommand(keyboardCmdStrings: [String], delay: TimeInterval = 0.2, index: Int = 0) { // we need a large delay for WAN streaming
@objc public func sendAutoReleaseComboCommand(cmdStrings: [String]?, delay: TimeInterval = 0.2, index: Int = 0, pressOnly: Bool = false, releaseOnly:Bool = false) { // we need a large delay for WAN streaming
// 如果已处理完所有按键,则开始释放按键
guard index < keyboardCmdStrings.count else {
// 释放按键
for keyStr in keyboardCmdStrings.reversed() { // 从后往前释放按键
guard let cmdStrings = cmdStrings else { return }
if releaseOnly {
for keyStr in cmdStrings.reversed() {
if let keyCode = CommandManager.keyboardButtonMappings[keyStr] {
LiSendKeyboardEvent(keyCode, Int8(KEY_ACTION_UP), 0) // 释放按键
}
if let mouseButtonCode = CommandManager.mouseButtonMappings[keyStr]{
LiSendMouseButtonEvent(CChar(BUTTON_ACTION_RELEASE), mouseButtonCode)
}
}
return
}
guard index < cmdStrings.count else {
// 释放按键
DispatchQueue.main.asyncAfter(deadline: .now()) {
if pressOnly {return}
for keyStr in cmdStrings.reversed() { // 从后往前释放按键
if let keyCode = CommandManager.keyboardButtonMappings[keyStr] {
LiSendKeyboardEvent(keyCode, Int8(KEY_ACTION_UP), 0) // 释放按键
}
if let mouseButtonCode = CommandManager.mouseButtonMappings[keyStr]{
LiSendMouseButtonEvent(CChar(BUTTON_ACTION_RELEASE), mouseButtonCode)
}
}
}
return
}
// 获取当前按键的映射值
if let keyCode = CommandManager.keyboardButtonMappings[keyboardCmdStrings[index]] {
if let keyCode = CommandManager.keyboardButtonMappings[cmdStrings[index]] {
// 发送当前按键的按下事件
LiSendKeyboardEvent(keyCode, Int8(KEY_ACTION_DOWN), 0)
// 延迟后递归处理下一个按键
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.sendKeyComboCommand(keyboardCmdStrings: keyboardCmdStrings, delay: delay, index: index + 1)
}
} else if let mouseButtonCode = CommandManager.mouseButtonMappings[cmdStrings[index]]{
LiSendMouseButtonEvent(CChar(BUTTON_ACTION_PRESS), mouseButtonCode)
} else {
print("No mapping found for \(keyboardCmdStrings[index])")
// 如果当前按键没有映射,跳过当前按键并继续下一个
self.sendKeyComboCommand(keyboardCmdStrings: keyboardCmdStrings, delay: delay, index: index + 1)
print("No mapping found for \(cmdStrings[index])")
}
// 延迟后递归处理下一个按键
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.sendAutoReleaseComboCommand(cmdStrings: cmdStrings, delay: delay, index: index + 1, pressOnly: pressOnly, releaseOnly: releaseOnly)
}
}
@@ -602,6 +662,4 @@ import UIKit
}
return
}
}
+517 -101
View File
@@ -11,7 +11,7 @@
#import "ControllerSupport.h"
#import "VoidController.h"
#import "VoidLink-Swift.h"
#import "OnScreenControls.h"
#import "DataManager.h"
@@ -42,18 +42,42 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
NSLock *_controllerStreamLock;
NSMutableDictionary *_voidControllers;
id<ControllerSupportDelegate> _delegate;
StreamConfiguration* _streamConfig;
__weak id<ControllerSupportDelegate> _delegate;
float accumulatedDeltaX;
float accumulatedDeltaY;
float accumulatedScrollX;
float accumulatedScrollY;
int _controllerMouseSwitch;
bool _mouseSwitchButtonPressed;
bool _mouseSwitchButtonBeingClicked;
NSTimeInterval mouseSwitchDownTimestamp;
int _controllerMouseLeftButton;
int _controllerMouseRightButton;
ControllerMouseStick _controllerMouseStick;
bool _mapControllerToMouse;
bool _controllerMouseEnabledFlag;
float stickToMouseInputX;
float stickToMouseInputY;
float stickToWheelInputX;
float stickToWheelInputY;
float _stickToMouseExpo;
float _stickToMouseVelocity;
CADisplayLink *_displayLink;
float stickMaxOffset;
float _leftStickMinOffset;
float _rightStickMinOffset;
OnScreenControls *_osc;
VoidController *_oscController;
NSMutableSet* _activeGCControllers;
TemporarySettings* tempSettings;
OSCProfile* oscProfile;
OSCProfilesManager* oscProfileMan;
#define EMULATING_SELECT 0x1
#define EMULATING_SPECIAL 0x2
@@ -64,6 +88,17 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
int _gyroMode;
CGFloat _gyroSensitivity;
bool _captureMouse;
bool _controllerGyroSwitchEnabled;
bool _gyroEnabledFlag;
int _controllerGyroSwitchToggle;
int _controllerGyroSwitchHold;
bool _reverseHoldButton;
bool _controllerGyroSwitchTogglePressed;
bool _controllerGyroSwitchHoldPressed;
ControllerGyroSwitchMode _gyroSwitchMode;
__weak MotionHandler* motionHandler;
}
// UPDATE_BUTTON_FLAG(controller, flag, pressed)
@@ -75,16 +110,40 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
-(void) rumble:(unsigned short)controllerNumber lowFreqMotor:(unsigned short)lowFreqMotor highFreqMotor:(unsigned short)highFreqMotor
{
VoidController* voidController = [_voidControllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
HapticEnginePreference preference = tempSettings.hapticEngine.intValue;
if (voidController == nil && controllerNumber == 0 && _oscEnabled) {
// TODO: Rumble emulation for OSC
// no physical controller connected:
if(preference != RumbleOff){
[_oscController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
[_oscController.highFreqMotor setMotorAmplitude:highFreqMotor];
}
}
if (voidController == nil) {
// No connected controller for this player
return;
}
[voidController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
[voidController.highFreqMotor setMotorAmplitude:highFreqMotor];
// physical controller connected:
switch (preference) {
case HapticEngineAuto:
// if controller has no haptic profile, it already falled bakc to device engine
[voidController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
[voidController.highFreqMotor setMotorAmplitude:highFreqMotor];
break;
case RumbleDevice:
[_oscController.lowFreqMotor setMotorAmplitude:lowFreqMotor];
[_oscController.highFreqMotor setMotorAmplitude:highFreqMotor];
case LeftRightSwapped:
[voidController.lowFreqMotor setMotorAmplitude:highFreqMotor];
[voidController.highFreqMotor setMotorAmplitude:lowFreqMotor];
case RumbleOff:
break;
default:
break;
}
}
-(void) rumbleTriggers:(uint16_t)controllerNumber leftTrigger:(uint16_t)leftTrigger rightTrigger:(uint16_t)rightTrigger
@@ -101,15 +160,20 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
[controller.rightTriggerMotor setMotorAmplitude:rightTrigger];
}
- (bool)isLandscapeNow {
return CGRectGetWidth([[UIScreen mainScreen]bounds]) > CGRectGetHeight([[UIScreen mainScreen]bounds]);
}
-(void)updateTimerStateForController:(VoidController* )voidController{
if (@available(iOS 14.0, tvOS 14.0, *)) {
// if (@available(iOS 14.0, tvOS 14.0, *)) {
if (true) {
if(_gyroMode == GyroModeOff){
[self stopTimerForController:voidController];
return;
}
if(voidController.gamepad.motion.hasAttitudeAndRotationRate) [voidController.motionTypes addObject:@(LI_MOTION_TYPE_ACCEL)];
if(voidController.gamepad.motion.hasRotationRate) [voidController.motionTypes addObject:@(LI_MOTION_TYPE_GYRO)];
if (@available(iOS 14.0, *)) if(voidController.gamepad.motion.hasRotationRate) [voidController.motionTypes addObject:@(LI_MOTION_TYPE_GYRO)];
for(NSNumber* motionTypeObj in voidController.motionTypes){
uint8_t motionType = motionTypeObj.intValue;
@@ -147,20 +211,29 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
voidController.lastDeviceAccelSample = deviceAccelSample;
// Convert g to m/s^2
if(UIApplication.sharedApplication.windows.firstObject.windowScene.interfaceOrientation == 4){ //check for landscape left or landscape right
if (@available(iOS 13.0, *)) {
if(UIApplication.sharedApplication.windows.firstObject.windowScene.interfaceOrientation == 4){ //check for landscape left or landscape right
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_ACCEL,
deviceAccelSample.y * -9.80665f * self->_gyroSensitivity,
deviceAccelSample.z * -9.80665f * self->_gyroSensitivity,
deviceAccelSample.x * -9.80665f * self->_gyroSensitivity);
}
else{
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_ACCEL,
deviceAccelSample.y * +9.80665f * self->_gyroSensitivity,
deviceAccelSample.z * -9.80665f * self->_gyroSensitivity,
deviceAccelSample.x * +9.80665f * self->_gyroSensitivity);
}
}
else{
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_ACCEL,
deviceAccelSample.y * -9.80665f * self->_gyroSensitivity,
deviceAccelSample.z * -9.80665f * self->_gyroSensitivity,
deviceAccelSample.x * -9.80665f * self->_gyroSensitivity);
}
else{
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_ACCEL,
deviceAccelSample.y * +9.80665f * self->_gyroSensitivity,
deviceAccelSample.z * -9.80665f * self->_gyroSensitivity,
deviceAccelSample.x * +9.80665f * self->_gyroSensitivity);
}
}];
});}
break;
@@ -186,21 +259,68 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
voidController.lastDeviceGyroSample = deviceGyroSample;
// Convert rad/s to deg/s
//NSLog(@"sending built-in gyro data, accelSample data 00: %f, playerIndex: %d",deviceGyroSample.x, voidController.playerIndex);
UIInterfaceOrientation interfaceOrientation = UIInterfaceOrientationUnknown;
if (@available(iOS 13.0, *)) {
interfaceOrientation = UIApplication.sharedApplication.windows.firstObject.windowScene.interfaceOrientation;
} else {
interfaceOrientation = UIApplication.sharedApplication.statusBarOrientation;
}
switch (interfaceOrientation) {
case UIInterfaceOrientationLandscapeLeft:
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
self->_gyroEnabledFlag ? deviceGyroSample.y * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.x * 57.2957795f * self->_gyroSensitivity : 0);
break;
case UIInterfaceOrientationLandscapeRight:
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
self->_gyroEnabledFlag ? deviceGyroSample.y * -57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.x * -57.2957795f * self->_gyroSensitivity : 0);
break;
case UIInterfaceOrientationPortrait:
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
self->_gyroEnabledFlag ? deviceGyroSample.x * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.y * -57.2957795f * self->_gyroSensitivity : 0);
break;
case UIInterfaceOrientationPortraitUpsideDown:
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
self->_gyroEnabledFlag ? deviceGyroSample.x * -57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.y * 57.2957795f * self->_gyroSensitivity : 0);
break;
default:
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
self->_gyroEnabledFlag ? deviceGyroSample.y * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.x * 57.2957795f * self->_gyroSensitivity : 0);
break;
}
/*
if(UIApplication.sharedApplication.windows.firstObject.windowScene.interfaceOrientation == 4){//check for landscape left or landscape right
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
deviceGyroSample.y * 57.2957795f * self->_gyroSensitivity,
deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity,
deviceGyroSample.x * 57.2957795f * self->_gyroSensitivity);
self->_gyroEnabledFlag ? deviceGyroSample.y * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.x * 57.2957795f * self->_gyroSensitivity : 0);
}
else{
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
deviceGyroSample.y * -57.2957795f * self->_gyroSensitivity,
deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity,
deviceGyroSample.x * -57.2957795f * self->_gyroSensitivity);
self->_gyroEnabledFlag ? deviceGyroSample.y * -57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? deviceGyroSample.x * -57.2957795f * self->_gyroSensitivity : 0);
}
*/
}];
break;
}
@@ -209,72 +329,75 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
#endif
else{
NSLog(@"controller obj timer update: controller timer ");
switch (motionType) {
case LI_MOTION_TYPE_ACCEL:
[voidController.accelTimer invalidate];
voidController.accelTimer = nil;
if (voidController.reportRateHz && voidController.gamepad.motion.hasGravityAndUserAcceleration) {
// Reset the last motion sample
GCAcceleration emptyAccelSample = {};
voidController.lastAccelSample = emptyAccelSample;
NSLog(@"setup controller gyro accelTimer");
dispatch_sync(dispatch_get_main_queue(), ^{
voidController.hasAccelerometer = YES;
voidController.accelTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / voidController.reportRateHz repeats:YES block:^(NSTimer *timer) {
// Don't send duplicate samples
GCAcceleration lastAccelSample = voidController.lastAccelSample;
GCAcceleration accelSample = voidController.gamepad.motion.acceleration;
if (memcmp(&accelSample, &lastAccelSample, sizeof(accelSample)) == 0) {
return;
}
voidController.lastAccelSample = accelSample;
// Convert g to m/s^2
//NSLog(@"sending controller gyro data, accelSample data 00: %f, playerIndex: %ld, obj: %@",accelSample.x, (long)voidController.gamepad.playerIndex, voidController);
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_ACCEL,
accelSample.x * -9.80665f * self->_gyroSensitivity,
accelSample.y * -9.80665f * self->_gyroSensitivity,
accelSample.z * -9.80665f * self->_gyroSensitivity);
}];
});
}
break;
case LI_MOTION_TYPE_GYRO:
[voidController.gyroTimer invalidate];
voidController.gyroTimer = nil;
if (voidController.reportRateHz && voidController.gamepad.motion.hasRotationRate) {
// Reset the last motion sample
GCRotationRate emptyGyroSample = {};
voidController.lastGyroSample = emptyGyroSample;
//dispatch_sync(dispatch_get_main_queue(), ^{
{dispatch_async(dispatch_get_main_queue(), ^{
voidController.hasGyroscope = YES;
voidController.gyroTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / voidController.reportRateHz repeats:YES block:^(NSTimer *timer) {
// Don't send duplicate samples
GCRotationRate lastGyroSample = voidController.lastGyroSample;
GCRotationRate gyroSample = voidController.gamepad.motion.rotationRate;
if (memcmp(&gyroSample, &lastGyroSample, sizeof(gyroSample)) == 0) {
return;
}
voidController.lastGyroSample = gyroSample;
// Convert rad/s to deg/s
// NSLog(@"sending controller gyro data, gyroSample data 00: %f, playerIndex: %ld, obj: %@",gyroSample.x, (long)voidController.gamepad.playerIndex, voidController);
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
gyroSample.x * 57.2957795f * self->_gyroSensitivity,
gyroSample.z * 57.2957795f * self->_gyroSensitivity,
gyroSample.y * -57.2957795f * self->_gyroSensitivity);
}];
// });
});}
}
break;
if (@available(iOS 14.0, *)) {
switch (motionType) {
case LI_MOTION_TYPE_ACCEL:
[voidController.accelTimer invalidate];
voidController.accelTimer = nil;
if (voidController.reportRateHz && voidController.gamepad.motion.hasGravityAndUserAcceleration) {
// Reset the last motion sample
GCAcceleration emptyAccelSample = {};
voidController.lastAccelSample = emptyAccelSample;
NSLog(@"setup controller gyro accelTimer");
dispatch_sync(dispatch_get_main_queue(), ^{
voidController.hasAccelerometer = YES;
voidController.accelTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / voidController.reportRateHz repeats:YES block:^(NSTimer *timer) {
// Don't send duplicate samples
GCAcceleration lastAccelSample = voidController.lastAccelSample;
GCAcceleration accelSample = voidController.gamepad.motion.acceleration;
if (memcmp(&accelSample, &lastAccelSample, sizeof(accelSample)) == 0) {
return;
}
voidController.lastAccelSample = accelSample;
// Convert g to m/s^2
//NSLog(@"sending controller gyro data, accelSample data 00: %f, playerIndex: %ld, obj: %@",accelSample.x, (long)voidController.gamepad.playerIndex, voidController);
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_ACCEL,
accelSample.x * -9.80665f * self->_gyroSensitivity,
accelSample.y * -9.80665f * self->_gyroSensitivity,
accelSample.z * -9.80665f * self->_gyroSensitivity);
}];
});
}
break;
case LI_MOTION_TYPE_GYRO:
[voidController.gyroTimer invalidate];
voidController.gyroTimer = nil;
if (voidController.reportRateHz && voidController.gamepad.motion.hasRotationRate) {
// Reset the last motion sample
GCRotationRate emptyGyroSample = {};
voidController.lastGyroSample = emptyGyroSample;
//dispatch_sync(dispatch_get_main_queue(), ^{
{dispatch_async(dispatch_get_main_queue(), ^{
voidController.hasGyroscope = YES;
voidController.gyroTimer = [NSTimer scheduledTimerWithTimeInterval:1.0 / voidController.reportRateHz repeats:YES block:^(NSTimer *timer) {
// Don't send duplicate samples
GCRotationRate lastGyroSample = voidController.lastGyroSample;
GCRotationRate gyroSample = voidController.gamepad.motion.rotationRate;
if (memcmp(&gyroSample, &lastGyroSample, sizeof(gyroSample)) == 0) {
return;
}
voidController.lastGyroSample = gyroSample;
// Convert rad/s to deg/s
// NSLog(@"sending controller gyro data, gyroSample data 00: %f, playerIndex: %ld, obj: %@",gyroSample.x, (long)voidController.gamepad.playerIndex, voidController);
LiSendControllerMotionEvent((uint8_t)voidController.controllerNumber,
LI_MOTION_TYPE_GYRO,
self->_gyroEnabledFlag ? gyroSample.x * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? gyroSample.z * 57.2957795f * self->_gyroSensitivity : 0,
self->_gyroEnabledFlag ? gyroSample.y * -57.2957795f * self->_gyroSensitivity : 0);
}];
// });
});}
}
break;
}
}
}
}
@@ -283,7 +406,7 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
// Set the motion sensor state if they require manual activation
[self updateSensorSateForController:voidController];
NSLog(@"sensor active: %d", voidController.gamepad.motion.sensorsActive);
// NSLog(@"sensor active: %d", voidController.gamepad.motion.sensorsActive);
}
}
@@ -301,7 +424,7 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
}
- (void) setMotionEventState:(uint16_t)controllerNumber motionType:(uint8_t)motionType reportRateHz:(uint16_t)reportRateHz {
if (@available(iOS 14.0, tvOS 14.0, *)) {
//if (@available(iOS 14.0, tvOS 14.0, *)) {
NSLog(@"gyroMode: %ld", (long)_gyroMode);
VoidController* voidController = [_voidControllers objectForKey:[NSNumber numberWithInteger:controllerNumber]];
@@ -322,7 +445,7 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
voidController.controllerNumber = controllerNumber;
if(voidController == _oscController) [self updateTimerStateForController:voidController];
}
//}
}
-(void) setControllerLed:(uint16_t)controllerNumber r:(uint8_t)r g:(uint8_t)g b:(uint8_t)b {
@@ -879,6 +1002,63 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
}
}
double rc_expo(double x, double expo) {
if (x == 0.0) return 0.0;
double ax = fabs(x);
double y = pow(ax, expo);
return x > 0 ? y : -y;
}
- (void)sendStickToMouseMoveEventWithStickX:(float)stickX stickY:(float)stickY expo:(float)expo {
CGFloat mouseDeltaX = _stickToMouseVelocity*rc_expo(stickX, expo);
CGFloat mouseDeltaY = _stickToMouseVelocity*rc_expo(stickY, expo);
LiSendMouseMoveEvent(mouseDeltaX, -mouseDeltaY);
}
- (void)stopDisplayLink {
[_displayLink invalidate];
_displayLink = nil;
}
- (void)displayLinkCallBack {
if(!_controllerMouseEnabledFlag){
return;
}
NSTimeInterval delay = 0.5/_displayLink.preferredFramesPerSecond*NSEC_PER_SEC;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self sendStickToMouseMoveEventWithStickX:self->stickToMouseInputX stickY:self->stickToMouseInputY expo:self->_stickToMouseExpo];
LiSendHighResScrollEvent(15*self->stickToWheelInputY);
LiSendHighResHScrollEvent(15*self->stickToWheelInputX);
});
}
- (void)sendControllerMouseSwitchClick:(VoidController*) voidController{
[self setButtonFlag:voidController flags:self->_controllerMouseSwitch];
_mouseSwitchButtonBeingClicked = true;
[self updateFinished:voidController];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.03*NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self clearButtonFlag:voidController flags:self->_controllerMouseSwitch];
self->_mouseSwitchButtonBeingClicked = false;
[self updateFinished:voidController];
});
}
- (bool)useMotionHandler{
return self->tempSettings.emulatedControllerType.intValue!=LI_CTYPE_PS
||self->tempSettings.gyroMode.intValue==GyroModeOff;
}
- (void)switchGyroOnOffByControllerButton{
self->_gyroEnabledFlag = self->_gyroEnabledFlag && self->_controllerGyroSwitchEnabled;
if([self useMotionHandler]){
if(self->_gyroEnabledFlag) [self->motionHandler startGyroByControllerButton];
else [self->motionHandler stopGyroUpdateWithInterruptNoneGyroInput:false resetLeftStick:true];
}
else self->_gyroEnabledFlag = self->_gyroEnabledFlag || !self->_controllerGyroSwitchEnabled;
}
-(void) registerControllerCallbacks:(GCController*) controller
{
if (controller != NULL) {
@@ -923,12 +1103,190 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
}
}
controller.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *gamepad, GCControllerElement *element) {
if(self->_swapABXYButtons){
switch (self->_controllerMouseLeftButton) {
case ControllerButtonA:
self->_controllerMouseLeftButton = ControllerButtonB;
break;
case ControllerButtonB:
self->_controllerMouseLeftButton = ControllerButtonA;
break;
case ControllerButtonX:
self->_controllerMouseLeftButton = ControllerButtonY;
break;
case ControllerButtonY:
self->_controllerMouseLeftButton = ControllerButtonX;
break;
default:
break;
}
switch (self->_controllerMouseRightButton) {
case ControllerButtonA:
self->_controllerMouseRightButton = ControllerButtonB;
break;
case ControllerButtonB:
self->_controllerMouseRightButton = ControllerButtonA;
break;
case ControllerButtonX:
self->_controllerMouseRightButton = ControllerButtonY;
break;
case ControllerButtonY:
self->_controllerMouseRightButton = ControllerButtonX;
break;
default:
break;
}
}
[ControllerUtil listenWithController:controller swapABXY:self->_swapABXYButtons handler:^(NSDictionary * buttonDict, GCExtendedGamepad * gamepad, GCControllerElement * element) {
VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]];
short leftStickX, leftStickY;
short rightStickX, rightStickY;
unsigned char leftTrigger, rightTrigger;
for(NSNumber* buttonFlagId in buttonDict){
GCControllerButtonInput * button = (GCControllerButtonInput *)buttonDict[buttonFlagId];
if(self->_mapControllerToMouse){
if(button.pressed){
if(buttonFlagId.intValue == self->_controllerMouseSwitch){
self->_mouseSwitchButtonPressed = true;
self->mouseSwitchDownTimestamp = CACurrentMediaTime();
}
}
else{
if(buttonFlagId.intValue == self->_controllerMouseSwitch && self->_mouseSwitchButtonPressed){
if(CACurrentMediaTime()-self->mouseSwitchDownTimestamp>1){
self->_controllerMouseEnabledFlag = !self->_controllerMouseEnabledFlag;
[self updateLeftStick:voidController x:0 y:0];
[self updateRightStick:voidController x:0 y:0];
}
else [self sendControllerMouseSwitchClick:voidController];
self->_mouseSwitchButtonPressed = false;
self->mouseSwitchDownTimestamp = 0;
}
}
}
else self->_controllerMouseEnabledFlag = false;
// controller switch buttons
if(true){
if(button.pressed){
if (buttonFlagId.intValue == self->_controllerGyroSwitchToggle
&& !self->_controllerGyroSwitchTogglePressed) {
self->_controllerGyroSwitchTogglePressed = true;
self->_gyroEnabledFlag = !self->_gyroEnabledFlag;
[self switchGyroOnOffByControllerButton];
}
if (buttonFlagId.intValue == self->_controllerGyroSwitchHold
&& !self->_controllerGyroSwitchHoldPressed) {
self->_controllerGyroSwitchHoldPressed = true;
self->_gyroEnabledFlag = !self->_reverseHoldButton;
[self switchGyroOnOffByControllerButton];
}
}
else{
if (buttonFlagId.intValue == self->_controllerGyroSwitchToggle
&& self->_controllerGyroSwitchTogglePressed) {
self->_controllerGyroSwitchTogglePressed = false;
}
if (buttonFlagId.intValue == self->_controllerGyroSwitchHold
&& self->_controllerGyroSwitchHoldPressed) {
self->_controllerGyroSwitchHoldPressed = false;
self->_gyroEnabledFlag = self->_reverseHoldButton;
[self switchGyroOnOffByControllerButton];
}
}
}
if(self->_controllerMouseEnabledFlag){
if(buttonFlagId.intValue == self->_controllerMouseLeftButton || buttonFlagId.intValue == self->_controllerMouseRightButton){
if(buttonFlagId.intValue == self->_controllerMouseLeftButton) LiSendMouseButtonEvent(button.pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_LEFT);
if(buttonFlagId.intValue == self->_controllerMouseRightButton) LiSendMouseButtonEvent(button.pressed ? BUTTON_ACTION_PRESS : BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
UPDATE_BUTTON_FLAG(voidController, buttonFlagId.intValue, NO);
}
else if(buttonFlagId.intValue!=self->_controllerMouseSwitch || !self->_mapControllerToMouse) UPDATE_BUTTON_FLAG(voidController, buttonFlagId.intValue, button.pressed);
}
else if(buttonFlagId.intValue!=self->_controllerMouseSwitch || !self->_mapControllerToMouse) UPDATE_BUTTON_FLAG(voidController, buttonFlagId.intValue, button.pressed);
}
CGFloat leftStickXRaw = gamepad.leftThumbstick.xAxis.value * self->stickMaxOffset;
CGFloat leftStickYRaw = gamepad.leftThumbstick.yAxis.value * self->stickMaxOffset;
CGFloat rightStickXRaw = gamepad.rightThumbstick.xAxis.value * self->stickMaxOffset;
CGFloat rightStickYRaw = gamepad.rightThumbstick.yAxis.value * self->stickMaxOffset;
CGVector leftStickOffset = [ControllerUtil compensatedWithOffsetVector:CGVectorMake(leftStickXRaw, leftStickYRaw) minOffset:self->_leftStickMinOffset circulate:false];
CGVector rightStickOffset = [ControllerUtil compensatedWithOffsetVector:CGVectorMake(rightStickXRaw, rightStickYRaw) minOffset:self->_rightStickMinOffset circulate:false];
leftStickX = self->_controllerMouseEnabledFlag ? 0 : leftStickOffset.dx;
leftStickY = self->_controllerMouseEnabledFlag ? 0 : leftStickOffset.dy;
rightStickX = self->_controllerMouseEnabledFlag ? 0 : rightStickOffset.dx;
rightStickY = self->_controllerMouseEnabledFlag ? 0 : rightStickOffset.dy;
if(self->_controllerMouseEnabledFlag){
self->stickToMouseInputX = self->_controllerMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.xAxis.value : gamepad.rightThumbstick.xAxis.value;
self->stickToMouseInputY = self->_controllerMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.yAxis.value : gamepad.rightThumbstick.yAxis.value;
self->stickToWheelInputX = self->_controllerMouseStick == LeftStickToMouse ? gamepad.rightThumbstick.xAxis.value: gamepad.leftThumbstick.xAxis.value;
self->stickToWheelInputY = self->_controllerMouseStick == LeftStickToMouse ? gamepad.rightThumbstick.yAxis.value: gamepad.leftThumbstick.yAxis.value;
}
else{
self->stickToMouseInputX = 0;
self->stickToMouseInputY = 0;
}
/*
if(self->oscProfile.mapGyroTo!=mapGyroToControllerStick
||!self->oscProfile.rollToLeftStick) [self updateLeftStick:voidController x:leftStickX y:leftStickY];
*/
if([self useMotionHandler]
&& self->oscProfile.mapGyroTo==mapGyroToControllerStick
&& self->oscProfile.yawPitchToRightStick
&& self->_gyroEnabledFlag
) [self->motionHandler mixPhysicalRightStickAndGyroInputWithX:rightStickX y:rightStickY];
else [self updateRightStick: voidController.playerIndex==0?self->_oscController:voidController x:rightStickX y:rightStickY];
if([self useMotionHandler]
&& self->oscProfile.mapGyroTo==mapGyroToControllerStick
&& self->oscProfile.rollToLeftStick
&& self->_gyroEnabledFlag
) [self->motionHandler mixPhysicalLeftStickAndGyroInputWithX:leftStickX y:leftStickY];
else [self updateLeftStick: voidController.playerIndex==0?self->_oscController:voidController x:leftStickX y:leftStickY];
leftTrigger = gamepad.leftTrigger.value * 0xFF;
rightTrigger = gamepad.rightTrigger.value * 0xFF;
[self updateTriggers:voidController left:leftTrigger right:rightTrigger];
[self updateFinished:voidController];
if (@available(iOS 14.0, *)) {
if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]) {
[self handleControllerTouchpad:voidController
touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadOne]
index:0];
}
if (gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]) {
[self handleControllerTouchpad:voidController
touch:gamepad.controller.physicalInputProfile.dpads[GCInputDualShockTouchpadTwo]
index:1];
}
}
}];
/*
controller.extendedGamepad.valueChangedHandler = ^(GCExtendedGamepad *gamepad, GCControllerElement *element) {
VoidController* voidController = [self->_voidControllers objectForKey:[NSNumber numberWithInteger:gamepad.controller.playerIndex]];
short leftStickX, leftStickY;
short rightStickX, rightStickY;
unsigned char leftTrigger, rightTrigger;
if (self->_swapABXYButtons) {
UPDATE_BUTTON_FLAG(voidController, B_FLAG, gamepad.buttonA.pressed);
UPDATE_BUTTON_FLAG(voidController, A_FLAG, gamepad.buttonB.pressed);
@@ -1011,12 +1369,15 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
index:1];
}
}
leftStickX = self->controllerToMouseStick != LeftStickToMouse ? gamepad.leftThumbstick.xAxis.value * 0x7FFE : 0;
leftStickY = self->controllerToMouseStick != LeftStickToMouse ? gamepad.leftThumbstick.yAxis.value * 0x7FFE : 0;;
leftStickX = gamepad.leftThumbstick.xAxis.value * 0x7FFE;
leftStickY = gamepad.leftThumbstick.yAxis.value * 0x7FFE;
rightStickX = self->controllerToMouseStick != RightStickToMouse ? gamepad.rightThumbstick.xAxis.value * 0x7FFE : 0;
rightStickY = self->controllerToMouseStick != RightStickToMouse ? gamepad.rightThumbstick.yAxis.value * 0x7FFE : 0;
rightStickX = gamepad.rightThumbstick.xAxis.value * 0x7FFE;
rightStickY = gamepad.rightThumbstick.yAxis.value * 0x7FFE;
self->stickToMouseInputX = self->controllerToMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.xAxis.value : gamepad.rightThumbstick.xAxis.value;
self->stickToMouseInputY = self->controllerToMouseStick == LeftStickToMouse ? gamepad.leftThumbstick.yAxis.value : gamepad.rightThumbstick.yAxis.value;
leftTrigger = gamepad.leftTrigger.value * 0xFF;
rightTrigger = gamepad.rightTrigger.value * 0xFF;
@@ -1026,6 +1387,7 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
[self updateTriggers:voidController left:leftTrigger right:rightTrigger];
[self updateFinished:voidController];
};
*/
}
} else {
Log(LOG_W, @"Tried to register controller callbacks on NULL controller");
@@ -1360,10 +1722,55 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
_oscController.playerIndex = 0;
oscProfile = [oscProfileMan getSelectedProfile];
motionHandler = [MotionHandler sharedWithProfile:oscProfile];
DataManager* dataMan = [[DataManager alloc] init];
TemporarySettings* currentSettings = [dataMan getSettings];
_oscEnabled = _oscEnabled || (OnScreenControlsLevel)[currentSettings.onscreenControls integerValue] != OnScreenControlsLevelOff || streamConfig.gyroMode != GyroModeOff;
_gyroSensitivity = currentSettings.gyroSensitivity.floatValue;
tempSettings = [dataMan getSettings];
_oscEnabled = _oscEnabled || (OnScreenControlsLevel)[tempSettings.onscreenControls integerValue] != OnScreenControlsLevelOff || streamConfig.gyroMode != GyroModeOff;
_gyroSensitivity = tempSettings.gyroSensitivity.floatValue;
_mapControllerToMouse = tempSettings.mapControllerToMouse;
_controllerMouseSwitch = tempSettings.controllerMouseSwitch.intValue;
mouseSwitchDownTimestamp = 0;
_mouseSwitchButtonPressed = false;
_mouseSwitchButtonBeingClicked = false;
_controllerMouseStick = tempSettings.controllerMouseStick.intValue;
_controllerMouseLeftButton = tempSettings.controllerMouseLeftButton.intValue;
_controllerMouseRightButton = tempSettings.controllerMouseRightButton.intValue;
_stickToMouseExpo = tempSettings.controllerMouseExpo.floatValue;
_stickToMouseVelocity = tempSettings.controllerMousePointerVelocity.floatValue*60/tempSettings.framerate.intValue;
stickToMouseInputX = 0;
stickToMouseInputY = 0;
stickToWheelInputY = 0;
[self stopDisplayLink];
if(_mapControllerToMouse){
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallBack)];
if (@available(iOS 15.0, tvOS 15.0, *)) {
[_displayLink setPreferredFrameRateRange:CAFrameRateRangeMake(tempSettings.framerate.intValue,tempSettings.framerate.intValue, tempSettings.framerate.intValue)];
}
else {
_displayLink.preferredFramesPerSecond = tempSettings.framerate.intValue;
}
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
_controllerGyroSwitchEnabled = oscProfile.controllerGyroSwitchMode != ControllerGyroSwitchDisabled;
_gyroSwitchMode = oscProfile.controllerGyroSwitchMode;
// _gyroSwitchMode = ControllerGyroSwitchHoldDownToActivate;
_controllerGyroSwitchToggle = oscProfile.controllerGyroSwitchToggle;
_controllerGyroSwitchHold = oscProfile.controllerGyroSwitchHold;
_reverseHoldButton = oscProfile.reverseGyroHoldButton;
_controllerGyroSwitchTogglePressed = false;
_controllerGyroSwitchHoldPressed = false;
stickMaxOffset = 0x7FFE;
_leftStickMinOffset = oscProfile.physicalLeftStickMinOffset;
_rightStickMinOffset = oscProfile.physicalRightStickMinOffset;
if(oscProfile.controllerGyroSwitchMode == ControllerGyroSwitchDisabled && ![self useMotionHandler]) _gyroEnabledFlag = true;
if(![self useMotionHandler]) [self->motionHandler stopGyroUpdateWithInterruptNoneGyroInput:false resetLeftStick:true];
}
- (void)resetGyroInputForController:(VoidController* )voidController{
@@ -1409,7 +1816,7 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
self = [super init];
NSLog(@"controller support init");
_delegate = delegate;
_controllerStreamLock = [[NSLock alloc] init];
_voidControllers = [[NSMutableDictionary alloc] init];
@@ -1553,8 +1960,14 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
_oscController = [[VoidController alloc] init];
[self initializeControllerHaptics:_oscController];
_gyroMode = AlwaysDevice;
_controllerMouseEnabledFlag = false;
_gyroEnabledFlag = false;
oscProfileMan = [OSCProfilesManager sharedManager:CGRectZero];
[self updateCommonConfig:streamConfig];
for(VoidController* voidController in _voidControllers.allValues){
@@ -1568,6 +1981,7 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
_shallDisableGyroHotSwitch = streamConfig.gyroMode == GyroModeOff && _voidControllers.allValues.count == 0;
NSLog(@"shallDisableGyroHotSwitch %d", _shallDisableGyroHotSwitch);
return self;
}
@@ -1693,6 +2107,8 @@ static const double MOUSE_SPEED_DIVISOR = 1.25;
[self unregisterMouseCallbacks:mouse];
}
}
[self stopDisplayLink];
}
@end
+222
View File
@@ -0,0 +1,222 @@
//
// ControllerUtil.swift
// VoidLink
//
// Created by True砖家 on 2025/11/19.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
import GameController
@objc enum ControllerButton: Int {
// Face buttons
case a = 0x1000
case b = 0x2000
case x = 0x4000
case y = 0x8000
// DPad
case dpadUp = 0x0001
case dpadDown = 0x0002
case dpadLeft = 0x0004
case dpadRight = 0x0008
// Shoulder
case leftShoulder = 0x0100
case rightShoulder = 0x0200
// Stick click
case leftStickButton = 0x0040
case rightStickButton = 0x0080
// Menu/Back/Special
case menu = 0x0010 // PLAY_FLAG
case back = 0x0020 // BACK_FLAG
case special = 0x0400
// Extended buttons (Sunshine only)
case paddle1 = 0x010000
case paddle2 = 0x020000
case paddle3 = 0x040000
case paddle4 = 0x080000
case touchpadButton = 0x100000
case misc = 0x200000
case leftTrigger = 0x400000
case rightTrigger = 0x800000
case null = 0xFFFFFF
}
@objc class ControllerUtil: NSObject {
static private let stickMaxOffset:CGFloat = 0x7FFE
@objc static func listen(
controller: GCController,
swapABXY: Bool,
handler: @escaping (_ buttonDict: NSDictionary,
_ gamepad: GCExtendedGamepad,
_ element: GCControllerElement) -> Void
) {
guard let gamepad = controller.extendedGamepad else { return }
// 内部生成 map
var tempMap: [NSNumber: GCControllerButtonInput] = [:]
let swiftMap = buildMapping(for: controller, swapABXY: swapABXY)
for (button, input) in swiftMap {
tempMap[NSNumber(value: button.rawValue)] = input
}
let buttonDict = tempMap as NSDictionary
// 单一 gamepad.valueChangedHandler
gamepad.valueChangedHandler = { gamepad, element in
handler(buttonDict, gamepad, element)
}
}
// MARK: - 构建按钮映射
private static func buildMapping(for controller: GCController, swapABXY:Bool)
-> [ControllerButton : GCControllerButtonInput]
{
var result: [ControllerButton : GCControllerButtonInput] = [:]
if let pad = controller.extendedGamepad {
// Face buttons
if swapABXY {
result[.a] = pad.buttonB
result[.b] = pad.buttonA
result[.x] = pad.buttonY
result[.y] = pad.buttonX
}
else{
result[.a] = pad.buttonA
result[.b] = pad.buttonB
result[.x] = pad.buttonX
result[.y] = pad.buttonY
}
// Shoulders & triggers
result[.leftShoulder] = pad.leftShoulder
result[.rightShoulder] = pad.rightShoulder
result[.leftTrigger] = pad.leftTrigger
result[.rightTrigger] = pad.rightTrigger
// Stick buttons
if #available(iOS 12.1, *) {
result[.leftStickButton] = pad.leftThumbstickButton
result[.rightStickButton] = pad.rightThumbstickButton
}
// DPad
result[.dpadUp] = pad.dpad.up
result[.dpadDown] = pad.dpad.down
result[.dpadLeft] = pad.dpad.left
result[.dpadRight] = pad.dpad.right
// Menu / Options / Share
if #available(iOS 13.0, *) {
result[.menu] = pad.buttonMenu
if let options = pad.buttonOptions { result[.back] = options }
}
if #available(iOS 14.0, tvOS 14.0, *) {
if let home = pad.buttonHome {
result[.special] = home
}
if let controller = pad.controller {
let profile = controller.physicalInputProfile
if let paddle1 = profile.buttons[GCInputXboxPaddleOne] {
result[.paddle1] = paddle1
}
if let paddle2 = profile.buttons[GCInputXboxPaddleTwo] {
result[.paddle2] = paddle2
}
if let paddle3 = profile.buttons[GCInputXboxPaddleThree] {
result[.paddle3] = paddle3
}
if let paddle4 = profile.buttons[GCInputXboxPaddleFour] {
result[.paddle4] = paddle4
}
if let touchpadBtn = profile.buttons[GCInputDualShockTouchpadButton] {
result[.touchpadButton] = touchpadBtn
}
if #available(iOS 15.0, tvOS 15.0, *) {
if let share = profile.buttons[GCInputButtonShare] {
result[.misc] = share
}
}
}
}
}
return result
}
@objc static func string(for button: ControllerButton) -> String {
switch button {
case .a: return "A"
case .b: return "B"
case .x: return "X"
case .y: return "Y"
case .dpadUp: return SwiftLocalizationHelper.localizedString(forKey: "Up")
case .dpadDown: return SwiftLocalizationHelper.localizedString(forKey: "Down")
case .dpadLeft: return SwiftLocalizationHelper.localizedString(forKey: "Left")
case .dpadRight: return SwiftLocalizationHelper.localizedString(forKey: "Right")
case .leftShoulder: return "LB"
case .rightShoulder: return "RB"
case .leftStickButton: return "LS"
case .rightStickButton: return "RS"
case .menu: return "Menu"
case .back: return "Back"
case .special: return "Home"
case .paddle1: return SwiftLocalizationHelper.localizedString(forKey: "Paddle1")
case .paddle2: return SwiftLocalizationHelper.localizedString(forKey: "Paddle2")
case .paddle3: return SwiftLocalizationHelper.localizedString(forKey: "Paddle3")
case .paddle4: return SwiftLocalizationHelper.localizedString(forKey: "Paddle4")
case .touchpadButton: return SwiftLocalizationHelper.localizedString(forKey: "Touch button")
case .misc: return "Misc"
case .leftTrigger: return "LT"
case .rightTrigger: return "RT"
case .null: return SwiftLocalizationHelper.localizedString(forKey: "Null")
default: return "UNKNOWN"
}
}
@objc static func compensated(offsetVector: CGVector, minOffset: CGFloat, circulate:Bool=false) -> CGVector{
let vectorHypot = hypot(offsetVector.dx, offsetVector.dy)
guard vectorHypot > 0 else {return CGVector(dx: 0, dy: 0)}
let targetHypot = minOffset + (stickMaxOffset-minOffset)*(vectorHypot/stickMaxOffset)
var compensatedX = targetHypot * (offsetVector.dx/vectorHypot)
var compensatedY = targetHypot * (offsetVector.dy/vectorHypot)
if circulate {
return circulated(offsetVector: CGVector(dx: compensatedX, dy: compensatedY))
}
else {
compensatedX = max(min(compensatedX, stickMaxOffset),-stickMaxOffset)
compensatedY = max(min(compensatedY, stickMaxOffset),-stickMaxOffset)
return CGVector(dx: compensatedX, dy: compensatedY)
}
}
@objc static func circulated(offsetVector: CGVector) -> CGVector{
let vectorHypot = hypot(offsetVector.dx, offsetVector.dy)
guard vectorHypot > 0 else {return CGVector(dx: 0, dy: 0)}
let targetHypot = min(vectorHypot, stickMaxOffset)
let circulatedX = targetHypot*(offsetVector.dx/vectorHypot)
let circulatedY = targetHypot*(offsetVector.dy/vectorHypot)
return CGVector(dx: circulatedX, dy: circulatedY)
}
}
@@ -23,7 +23,7 @@ NS_ASSUME_NONNULL_BEGIN
@property UIView* _view;
@property NSMutableArray *layoutChanges;
@property CALayer *layerBeingDragged;
@property UIViewController *layoutToolVC;
@property (nonatomic, weak) UIViewController *layoutToolVC;
- (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport
streamConfig:(StreamConfiguration*)streamConfig oscLevel:(int)oscLevel;
@@ -44,7 +44,7 @@
layoutChanges = [[NSMutableArray alloc] init]; // will contain OSC button layout changes the user has made for this profile
[self drawButtons];
// [self drawButtons];
[self drawGuidelines]; // add the blue guidelines that appear when button is being tapped and dragged
return self;
@@ -55,35 +55,45 @@
/**
* This method overrides the superclass's drawButtons method. The purpose of this method is to create a dPad parent layer, and add the four dPad buttons to it so that the user can drag the entire dPad around, directional buttons and all, as one unit as is the expected behavior. Note that we do not want the four dPad buttons to be child layers of a CALayer parent layer on the game stream view since the touch logic implemented for the four dPad buttons on the game stream view is written assuming the dPad buttons are not children of another parent CALayer
*/
- (void) drawButtons {
[super setDPadCenter]; // Set custom position for D-Pad here
[super setAnalogStickPositions]; // Set custom position for analog sticks here
- (void) drawButtons:(OSCProfile* )profile {
// [super regenLayerPool];
[super setDPadCenter:profile]; // Set custom position for D-Pad here
[super setAnalogStickPositions:profile]; // Set custom position for analog sticks here
[super drawBumpers];
[super drawButtons];
[super drawButtons:profile];
/*
UIImage* downButtonImage = [UIImage imageNamed:@"DownButton"];
UIImage* rightButtonImage = [UIImage imageNamed:@"RightButton"];
UIImage* upButtonImage = [UIImage imageNamed:@"UpButton"];
UIImage* leftButtonImage = [UIImage imageNamed:@"LeftButton"];
*/
UIImage* downButtonImage = [UIImage imageNamed:@"DownButton"];
UIImage* rightButtonImage = [UIImage imageNamed:@"RightButton"];
UIImage* upButtonImage = [UIImage imageNamed:@"UpButton"];
UIImage* leftButtonImage = [UIImage imageNamed:@"LeftButton"];
// create dPad background layer
self._dPadBackground = [CALayer layer];
self._dPadBackground.name = @"dPad";
self._dPadBackground.frame = CGRectMake(self.D_PAD_CENTER_X,
self.D_PAD_CENTER_Y,
self._leftButton.frame.size.width * 2 + BUTTON_DIST,
self._leftButton.frame.size.width * 2 + BUTTON_DIST);
self._dPadBackground.position = CGPointMake(self.D_PAD_CENTER_X, self.D_PAD_CENTER_Y); // since dPadBackground's dimensions have change after settings its width and height you need to reset its position again here
[self.OSCButtonLayers addObject:self._dPadBackground];
[_view.layer addSublayer:self._dPadBackground];
// add dPad buttons to parent layer
[self._dPadBackground addSublayer:self._downButton];
[self._dPadBackground addSublayer:self._rightButton];
[self._dPadBackground addSublayer:self._upButton];
[self._dPadBackground addSublayer:self._leftButton];
if([self.OSCButtonLayerPool containsObject:self._upButton]){
[self.OSCButtonLayerPool addObject:self._dPadBackground];
self._dPadBackground.name = @"dPad";
self._dPadBackground.frame = CGRectMake(self.D_PAD_CENTER_X,
self.D_PAD_CENTER_Y,
self._leftButton.frame.size.width * 2 + BUTTON_DIST,
self._leftButton.frame.size.width * 2 + BUTTON_DIST);
self._dPadBackground.position = CGPointMake(self.D_PAD_CENTER_X, self.D_PAD_CENTER_Y); // since dPadBackground's dimensions have change after settings its width and height you need to reset its position again here
[self.OSCButtonLayerPool addObject:self._dPadBackground];
[_view.layer addSublayer:self._dPadBackground];
// add dPad buttons to parent layer
[self._dPadBackground addSublayer:self._downButton];
[self._dPadBackground addSublayer:self._rightButton];
[self._dPadBackground addSublayer:self._upButton];
[self._dPadBackground addSublayer:self._leftButton];
}
NSLog(@"layer name ttttttttttt============================");
for(CALayer* buttonLayer in self.OSCButtonLayerPool){
NSLog(@"layer name %@", buttonLayer.name);
}
[self resizeControllerLayerWith:self._dPadBackground and:_dPadSizeFactor];
/*
@@ -128,7 +138,7 @@
*/
horizontalGuideline.backgroundColor = [UIColor blueColor]; // change horizontal guideline back to blue if it doesn't line up with one of the on screen buttons
for (CALayer *button in self.OSCButtonLayers) { // horizontal guideline position check
for (CALayer *button in self.OSCButtonLayerPool) { // horizontal guideline position check
if ((layerBeingDragged != button) && !button.isHidden) {
if ((horizontalGuideline.center.y < button.position.y + 1) &&
(horizontalGuideline.center.y > button.position.y - 1)) {
@@ -147,7 +157,7 @@
}
verticalGuideline.backgroundColor = [UIColor blueColor]; // change vertical guideline back to blue if it doesn't line up with one of the on screen buttons
for (CALayer *button in self.OSCButtonLayers) { // vertical guideline position check
for (CALayer *button in self.OSCButtonLayerPool) { // vertical guideline position check
if ((layerBeingDragged != button) && !button.isHidden) {
if ((verticalGuideline.center.x < button.position.x + 1) &&
(verticalGuideline.center.x > button.position.x - 1)) {
@@ -167,7 +177,7 @@
}
- (void) updateGuidelinesForLegacyOscButton{
- (void) updateGuidelinesForLegacyOnScreenControls{
/* have guidelines follow wherever the user is touching on the screen */
horizontalGuideline.center = layerBeingDragged.position;
verticalGuideline.center = layerBeingDragged.position;
@@ -177,7 +187,7 @@
-Change vertical guideline color to yellow if its x-position is almost equal to that of one of the buttons on screen.
*/
horizontalGuideline.backgroundColor = [UIColor blueColor]; // change horizontal guideline back to blue if it doesn't line up with one of the on screen buttons
for (CALayer *button in self.OSCButtonLayers) { // horizontal guideline position check
for (CALayer *button in self.OSCButtonLayerPool) { // horizontal guideline position check
if ((layerBeingDragged != button) && !button.isHidden) {
if ((horizontalGuideline.center.y < button.position.y + 1) &&
(horizontalGuideline.center.y > button.position.y - 1)) {
@@ -196,7 +206,7 @@
}
}
verticalGuideline.backgroundColor = [UIColor blueColor]; // change vertical guideline back to blue if it doesn't line up with one of the on screen buttons
for (CALayer *button in self.OSCButtonLayers) { // vertical guideline position check
for (CALayer *button in self.OSCButtonLayerPool) { // vertical guideline position check
if ((layerBeingDragged != button) && !button.isHidden) {
if ((verticalGuideline.center.x < button.position.x + 1) &&
(verticalGuideline.center.x > button.position.x - 1)) {
@@ -233,7 +243,7 @@
/* returns reference to button layer object given the button's name*/
- (CALayer*)controllerLayerFromName: (NSString*)name {
for (CALayer *buttonLayer in self.OSCButtonLayers) {
for (CALayer *buttonLayer in self.OSCButtonLayerPool) {
if ([buttonLayer.name isEqualToString:name]) {
return buttonLayer;
@@ -297,7 +307,7 @@
[[NSNotificationCenter defaultCenter] postNotificationName:@"LegacyOscCALayerSelectedNotification" object:layerBeingDragged]; // inform that a CALayer of onScreen controller has being selected
/* save the name, position, and visibility of button being touched in array in case user wants to undo the change later */
OnScreenButtonState *onScreenButtonState = [[OnScreenButtonState alloc] initWithButtonName:layerBeingDragged.name buttonType:LegacyOscButton andPosition:layerBeingDragged.position];
OnScreenButtonState *onScreenButtonState = [[OnScreenButtonState alloc] initWithButtonName:layerBeingDragged.name buttonType:LegacyOnScreenControls andPosition:layerBeingDragged.position];
// add hidden attr here
onScreenButtonState.isHidden = layerBeingDragged.isHidden;
@@ -307,7 +317,7 @@
/* make guide lines visible and position them over the button the user is touching */
horizontalGuideline.hidden = NO;
verticalGuideline.hidden = NO;
[self updateGuidelinesForLegacyOscButton];
[self updateGuidelinesForLegacyOnScreenControls];
}
}
@@ -317,7 +327,7 @@
[CATransaction begin];
[CATransaction setDisableActions:YES];
layerBeingDragged.position = touchLocation; // move object to touch location
[self updateGuidelinesForLegacyOscButton];
[self updateGuidelinesForLegacyOnScreenControls];
[CATransaction commit];
}
+49 -3
View File
@@ -14,17 +14,63 @@
NS_ASSUME_NONNULL_BEGIN
#define DEFAULT_TEMPLATE_NAME1 @"控件模板 / Widget templates"
#define DEFAULT_TEMPLATE_NAME2 @"旧版屏显控件 / Legacy on-screen controls"
#define DEFAULT_TEMPLATE_NAME2 @"旧版控件 / Legacy widgets"
typedef NS_ENUM(NSInteger, MapGyroTo) {
mapGyroToMouse,
mapGyroToControllerStick,
driftCorrection
};
typedef NS_ENUM(NSInteger, PencilHoverMode) {
HoverDisabled,
HoverPencil,
HoverMouse,
HoverBoth
};
/**
This object contains information pertaining to any of the user created, custom on screen controller layout configurations, or 'profiles.' The object contains a 'name' property for easy reference, as well as an 'isSelected' property which is used to determine whether this particular custom OSC layout should show on screen during game stream view. Only one 'OSCProfile' is set to 'isSelected' at any given time. The object also contains an array of 'OnScreenButtonStates' which provides information that allows us to move and hide/unhide each of the 19 on screen buttons. Note that the 'buttonStates' property should contain an NSMutableArray of ENCODED 'OnScreenButtonState' objects. This allows us to save the 'OSCProfile' object to NSUserDefaults.
Additionally the 'OSCProfile' object adopts encoding and decoding protocols so that we can encode the object before saving it to NSUserDefaults. By saving this object to NSUserDefaults we allow the user to save and load their custom on screen controller button layouts between app launches
*/
@interface OSCProfile : NSObject <NSCoding, NSSecureCoding>
@interface OSCProfile : NSObject <NSCoding, NSSecureCoding, NSMutableCopying>
@property NSString *name;
@property NSMutableArray <OnScreenButtonState *> *buttonStates;
@property NSMutableArray <NSData *> *buttonStatesEncoded;
@property (nonatomic, assign) int16_t unfoldedExclusiveFolderSequence;
@property NSSet<NSNumber *> *postExclusiveUnfoldedSequences;
@property BOOL isSelected;
@property (nonatomic, assign) MapGyroTo mapGyroTo;
@property (nonatomic, assign) bool yawPitchToRightStick;
@property (nonatomic, assign) bool rollToLeftStick;
@property (nonatomic, assign) bool synthesizePhysicalStick;
@property (nonatomic, assign) CGFloat gyroSensitivityYaw;
@property (nonatomic, assign) CGFloat gyroSensitivityPitch;
@property (nonatomic, assign) CGFloat gyroSensitivityRoll;
@property (nonatomic, assign) CGFloat accelSensitivityX;
@property (nonatomic, assign) CGFloat accelSensitivityY;
@property (nonatomic, assign) CGFloat accelSensitivityZ;
@property (nonatomic, assign) double gyroToStickMinOffset;
@property (nonatomic, assign) double physicalLeftStickMinOffset;
@property (nonatomic, assign) double physicalRightStickMinOffset;
@property (nonatomic, assign) int controllerGyroSwitchMode;
@property (nonatomic, assign) bool reverseGyroHoldButton;
@property (nonatomic, assign) int controllerGyroSwitchHold;
@property (nonatomic, assign) int controllerGyroSwitchToggle;
@property NSArray<NSNumber *> *pressureCurvePoints;
@property (nonatomic, assign) bool pressureCurveEnabled;
@property (nonatomic, assign) bool doubleTapShorcutEnabled;
@property NSString *brushShortcut;
@property NSString *eraserShortcut;
@property (nonatomic, assign) bool squeezeShorcutEnabled;
@property NSString *squeezeStartShortcut;
@property NSString *squeezeEndShortcut;
@property (nonatomic, assign) bool pencilPausesNativeTouch;
@property (nonatomic, assign) bool disablePencilSlideGestures;
@property (nonatomic, assign) PencilHoverMode pencilHoverMode;
- (id) initWithName:(NSString*)name buttonStates:(NSMutableArray*)buttonStates isSelected:(BOOL)isSelected;
+164 -4
View File
@@ -10,13 +10,14 @@
//
#import "OSCProfile.h"
#import "VoidLink-Swift.h"
@implementation OSCProfile
- (id) initWithName:(NSString*)name buttonStates:(NSMutableArray*)buttonStates isSelected:(BOOL)isSelected {
if ((self = [self init])) {
self.name = name;
self.buttonStates = buttonStates;
self.buttonStatesEncoded = buttonStates;
self.isSelected = isSelected;
}
@@ -29,18 +30,177 @@
- (void) encodeWithCoder:(NSCoder*)encoder {
[encoder encodeObject:self.name forKey:@"name"];
[encoder encodeObject:self.buttonStates forKey:@"buttonStates"];
[encoder encodeObject:self.buttonStatesEncoded forKey:@"buttonStates"];
[encoder encodeInt32:self.unfoldedExclusiveFolderSequence forKey:@"unfoldedExclusiveFolderSequence"];
[encoder encodeObject:self.postExclusiveUnfoldedSequences forKey:@"postExclusiveUnfoldedSequences"];
[encoder encodeBool:self.isSelected forKey:@"isSelected"];
[encoder encodeInt64:self.mapGyroTo forKey:@"mapGyroTo"];
[encoder encodeBool:self.yawPitchToRightStick forKey:@"yawPitchToRightStick"];
[encoder encodeBool:self.rollToLeftStick forKey:@"rollToLeftStick"];
[encoder encodeBool:self.synthesizePhysicalStick forKey:@"synthesizePhysicalStick"];
[encoder encodeFloat:self.gyroSensitivityYaw forKey:@"gyroSensitivityYaw"];
[encoder encodeFloat:self.gyroSensitivityPitch forKey:@"gyroSensitivityPitch"];
[encoder encodeFloat:self.gyroSensitivityRoll forKey:@"gyroSensitivityRoll"];
[encoder encodeFloat:self.accelSensitivityX forKey:@"accelSensitivityX"];
[encoder encodeFloat:self.accelSensitivityY forKey:@"accelSensitivityY"];
[encoder encodeFloat:self.accelSensitivityZ forKey:@"accelSensitivityZ"];
[encoder encodeDouble:self.gyroToStickMinOffset forKey:@"gyroToStickMinOffset"];
[encoder encodeDouble:self.physicalLeftStickMinOffset forKey:@"physicalLeftStickMinOffset"];
[encoder encodeDouble:self.physicalRightStickMinOffset forKey:@"physicalRightStickMinOffset"];
[encoder encodeInt:self.controllerGyroSwitchMode forKey:@"controllerGyroSwitchMode"];
[encoder encodeBool:self.reverseGyroHoldButton forKey:@"reverseGyroHoldButton"];
[encoder encodeInt:self.controllerGyroSwitchHold forKey:@"controllerGyroSwitchHold"];
[encoder encodeInt:self.controllerGyroSwitchToggle forKey:@"controllerGyroSwitchToggle"];
[encoder encodeObject:self.pressureCurvePoints forKey:@"pressureCurvePoints"];
[encoder encodeBool:self.pressureCurveEnabled forKey:@"pressureCurveEnabled"];
[encoder encodeBool:self.doubleTapShorcutEnabled forKey:@"doubleTapShorcutEnabled"];
[encoder encodeObject:self.brushShortcut forKey:@"brushShortcut"];
[encoder encodeObject:self.eraserShortcut forKey:@"eraserShortcut"];
[encoder encodeBool:self.squeezeShorcutEnabled forKey:@"squeezeShorcutEnabled"];
[encoder encodeObject:self.squeezeStartShortcut forKey:@"squeezeStartShortcut"];
[encoder encodeObject:self.squeezeEndShortcut forKey:@"squeezeEndShortcut"];
[encoder encodeBool:self.pencilPausesNativeTouch forKey:@"pencilPausesNativeTouch"];
[encoder encodeBool:self.disablePencilSlideGestures forKey:@"disablePencilSlideGestures"];
[encoder encodeInt64:self.pencilHoverMode forKey:@"pencilHoverMode"];
}
- (id) initWithCoder:(NSCoder*)decoder {
if (self = [super init]) {
self.name = [decoder decodeObjectForKey:@"name"];
self.buttonStates = [decoder decodeObjectForKey:@"buttonStates"];
self.name =
[decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSString class],
nil]
forKey:@"name"];
self.buttonStatesEncoded =
[decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSMutableArray class],
[NSArray class],
[NSData class],
nil]
forKey:@"buttonStates"];
self.unfoldedExclusiveFolderSequence = [decoder containsValueForKey:@"unfoldedExclusiveFolderSequence"] ? [decoder decodeInt32ForKey:@"unfoldedExclusiveFolderSequence"] : -1;
self.postExclusiveUnfoldedSequences = [decoder containsValueForKey:@"postExclusiveUnfoldedSequences"] ?
[decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSMutableSet class],
[NSSet class],
[NSNumber class],
nil]
forKey:@"postExclusiveUnfoldedSequences"] :
[NSSet set];
self.isSelected = [decoder decodeBoolForKey:@"isSelected"];
self.mapGyroTo = [decoder containsValueForKey:@"mapGyroTo"] ? [decoder decodeInt64ForKey:@"mapGyroTo"] : mapGyroToMouse;
self.yawPitchToRightStick = [decoder containsValueForKey:@"yawPitchToRightStick"] ? [decoder decodeBoolForKey:@"yawPitchToRightStick"] : true;
self.rollToLeftStick = [decoder containsValueForKey:@"rollToLeftStick"] ? [decoder decodeBoolForKey:@"rollToLeftStick"] : false;
self.synthesizePhysicalStick = [decoder containsValueForKey:@"synthesizePhysicalStick"] ? [decoder decodeBoolForKey:@"synthesizePhysicalStick"] : true;
self.gyroSensitivityYaw = [decoder containsValueForKey:@"gyroSensitivityYaw"] ? [decoder decodeFloatForKey:@"gyroSensitivityYaw"] : 1.0;
self.gyroSensitivityPitch = [decoder containsValueForKey:@"gyroSensitivityPitch"] ? [decoder decodeFloatForKey:@"gyroSensitivityPitch"] : 1.0;
self.gyroSensitivityRoll = [decoder containsValueForKey:@"gyroSensitivityRoll"] ? [decoder decodeFloatForKey:@"gyroSensitivityRoll"] : 1.0;
self.accelSensitivityX = [decoder containsValueForKey:@"accelSensitivityX"] ? [decoder decodeFloatForKey:@"accelSensitivityX"] : 1.0;
self.accelSensitivityY = [decoder containsValueForKey:@"accelSensitivityY"] ? [decoder decodeFloatForKey:@"accelSensitivityY"] : 1.0;
self.accelSensitivityZ = [decoder containsValueForKey:@"accelSensitivityZ"] ? [decoder decodeFloatForKey:@"accelSensitivityZ"] : 1.0;
self.gyroToStickMinOffset = [decoder containsValueForKey:@"gyroToStickMinOffset"] ? [decoder decodeDoubleForKey:@"gyroToStickMinOffset"] : 0;
self.physicalLeftStickMinOffset = [decoder containsValueForKey:@"physicalLeftStickMinOffset"] ? [decoder decodeDoubleForKey:@"physicalLeftStickMinOffset"] : 0;
self.physicalRightStickMinOffset = [decoder containsValueForKey:@"physicalRightStickMinOffset"] ? [decoder decodeDoubleForKey:@"physicalRightStickMinOffset"] : 0;
self.controllerGyroSwitchMode = [decoder containsValueForKey:@"controllerGyroSwitchMode"] ? [decoder decodeIntForKey:@"controllerGyroSwitchMode"] : ControllerGyroSwitchDisabled;
self.reverseGyroHoldButton = [decoder containsValueForKey:@"reverseGyroHoldButton"] ? [decoder decodeBoolForKey:@"reverseGyroHoldButton"] : false;
self.controllerGyroSwitchHold = [decoder containsValueForKey:@"controllerGyroSwitchHold"] ? [decoder decodeIntForKey:@"controllerGyroSwitchHold"] : ControllerButtonNull;
self.controllerGyroSwitchToggle = [decoder containsValueForKey:@"controllerGyroSwitchToggle"] ? [decoder decodeIntForKey:@"controllerGyroSwitchToggle"] : ControllerButtonNull;
self.pressureCurvePoints =
[decoder containsValueForKey:@"pressureCurvePoints"]
? [decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSArray class],
[NSNumber class],
nil]
forKey:@"pressureCurvePoints"]
: @[@0.0, @0.0, @1.0, @1.0];
self.pressureCurveEnabled = [decoder containsValueForKey:@"pressureCurveEnabled"] ? [decoder decodeBoolForKey:@"pressureCurveEnabled"] : false;
self.doubleTapShorcutEnabled = [decoder containsValueForKey:@"doubleTapShorcutEnabled"] ? [decoder decodeBoolForKey:@"doubleTapShorcutEnabled"] : false;
self.brushShortcut =
[decoder containsValueForKey:@"brushShortcut"]
? [decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSString class],
nil]
forKey:@"brushShortcut"]
: @"";
self.eraserShortcut =
[decoder containsValueForKey:@"eraserShortcut"]
? [decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSString class],
nil]
forKey:@"eraserShortcut"]
: @"";
self.squeezeShorcutEnabled = [decoder containsValueForKey:@"squeezeShorcutEnabled"] ? [decoder decodeBoolForKey:@"squeezeShorcutEnabled"] : false;
self.squeezeStartShortcut =
[decoder containsValueForKey:@"squeezeStartShortcut"]
? [decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSString class],
nil]
forKey:@"squeezeStartShortcut"]
: @"";
self.squeezeEndShortcut =
[decoder containsValueForKey:@"squeezeEndShortcut"]
? [decoder decodeObjectOfClasses:
[NSSet setWithObjects:
[NSString class],
nil]
forKey:@"squeezeEndShortcut"]
: @"";
self.pencilPausesNativeTouch = [decoder containsValueForKey:@"pencilPausesNativeTouch"] ? [decoder decodeBoolForKey:@"pencilPausesNativeTouch"] : false;
self.disablePencilSlideGestures = [decoder containsValueForKey:@"disablePencilSlideGestures"] ? [decoder decodeBoolForKey:@"disablePencilSlideGestures"] : false;
self.pencilHoverMode = [decoder containsValueForKey:@"pencilHoverMode"] ? [decoder decodeInt64ForKey:@"pencilHoverMode"] : HoverPencil;
}
return self;
}
- (nonnull id)mutableCopyWithZone:(nullable NSZone *)zone {
OSCProfile *copy = [[[self class] allocWithZone:zone] init];
copy.name = [self.name mutableCopy]; // NSString → NSMutableString
copy.buttonStatesEncoded = [[NSMutableArray alloc] initWithArray:self.buttonStatesEncoded copyItems:YES];
copy.unfoldedExclusiveFolderSequence = self.unfoldedExclusiveFolderSequence;
copy.postExclusiveUnfoldedSequences = [self.postExclusiveUnfoldedSequences copy];
copy.isSelected = self.isSelected;
copy.mapGyroTo = self.mapGyroTo;
copy.yawPitchToRightStick = self.yawPitchToRightStick;
copy.rollToLeftStick = self.rollToLeftStick;
copy.synthesizePhysicalStick = self.synthesizePhysicalStick;
copy.gyroSensitivityYaw = self.gyroSensitivityYaw;
copy.gyroSensitivityPitch = self.gyroSensitivityPitch;
copy.gyroSensitivityRoll = self.gyroSensitivityRoll;
copy.accelSensitivityX = self.accelSensitivityX;
copy.accelSensitivityY = self.accelSensitivityY;
copy.accelSensitivityZ = self.accelSensitivityZ;
copy.gyroToStickMinOffset = self.gyroToStickMinOffset;
copy.physicalLeftStickMinOffset = self.physicalLeftStickMinOffset;
copy.physicalRightStickMinOffset = self.physicalRightStickMinOffset;
copy.controllerGyroSwitchMode = self.controllerGyroSwitchMode;
copy.reverseGyroHoldButton = self.reverseGyroHoldButton;
copy.controllerGyroSwitchHold = self.controllerGyroSwitchHold;
copy.controllerGyroSwitchToggle = self.controllerGyroSwitchToggle;
copy.pressureCurvePoints = [[NSMutableArray alloc] initWithArray:self.pressureCurvePoints copyItems:YES];
copy.pressureCurveEnabled = self.pressureCurveEnabled;
copy.doubleTapShorcutEnabled = self.doubleTapShorcutEnabled;
copy.brushShortcut = [self.brushShortcut mutableCopy]; // NSString → NSMutableString
copy.eraserShortcut = [self.eraserShortcut mutableCopy]; // NSString → NSMutableString
copy.squeezeShorcutEnabled = self.squeezeShorcutEnabled;
copy.squeezeStartShortcut = [self.squeezeStartShortcut mutableCopy]; // NSString → NSMutableString
copy.squeezeEndShortcut = [self.squeezeEndShortcut mutableCopy]; // NSString → NSMutableString
copy.pencilPausesNativeTouch = self.pencilPausesNativeTouch;
copy.disablePencilSlideGestures = self.disablePencilSlideGestures;
copy.pencilHoverMode = self.pencilHoverMode;
return copy;
}
@end
+14 -6
View File
@@ -11,6 +11,7 @@
#import <Foundation/Foundation.h>
#import "OSCProfile.h"
#import "DataManager.h"
NS_ASSUME_NONNULL_BEGIN
@@ -20,7 +21,7 @@ NS_ASSUME_NONNULL_BEGIN
*/
@interface OSCProfilesManager : NSObject
@property NSMutableArray <OSCProfile *> *currentProfiles;
@property (atomic, assign) WidgetSizeTransition widgetSizeTransition;
+ (OSCProfilesManager *)sharedManager:(CGRect)viewBounds;
+ (void)setOnScreenWidgetViewsSet:(NSMutableSet* )set;
@@ -38,28 +39,34 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (OSCProfile *) getSelectedProfile;
- (uint32_t) getIndexOfLastProfile;
/**
* Returns the index of the 'selected' profile within the array it's in
*/
- (NSInteger) getIndexOfSelectedProfile;
- (NSMutableArray *) getEncodedProfiles;
// - (NSData* )getSelectedEncodedProfile;
- (void) importEncodedProfiles:(NSMutableArray* )profilesEncoded;
- (OnScreenButtonState *)unarchiveButtonStateEncoded:(NSData *)data;
- (void) updateDefaultTemplates;
- (void) importDefaultTemplates;
#pragma mark - Setters
/**
* Sets the profile object with the particular 'name' as the selected profile to be displayed on screen during game streaming
* Sets the profile object with the particular index as the selected profile to be displayed on screen during game streaming
*/
- (void) setProfileToSelected:(NSString *)name;
- (void) setProfileToSelected:(uint32_t)index;
- (void) replaceSelectedProfileWith:(OSCProfile*)newProfile overwriteDefault:(bool)overwriteDefault;
- (void) replaceProfile:(OSCProfile*)oldProfile withProfile:(OSCProfile*)newProfile;
/**
* Saves a profile object with a particular 'name' and an array of button layers (the CALayer button layers are the objects currently visible on screen) to persistent storage
*/
- (void) saveProfileWithName:(NSString*)name andButtonLayers:(NSMutableArray *)buttonLayers;
- (void) duplicateSelectedProfileWithName:(NSString*)name;
- (bool) updateSelectedProfile:(NSMutableSet *) oscButtonLayers;
- (bool) updateSelectedProfile:(NSMutableArray *) oscButtonLayers;
/**
* Delete current selected profile.
*/
@@ -71,6 +78,7 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (BOOL) profileNameAlreadyExist:(NSString*)name;
- (OSCProfile *) findProfileByName:(NSString*) name inProfileArray:(NSMutableArray*)profiles;
- (CGPoint)normalizeWidgetPosition:(CGPoint)position;
@end
+197 -96
View File
@@ -25,7 +25,7 @@ static CGRect layoutViewBounds;
+ (OSCProfilesManager *) sharedManager:(CGRect)viewBounds {
static OSCProfilesManager *_sharedManager = nil;
static dispatch_once_t onceToken;
layoutViewBounds = viewBounds;
if(!CGRectEqualToRect(viewBounds, CGRectZero)) layoutViewBounds = viewBounds;
// NSLog(@"bounds width: %f, height: %f", layoutViewBounds.size.width, layoutViewBounds.size.height);
dispatch_once(&onceToken, ^{
_sharedManager = [[self alloc] init];
@@ -105,6 +105,22 @@ static CGRect layoutViewBounds;
}
- (void)replaceSelectedProfileWith:(OSCProfile*)newProfile overwriteDefault:(bool)overwriteDefault{
NSInteger index = 0;
NSMutableArray *profiles = [self getAllProfiles];
for (OSCProfile *profile in profiles) {
if (profile.isSelected == YES) {
index = [profiles indexOfObject:profile];
}
}
if(index>0) profiles[index] = newProfile;
else if(overwriteDefault) profiles[index] = newProfile;
NSMutableArray *profilesEncoded = [self encodedProfilesFromArray:profiles];
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:profilesEncoded requiringSecureCoding:YES error:nil];
[[NSUserDefaults standardUserDefaults] setObject:data forKey:@"OSCProfiles"];
[[NSUserDefaults standardUserDefaults] synchronize];
}
/**
* Replaces one 'OSCProfile' object for another in the 'OSCProfile' objects array stored in persistent storage
@@ -139,18 +155,32 @@ static CGRect layoutViewBounds;
}
- (NSMutableArray *) getEncodedProfiles {
NSMutableArray *profiles = [self getAllProfiles];
NSMutableArray *profilesEncoded = [self encodedProfilesFromArray:profiles]; // encode each 'profile' object in the array and add them to a new array
NSData *profilesArrayEncoded = [[NSUserDefaults standardUserDefaults] objectForKey: @"OSCProfiles"]; // Get the encoded array of encoded OSC profiles from persistent storage
NSSet *classes = [NSSet setWithObjects:[NSString class], [NSMutableData class], [NSMutableArray class], [OSCProfile class], [OnScreenButtonState class], nil];
NSMutableArray *profilesEncoded = [NSKeyedUnarchiver unarchivedObjectOfClasses:classes fromData:profilesArrayEncoded error:nil]; // Decode the encoded array itself, NOT the objects contained in the array
return profilesEncoded;
}
- (void) importEncodedProfiles:(NSMutableArray* )profilesEncoded {
NSMutableArray* targetProfiles = [_currentProfiles mutableCopy]; //_currentProfiles is availabled as long as getAllProfiles was called before calling this method (_currentProfiles avoids frequent accessing persisted data)
OSCProfile* profile;
/*
profile = [self findProfileByName:DEFAULT_TEMPLATE_NAME1 inProfileArray:targetProfiles];
if(profile && targetProfiles.count > 1) [targetProfiles removeObject:profile];
profile = [self findProfileByName:DEFAULT_TEMPLATE_NAME2 inProfileArray:targetProfiles];
if(profile && targetProfiles.count > 1) [targetProfiles removeObject:profile];
*/
NSString *bundleId = [[NSBundle mainBundle] bundleIdentifier];
bool importPencilProProfile = ([bundleId isEqualToString:@"com.voidlink.iOS"]
|| [bundleId isEqualToString:@"com.voidlinkextreme.iOS"]
|| [bundleId isEqualToString:@"com.voidlink.tf.debug10.iOS"]);
if(!importPencilProProfile && [GenericUtils isIPad]){
[profilesEncoded removeObjectAtIndex:1];
}
if(targetProfiles.count > 0) [targetProfiles removeObjectAtIndex:0];
NSMutableArray* localEncodedPofiles = [self encodedProfilesFromArray:targetProfiles];
[profilesEncoded addObjectsFromArray:localEncodedPofiles];
@@ -164,7 +194,6 @@ static CGRect layoutViewBounds;
return ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone);
}
- (void) importDefaultTemplates{
NSString *filePath = [[NSBundle mainBundle] pathForResource: [self isIPhone] ? @"widgetTemplatesIPhone": @"widgetTemplates" ofType:@"bin"];
if (filePath) {
@@ -177,6 +206,47 @@ static CGRect layoutViewBounds;
NSSet *classes = [NSSet setWithObjects: [NSMutableData class], [NSMutableArray class], nil];
profilesEncoded = [NSKeyedUnarchiver unarchivedObjectOfClasses:classes fromData:fileData error:&error];
[self importEncodedProfiles:profilesEncoded];
[self setProfileToSelected:0];
}
}
}
- (void) updateDefaultTemplates{
NSString *filePath = [[NSBundle mainBundle] pathForResource: [self isIPhone] ? @"widgetTemplatesIPhone": @"widgetTemplates" ofType:@"bin"];
if (filePath) {
// 2. 读取二进制数据
NSError *error;
NSData *fileData = [NSData dataWithContentsOfFile:filePath options:NSDataReadingMappedIfSafe error:&error];
if (fileData && !error) {
NSSet *classes = [NSSet setWithObjects:[NSString class], [NSMutableData class], [NSMutableArray class], [OSCProfile class], [OnScreenButtonState class], nil];
NSMutableArray *defaultProfilesEncoded = [NSKeyedUnarchiver unarchivedObjectOfClasses:classes fromData:fileData error:nil]; // Decode the encoded array itself, NOT the objects contained in the array
OSCProfile *defaultProfileDecoded;
NSMutableArray *targetProfilesDecoded = [self getAllProfiles];
OSCProfile *newPencilProProfile;
bool pencilProProfileExisted = false;
for (NSData *profileEncoded in defaultProfilesEncoded) {
defaultProfileDecoded = [NSKeyedUnarchiver unarchivedObjectOfClasses: classes fromData:profileEncoded error: nil];
OSCProfile *targetProfile = [self findProfileByName:defaultProfileDecoded.name inProfileArray:targetProfilesDecoded];
if([defaultProfileDecoded.name isEqualToString:@"Pencil Pro"]){
newPencilProProfile = [defaultProfileDecoded mutableCopy];
}
if(targetProfile){
if([targetProfile.name isEqualToString:@"Pencil Pro"]){
pencilProProfileExisted = true;
}
uint64_t targetIndex = [targetProfilesDecoded indexOfObject:targetProfile];
targetProfilesDecoded[targetIndex] = defaultProfileDecoded;
}
}
if(newPencilProProfile && !pencilProProfileExisted){
if(newPencilProProfile) [targetProfilesDecoded insertObject:newPencilProProfile atIndex:1];
}
NSMutableArray* targetPofilesEncoded = [self encodedProfilesFromArray:targetProfilesDecoded];
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:targetPofilesEncoded requiringSecureCoding:YES error:nil];
[[NSUserDefaults standardUserDefaults] setObject:data forKey:@"OSCProfiles"];
[[NSUserDefaults standardUserDefaults] synchronize];
[self setProfileToSelected:0];
}
}
}
@@ -187,10 +257,9 @@ static CGRect layoutViewBounds;
#pragma mark - Getters
- (NSMutableArray *) getAllProfiles {
NSData *profilesArrayEncoded = [[NSUserDefaults standardUserDefaults] objectForKey: @"OSCProfiles"]; // Get the encoded array of encoded OSC profiles from persistent storage
NSSet *classes = [NSSet setWithObjects:[NSString class], [NSMutableData class], [NSMutableArray class], [OSCProfile class], [OnScreenButtonState class], nil];
NSMutableArray *profilesEncoded = [NSKeyedUnarchiver unarchivedObjectOfClasses:classes fromData:profilesArrayEncoded error:nil]; // Decode the encoded array itself, NOT the objects contained in the array
/* Decode each of the encoded profiles, place them into an array, then return the array */
@@ -206,15 +275,27 @@ static CGRect layoutViewBounds;
}
- (OSCProfile *) getSelectedProfile {
NSLog(@"getAllProfiles test %f", CACurrentMediaTime());
NSMutableArray *profiles = [self getAllProfiles];
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
// NSString* persistedKey = @"widgetProfileUpdated-20251015";
NSString* persistedKey = @"widgetProfileUpdated-20260213";
BOOL needImportDefaultTemplates = [defaults objectForKey:persistedKey] == nil;
if(profiles.count == 0 || needImportDefaultTemplates){
if(profiles.count == 0) [self importDefaultTemplates];
else [self updateDefaultTemplates];
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:persistedKey];
[[NSUserDefaults standardUserDefaults] synchronize];
profiles = [self getAllProfiles];
}
for (OSCProfile *profile in profiles) {
if (profile.isSelected) {
return profile;
}
}
return nil;
return profiles[0];
}
- (NSInteger) getIndexOfSelectedProfile {
@@ -228,25 +309,23 @@ static CGRect layoutViewBounds;
return 0; // if none of the profiles in the array have their 'isSelected' property set to YES (which should not be possible) return the 'Default' profile as the 'selected' profile
}
- (uint32_t) getIndexOfLastProfile {
NSMutableArray *profiles = [self getAllProfiles];
return (uint32_t)profiles.count-1; // if none of the profiles in the array have their 'isSelected' property set to YES (which should not be possible) return the 'Default' profile as the 'selected' profile
}
#pragma mark - Setters
- (void) setProfileToSelected:(NSString *)name {
- (void) setProfileToSelected:(uint32_t)tableIndex {
NSMutableArray *profiles = [self getAllProfiles];
/* Iterate through each profile. If its name equals the value of the 'name' parameter passed into this method then set the profile's 'isSelected' property to YES, otherwise set the value to NO */
for (OSCProfile *profile in profiles) {
if ([profile.name isEqualToString:name]) {
profile.isSelected = YES;
}
else {
profile.isSelected = NO;
}
}
[profiles enumerateObjectsUsingBlock:^(OSCProfile* profile, NSUInteger idx, BOOL *stop) {
profile.isSelected = idx == tableIndex;
}];
NSMutableArray *profilesEncoded = [self encodedProfilesFromArray:profiles]; // encode each 'profile' object in the array and add them to a new array
/* Encode the array itself, NOT the objects inside the array, which have already been encoded by this point */
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:profilesEncoded requiringSecureCoding:YES error:nil];
[[NSUserDefaults standardUserDefaults] setObject:data forKey:@"OSCProfiles"];
@@ -254,55 +333,45 @@ static CGRect layoutViewBounds;
}
- (bool) updateSelectedProfile:(NSMutableArray *) oscButtonLayers {
- (bool) updateSelectedProfile:(NSMutableSet *) oscButtonLayers {
NSMutableArray* buttonStatesEncoded = [self convertOnScreenControllerAndWidgetsToButtonStates:oscButtonLayers];
if([self getIndexOfSelectedProfile]==0) return false;
/*
OSCProfile *newProfile = [[OSCProfile alloc] initWithName:[self getSelectedProfile].name
buttonStates:buttonStatesEncoded isSelected:YES]; // create a new 'OSCProfile'. Set the array of encoded button states created above to the 'buttonStates' property of the new profile, along with a 'name'. Set 'isSelected' argument to YES which will set this saved profile as the one that will show up in the game stream view
/* set all saved OSCProfiles 'isSelected' property to NO since the new profile you're adding will be set as the selected profile */
NSMutableArray *profiles = [self getAllProfiles];
for (OSCProfile *profile in profiles) {
profile.isSelected = NO;
}
[self replaceProfile:[self getSelectedProfile] withProfile:newProfile];
*/
OSCProfile *selectedProfile = [self getSelectedProfile];
selectedProfile.buttonStatesEncoded = buttonStatesEncoded;
selectedProfile.unfoldedExclusiveFolderSequence = OnScreenWidgetView.unfoldedExclusiveFolderSequence;
selectedProfile.postExclusiveUnfoldedSequences = OnScreenWidgetView.postExclusiveUnfoldedSequences;
[self replaceSelectedProfileWith:selectedProfile overwriteDefault:NO];
return true;
}
- (void) saveProfileWithName:(NSString*)name andButtonLayers:(NSMutableArray *)oscButtonLayers {
NSMutableArray* buttonStatesEncoded = [self convertOnScreenControllerAndWidgetsToButtonStates:oscButtonLayers];
OSCProfile *newProfile = [[OSCProfile alloc] initWithName:name
buttonStates:buttonStatesEncoded isSelected:YES]; // create a new 'OSCProfile'. Set the array of encoded button states created above to the 'buttonStates' property of the new profile, along with a 'name'. Set 'isSelected' argument to YES which will set this saved profile as the one that will show up in the game stream view
/* set all saved OSCProfiles 'isSelected' property to NO since the new profile you're adding will be set as the selected profile */
NSMutableArray *profiles = [self getAllProfiles];
for (OSCProfile *profile in profiles) {
profile.isSelected = NO;
}
if ([self profileNameAlreadyExist:name]) { // if a saved profile with the same 'name' already exists in persistent storage then overwrite it and save the change to persistent storage
[self replaceProfile:[self OSCProfileWithName:name] withProfile:newProfile];
}
- (void) duplicateSelectedProfileWithName:(NSString*)name {
if ([self profileNameAlreadyExist:name]) return;
else { // otherwise encode then add the new profile to the end of the OSCProfiles array
NSData *newProfileEncoded = [NSKeyedArchiver archivedDataWithRootObject:newProfile requiringSecureCoding:YES error:nil];
NSMutableArray *profilesEncoded = [self encodedProfilesFromArray:profiles];
[profilesEncoded addObject:newProfileEncoded];
/* Encode the 'profilesEncoded' array itself, NOT the objects in the 'profilesEncoded' array, all of which are already encoded by this point */
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:profilesEncoded requiringSecureCoding:YES error:nil];
[[NSUserDefaults standardUserDefaults] setObject:data forKey:@"OSCProfiles"];
[[NSUserDefaults standardUserDefaults] synchronize];
// saving test:
/*
NSString *documentsPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
NSString *path = [documentsPath stringByAppendingPathComponent:@"newDefault.bin"];
[profiles writeToFile:path atomically:YES];
NSLog(@"默认 Profile 数据已保存到: %@", path);
*/
NSMutableArray *profiles = [self getAllProfiles];
OSCProfile* newProfile = nil;
for(OSCProfile* profile in profiles){
if(profile.isSelected){
profile.isSelected = false;
newProfile = [profile mutableCopy];
newProfile.isSelected = true;
newProfile.name = name;
}
}
if(newProfile){
NSData *newProfileEncoded = [NSKeyedArchiver archivedDataWithRootObject:newProfile requiringSecureCoding:YES error:nil];
NSMutableArray *profilesEncoded = [self encodedProfilesFromArray:profiles];
[profilesEncoded addObject:newProfileEncoded];
/* Encode the 'profilesEncoded' array itself, NOT the objects in the 'profilesEncoded' array, all of which are already encoded by this point */
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:profilesEncoded requiringSecureCoding:YES error:nil];
[[NSUserDefaults standardUserDefaults] setObject:data forKey:@"OSCProfiles"];
[[NSUserDefaults standardUserDefaults] synchronize];
}
}
}
@@ -312,8 +381,7 @@ static CGRect layoutViewBounds;
position.x = position.x / layoutViewBounds.size.width;
position.y = position.y / layoutViewBounds.size.height;
}
// asdfsda;
NSLog(@"sef.view bounds: %f, %f", layoutViewBounds.size.width, layoutViewBounds.size.height);
NSLog(@"layoutToolView bounds: %f, %f", layoutViewBounds.size.width, layoutViewBounds.size.height);
NSLog(@"position: %f, %f, denormalized position: %f, %f", position.x, position.y, newPosition.x, newPosition.y);
return position;
}
@@ -326,30 +394,32 @@ static CGRect layoutViewBounds;
return position;
}
- (NSMutableArray* ) convertOnScreenControllerAndWidgetsToButtonStates:(NSMutableArray *) oscButtonLayers {
- (NSMutableArray* ) convertOnScreenControllerAndWidgetsToButtonStates:(NSMutableSet *) oscButtonLayers {
/* iterate through each OSC button the user sees on screen, create an 'OnScreenButtonState' object from each button, encode each object, and then add each object to an array */
/*
NSSet *validPositionButtonNames = [NSSet setWithObjects:
@"l2Button",
@"l1Button",
@"dPad",
@"selectButton",
@"leftStickBackground",
@"rightStickBackground",
@"r2Button",
@"r1Button",
@"aButton",
@"bButton",
@"xButton",
@"yButton",
@"startButton",
nil]; */
NSSet *validPositionButtonNames = [NSSet setWithObjects:
@"l2Button",
@"l1Button",
@"dPad",
@"selectButton",
@"leftStickBackground",
@"rightStickBackground",
@"r2Button",
@"r1Button",
@"aButton",
@"bButton",
@"xButton",
@"yButton",
@"startButton",
nil]; */
NSMutableArray *buttonStatesEncoded = [[NSMutableArray alloc] init];
// save on-screen game controller buttons & sticks as buttonstate:
for (CALayer *oscButtonLayer in oscButtonLayers) {
if(oscButtonLayer.isHidden) continue;
CGPoint normalizedPosition = [self normalizeWidgetPosition:oscButtonLayer.position];
OnScreenButtonState *buttonState = [[OnScreenButtonState alloc] initWithButtonName:oscButtonLayer.name buttonType:LegacyOscButton andPosition:normalizedPosition];
OnScreenButtonState *buttonState = [[OnScreenButtonState alloc] initWithButtonName:oscButtonLayer.name buttonType:LegacyOnScreenControls andPosition:normalizedPosition];
// add hidden attr here
buttonState.isHidden = oscButtonLayer.isHidden;
buttonState.oscLayerSizeFactor = [OnScreenControls getControllerLayerSizeFactor:oscButtonLayer];
@@ -370,40 +440,71 @@ static CGRect layoutViewBounds;
}
// save on-screen widget views (keyboard & mouse command) as buttonstate:
_widgetSizeTransition = keepWidgetSize;
for(OnScreenWidgetView* widgetView in OnScreenWidgetViews){
CGPoint normalizedPosition = [self normalizeWidgetPosition:widgetView.center];
CGPoint normalizedPosition = [self normalizeWidgetPosition:widgetView.storedCenter];
OnScreenButtonState *buttonState = [[OnScreenButtonState alloc] initWithButtonName:widgetView.cmdString buttonType:CustomOnScreenWidget andPosition:normalizedPosition];
buttonState.alias = widgetView.buttonLabel;
buttonState.widthFactor = [self normalizeSizeWidthFactor:widgetView];
NSLog(@"logging widthFactor %f", buttonState.widthFactor);
buttonState.heightFactor = [self normalizeSizeHeightFactor:widgetView];
buttonState.alias = widgetView.widgetLabel;
buttonState.sequence = widgetView.sequence;
buttonState.sequenceSet = widgetView.sequenceSet;
buttonState.parentSequence = widgetView.parentSequence;
buttonState.folded = widgetView.folded;
buttonState.revealMode = widgetView.revealMode;
buttonState.widthFactor = [self normalizeSizeWidthFactorWith:widgetView];
buttonState.heightFactor = [self normalizeSizeHeightFactorWith:widgetView];
buttonState.componentSizeFactor = [self normalizeComponentSizeFactorWith:widgetView];
buttonState.backgroundAlpha = widgetView.backgroundAlpha;
buttonState.labelAlpha = widgetView.labelAlpha;
buttonState.borderAlpha = widgetView.borderAlpha;
buttonState.highlightAlpha = widgetView.highlightAlpha;
buttonState.borderWidth = widgetView.borderWidth;
buttonState.highlightSizeFactor = widgetView.highlightSizeFactor;
buttonState.autoTapInterval = widgetView.autoTapInterval;
buttonState.vibrationStyle = widgetView.vibrationStyle;
buttonState.mouseButtonAction = widgetView.mouseButtonAction;
buttonState.sensitivityFactorX = widgetView.sensitivityFactorX;
buttonState.sensitivityFactorY = widgetView.sensitivityFactorY;
buttonState.decelerationRate = widgetView.trackballDecelerationRate;
buttonState.slideThreshold = widgetView.slideThreshold;
buttonState.yawFactor = widgetView.yawFactor;
buttonState.pitchFactor = widgetView.pitchFactor;
buttonState.rollFactor = widgetView.rollFactor;
buttonState.decelerationRateX = widgetView.decelerationRateX;
buttonState.decelerationRateY = widgetView.decelerationRateY;
buttonState.stickIndicatorOffset = widgetView.stickIndicatorOffset;
buttonState.widgetShape = widgetView.shape;
buttonState.walkModeThreshold = widgetView.dWheelWalkModeThreshold;
buttonState.minStickOffset = widgetView.minStickOffset;
buttonState.slideMode = widgetView.slideMode;
buttonState.buttonMode = widgetView.buttonMode;
NSData *buttonStateEncoded = [NSKeyedArchiver archivedDataWithRootObject:buttonState requiringSecureCoding:YES error:nil];
[buttonStatesEncoded addObject: buttonStateEncoded];
}
return buttonStatesEncoded;
}
- (CGFloat)normalizeSizeWidthFactor:(OnScreenWidgetView* )widget{
- (CGFloat)getReferenceLen{
CGFloat screenWidthInPoints = CGRectGetWidth([[UIScreen mainScreen] bounds]);
return widget.bounds.size.width/screenWidthInPoints * 10000;
CGFloat screenHeightInPoints = CGRectGetHeight([[UIScreen mainScreen] bounds]);
CGFloat longSideLen = fmax(screenWidthInPoints,screenHeightInPoints);
CGFloat referenceLen = longSideLen;
if(_widgetSizeTransition == keepWidgetSize) referenceLen = longSideLen;
if(_widgetSizeTransition == transitionWithOrientation) referenceLen = screenWidthInPoints;
return referenceLen;
}
- (CGFloat)normalizeSizeHeightFactor:(OnScreenWidgetView* )widget{
CGFloat screenWidthInPoints = CGRectGetWidth([[UIScreen mainScreen] bounds]);
return widget.bounds.size.height/screenWidthInPoints * 10000;
- (CGFloat)normalizeSizeWidthFactorWith:(OnScreenWidgetView* )widget{
return widget.bounds.size.width/[self getReferenceLen] * 10000;
}
- (CGFloat)normalizeSizeHeightFactorWith:(OnScreenWidgetView* )widget{
return widget.bounds.size.height/[self getReferenceLen] * 10000;
}
- (CGFloat)normalizeComponentSizeFactorWith:(OnScreenWidgetView* )widget{
if(widget.isStickWheel) {
return widget.denormalizedComponentSizeFactor*widget.baselineDiameter/[self getReferenceLen] * 10000;
}
return 1;
}
@@ -432,7 +533,7 @@ static CGRect layoutViewBounds;
- (OnScreenButtonState *)unarchiveButtonStateEncoded:(NSData *)data {
OnScreenButtonState* buttonState;
buttonState = [NSKeyedUnarchiver unarchivedObjectOfClasses:[NSSet setWithObjects:[NSString class], [OnScreenButtonState class], nil]
buttonState = [NSKeyedUnarchiver unarchivedObjectOfClasses:[NSSet setWithObjects:[NSString class], [OnScreenButtonState class], [NSSet class], [NSNumber class], nil]
fromData:data
error:nil];
return buttonState;
+42 -12
View File
@@ -24,46 +24,76 @@ NS_ASSUME_NONNULL_BEGIN
@property NSString *name;
@property NSString *alias;
@property (nonatomic, assign) BOOL folded;
@property (nonatomic, assign) uint8_t revealMode;
@property (nonatomic, assign) int16_t sequence;
@property (nonatomic, assign) int16_t parentSequence;
@property NSSet *sequenceSet;
@property CGPoint position;
@property (nonatomic, assign) BOOL isHidden;
@property (nonatomic, assign) uint8_t slideMode;
@property (nonatomic, assign) uint8_t buttonType;
@property (nonatomic, assign) uint8_t buttonMode;
@property (nonatomic, assign) uint8_t widgetType;
@property (nonatomic, assign) uint8_t sizeReference;
@property (nonatomic, assign) CGFloat widthFactor; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat heightFactor; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat componentSizeFactor; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat borderWidth; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat highlightSizeFactor; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat sensitivityFactorX; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat sensitivityFactorY; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat decelerationRate; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat slideThreshold; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat yawFactor; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat pitchFactor; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat rollFactor; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat decelerationRateX; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat decelerationRateY; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat stickIndicatorOffset; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat walkModeThreshold; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat minStickOffset; // for OnScreenWidgetView
@property NSString* widgetShape; // for OnScreenWidgetView
@property (nonatomic, assign) CGFloat oscLayerSizeFactor; // for OnScreenController CALayer
@property (nonatomic, assign) CGFloat backgroundAlpha; // for OnScreenController CALayer
@property (nonatomic, assign) uint8_t vibrationStyle; // for OnScreenController CALayer
@property (nonatomic, assign) uint8_t mouseButtonAction; // for OnScreenController CALayer
@property (nonatomic, assign) CGFloat backgroundAlpha;
@property (nonatomic, assign) CGFloat labelAlpha;
@property (nonatomic, assign) CGFloat borderAlpha;
@property (nonatomic, assign) CGFloat highlightAlpha;
@property (nonatomic, assign) uint8_t vibrationStyle;
@property (nonatomic, assign) uint8_t mouseButtonAction;
@property (nonatomic, assign) uint16_t autoTapInterval;
// @property (nonatomic, assign) BOOL hasValidPosition;
typedef NS_ENUM(NSInteger, OnScreenButtonType) {
LegacyOscButton,
typedef NS_ENUM(NSInteger, OnScreenWidgetType) {
LegacyOnScreenControls,
CustomOnScreenWidget
};
typedef NS_ENUM(NSInteger, WidgetSizeReference) {
longSide,
shortSide
};
typedef NS_ENUM(NSInteger, MouseButtonAction) {
hovering,
leftButtonDown,
middleButtonDown,
rightButtonDown,
noClick
};
typedef NS_ENUM(NSInteger, ButtonSlideMode) {
toggle,
typedef NS_ENUM(NSInteger, ButtonMode) {
slideToToggle,
slideAndHold,
disabled
regular,
tapToToggle,
movable
};
typedef NS_ENUM(NSInteger, RevealMode) {
coexist,
exclusive
};
- (id) initWithButtonName:(NSString*)name buttonType:(uint8_t)buttonType andPosition:(CGPoint)position;
+48 -13
View File
@@ -10,6 +10,7 @@
//
#import "OnScreenButtonState.h"
#import "VoidLink-Swift.h"
@implementation OnScreenButtonState
@@ -19,7 +20,7 @@
self.name = name;
//self.isHidden = isHidden;
self.position = position;
self.buttonType = buttonType;
self.widgetType = buttonType;
}
return self;
@@ -31,48 +32,82 @@
- (void) encodeWithCoder:(NSCoder*)encoder {
[encoder encodeObject:self.name forKey:@"name"];
[encoder encodeBool:self.folded forKey:@"folded"];
[encoder encodeInt32:self.revealMode forKey:@"revealMode"];
[encoder encodeInt32:self.sequence forKey:@"sequence"];
[encoder encodeInt32:self.parentSequence forKey:@"parentSequence"];
[encoder encodeObject:self.sequenceSet forKey:@"sequenceSet"];
[encoder encodeObject:self.alias forKey:@"alias"];
[encoder encodeInt:self.buttonType forKey:@"buttonType"];
[encoder encodeInt:self.widgetType forKey:@"buttonType"]; // keep original key
[encoder encodeInt:self.sizeReference forKey:@"sizeReference"];
[encoder encodeInt:self.vibrationStyle forKey:@"vibrationStyle"];
[encoder encodeInt:self.mouseButtonAction forKey:@"mouseButtonAction"];
[encoder encodeInt:self.slideMode forKey:@"slideMode"];
[encoder encodeInt:self.buttonMode forKey:@"slideMode"]; // buttonMode: previously slideMode, keep it for consistency
[encoder encodeInt:self.autoTapInterval forKey:@"autoTapInterval"];
[encoder encodeCGPoint:self.position forKey:@"position"];
[encoder encodeBool:self.isHidden forKey:@"isHidden"];
[encoder encodeFloat:self.widthFactor forKey:@"widthFactor"];
[encoder encodeFloat:self.heightFactor forKey:@"heightFactor"];
[encoder encodeFloat:self.componentSizeFactor forKey:@"componentSizeFactor"];
[encoder encodeFloat:self.sensitivityFactorX forKey:@"sensitivityFactorX"];
[encoder encodeFloat:self.sensitivityFactorY forKey:@"sensitivityFactorY"];
[encoder encodeFloat:self.decelerationRate forKey:@"decelerationRate"];
[encoder encodeFloat:self.slideThreshold forKey:@"slideThreshold"];
[encoder encodeFloat:self.yawFactor forKey:@"yawFactor"];
[encoder encodeFloat:self.pitchFactor forKey:@"pitchFactor"];
[encoder encodeFloat:self.rollFactor forKey:@"rollFactor"];
[encoder encodeFloat:self.decelerationRateX forKey:@"decelerationRateX"];
[encoder encodeFloat:self.decelerationRateY forKey:@"decelerationRateY"];
[encoder encodeFloat:self.stickIndicatorOffset forKey:@"stickIndicatorOffset"];
[encoder encodeFloat:self.oscLayerSizeFactor forKey:@"oscLayerSizeFactor"];
[encoder encodeFloat:self.backgroundAlpha forKey:@"backgroundAlpha"];
[encoder encodeFloat:self.labelAlpha forKey:@"labelAlpha"];
[encoder encodeFloat:self.borderAlpha forKey:@"borderAlpha"];
[encoder encodeFloat:self.highlightAlpha forKey:@"highlightAlpha"];
[encoder encodeFloat:self.borderWidth forKey:@"borderWidth"];
[encoder encodeFloat:self.highlightSizeFactor forKey:@"highlightSizeFactor"];
[encoder encodeObject:self.widgetShape forKey:@"widgetShape"];
[encoder encodeFloat:self.walkModeThreshold forKey:@"walkModeThreshold"];
[encoder encodeFloat:self.minStickOffset forKey:@"minStickOffset"];
}
- (id) initWithCoder:(NSCoder*)decoder {
if (self = [super init]) {
self.name = [decoder decodeObjectForKey:@"name"];
self.folded = [decoder containsValueForKey:@"folded"] ? [decoder decodeBoolForKey:@"folded"] : false;
self.revealMode = [decoder containsValueForKey:@"revealMode"] ? [decoder decodeInt32ForKey:@"revealMode"] : coexist;
self.sequence = [decoder containsValueForKey:@"sequence"] ? [decoder decodeInt32ForKey:@"sequence"] : -1;
self.parentSequence = [decoder containsValueForKey:@"parentSequence"] ? [decoder decodeInt32ForKey:@"parentSequence"] : -1;
self.sequenceSet = [decoder containsValueForKey:@"sequenceSet"] ? [decoder decodeObjectForKey:@"sequenceSet"] : [NSSet set];
self.alias = [decoder decodeObjectForKey:@"alias"];
self.buttonType = [decoder decodeIntForKey:@"buttonType"];
self.vibrationStyle = [decoder decodeIntForKey:@"vibrationStyle"];
self.widgetType = [decoder decodeIntForKey:@"buttonType"];
self.sizeReference = [decoder containsValueForKey:@"sizeReference"] ? [decoder decodeIntForKey:@"sizeReference"] : longSide;
self.vibrationStyle = [decoder containsValueForKey:@"vibrationStyle"] ? [decoder decodeIntForKey:@"vibrationStyle"] : UIImpactFeedbackStyleLight;
self.mouseButtonAction = [decoder decodeIntForKey:@"mouseButtonAction"];
self.slideMode = [decoder containsValueForKey:@"slideMode"] ? [decoder decodeIntForKey:@"slideMode"] : 0;
self.buttonMode = [decoder containsValueForKey:@"slideMode"] ? [decoder decodeIntForKey:@"slideMode"] : 0;
self.autoTapInterval = [decoder containsValueForKey:@"autoTapInterval"] ? [decoder decodeIntForKey:@"autoTapInterval"] : 45;
self.position = [decoder decodeCGPointForKey:@"position"];
self.isHidden = [decoder decodeBoolForKey:@"isHidden"];
self.widthFactor = [decoder decodeFloatForKey:@"widthFactor"];
self.heightFactor = [decoder decodeFloatForKey:@"heightFactor"];
self.sensitivityFactorX = [decoder decodeFloatForKey:@"sensitivityFactorX"];
self.sensitivityFactorX = self.sensitivityFactorX == 0 ? 1.0 : self.sensitivityFactorX;
self.sensitivityFactorY = [decoder decodeFloatForKey:@"sensitivityFactorY"];
self.sensitivityFactorY = self.sensitivityFactorY == 0 ? 1.0 : self.sensitivityFactorY;
self.decelerationRate = [decoder decodeFloatForKey:@"decelerationRate"];
self.componentSizeFactor = [decoder containsValueForKey:@"componentSizeFactor"] ? [decoder decodeFloatForKey:@"componentSizeFactor"] : 1.0;
self.sensitivityFactorX = [decoder containsValueForKey:@"sensitivityFactorX"] ? [decoder decodeFloatForKey:@"sensitivityFactorX"] : 1.0;
self.sensitivityFactorY = [decoder containsValueForKey:@"sensitivityFactorY"] ? [decoder decodeFloatForKey:@"sensitivityFactorY"] : 1.0;
self.slideThreshold = [decoder containsValueForKey:@"slideThreshold"] ? [decoder decodeFloatForKey:@"slideThreshold"] : 6.0;
self.yawFactor = [decoder containsValueForKey:@"yawFactor"] ? [decoder decodeFloatForKey:@"yawFactor"] : 1.0;
self.pitchFactor = [decoder containsValueForKey:@"pitchFactor"] ? [decoder decodeFloatForKey:@"pitchFactor"] : 1.0;
self.rollFactor = [decoder containsValueForKey:@"rollFactor"] ? [decoder decodeFloatForKey:@"rollFactor"] : 1.0;
self.decelerationRateX = [decoder containsValueForKey:@"decelerationRateX"] ? [decoder decodeFloatForKey:@"decelerationRateX"] : 0.5;
self.decelerationRateY = [decoder containsValueForKey:@"decelerationRateY"] ? [decoder decodeFloatForKey:@"decelerationRateY"] : 0.5;
self.stickIndicatorOffset = [decoder decodeFloatForKey:@"stickIndicatorOffset"];
self.oscLayerSizeFactor = [decoder decodeFloatForKey:@"oscLayerSizeFactor"];
self.backgroundAlpha = [decoder decodeFloatForKey:@"backgroundAlpha"];
self.backgroundAlpha = [decoder containsValueForKey:@"backgroundAlpha"] ? [decoder decodeFloatForKey:@"backgroundAlpha"] : 0.5;
self.labelAlpha = [decoder containsValueForKey:@"labelAlpha"] ? [decoder decodeFloatForKey:@"labelAlpha"] : 0.82;
self.borderAlpha = [decoder containsValueForKey:@"borderAlpha"] ? [decoder decodeFloatForKey:@"borderAlpha"] : 0.19;
self.highlightAlpha = [decoder containsValueForKey:@"highlightAlpha"] ? [decoder decodeFloatForKey:@"highlightAlpha"] : 0.77;
self.borderWidth = [decoder decodeFloatForKey:@"borderWidth"];
self.highlightSizeFactor = [decoder containsValueForKey:@"highlightSizeFactor"] ? [decoder decodeFloatForKey:@"highlightSizeFactor"] : 1.0;
self.widgetShape = [decoder decodeObjectForKey:@"widgetShape"];
self.walkModeThreshold = [decoder containsValueForKey:@"walkModeThreshold"] ? [decoder decodeFloatForKey:@"walkModeThreshold"] : 16383;
self.minStickOffset = [decoder decodeFloatForKey:@"minStickOffset"];
}
return self;
+1 -1
View File
@@ -14,7 +14,7 @@
CGFloat lowestTouchPointYCoord;
}
@property (nonatomic, weak) UIView* touchCapturingView;
@property (nonatomic, assign) uint8_t numberOfTouchesRequired;
@property (nonatomic, assign) bool immediateTriggering; // if enabled, trigger the signal on touchesBegan stage.
@property (nonatomic, assign) double tapDownTimeThreshold; // tap down threshold in seconds.
+29 -35
View File
@@ -39,13 +39,21 @@ static CGFloat screenWidthInPoints;
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// Check if the number of touches and taps meets the required criteria
if ([[event allTouches] count] == _numberOfTouchesRequired) {
_gestureCapturedTime = CACurrentMediaTime();
_gestureCaptured = true;
for(UITouch *touch in [event allTouches]){
if(lowestTouchPointYCoord < [touch locationInView:self.view].y) lowestTouchPointYCoord = [touch locationInView:self.view].y;
NSSet* allTouches = [event allTouches];
if (allTouches.count == _numberOfTouchesRequired) {
if([UITouchUtil touchesIn:_touchCapturingView from:event].count != allTouches.count){
_gestureCaptured = false;
self.state = UIGestureRecognizerStateFailed;
return;
}
_gestureCapturedTime = CACurrentMediaTime();
_gestureCaptured = true;
lowestTouchPointYCoord = 0;
for(UITouch *touch in allTouches){
if(lowestTouchPointYCoord < [touch locationInView:self.view].y) lowestTouchPointYCoord = [touch locationInView:self.view].y;
// NSLog(@"lowest point coord: %f, %f", lowestTouchPointYCoord, CACurrentMediaTime());
}
// this mechanism is deprecated.
/* if(_numberOfTouchesRequired == 2){
NSArray *twoTouches = [[event allTouches] allObjects];
@@ -55,13 +63,13 @@ static CGFloat screenWidthInPoints;
_lowestTouchPointHeight = screenHeightInPoints - lowestTouchPointYCoord;
// NSLog(@"gen _lowestTouchPointHeight %f markmark", _lowestTouchPointHeight);
if(_immediateTriggering){
lowestTouchPointYCoord = 0.0; //reset for next recoginition
// lowestTouchPointYCoord = 0.0; //reset for next recoginition
self.state = UIGestureRecognizerStateRecognized;
return;
}
self.state = UIGestureRecognizerStatePossible;
}
if ([[event allTouches] count] > _numberOfTouchesRequired) {
if (allTouches.count > _numberOfTouchesRequired) {
_gestureCaptured = false;
self.state = UIGestureRecognizerStateFailed;
}
@@ -76,40 +84,26 @@ static CGFloat screenWidthInPoints;
_gestureCaptured = false;
self.state = UIGestureRecognizerStateFailed;
}
else if(_gestureCaptured && allTouchesCount == [touches count] && !_isOnScreenControllerBeingPressed && ![self isOnScreenWidgetViewBeingPressed]){ //must exclude virtual controller & onscreen button taps here to prevent stucked button, _areVirtualControllerTaps flag is set by onscreencontrols, containOnScreenButtonsTaps will be returned by iterating all widget views in streamframeview
else if(_gestureCaptured
&& allTouchesCount == [touches count]
&& !_isOnScreenControllerBeingPressed){ //must exclude virtual controller & onscreen button taps here to prevent stucked button, _areVirtualControllerTaps flag is set by onscreencontrols, containOnScreenButtonsTaps will be returned by iterating all widget views in streamframeview
_gestureCaptured = false; //reset for next recognition
if((CACurrentMediaTime() - _gestureCapturedTime) < _tapDownTimeThreshold){
lowestTouchPointYCoord = 0.0; //reset for next recognition
self.state = UIGestureRecognizerStateRecognized;
// lowestTouchPointYCoord = 0.0; //reset for next recognition
for(UITouch* touch in touches){
CGVector vector = [UITouchUtil vectorOf:touch in:self.view];
if(hypot(vector.dx, vector.dy)>2){
self.state = UIGestureRecognizerStateFailed;
break;
}
}
if(self.state != UIGestureRecognizerStateFailed) self.state = UIGestureRecognizerStateRecognized;
// NSLog(@"gen _lowestTouchPointHeight %f markmark touchesEnd", _lowestTouchPointHeight);
}
}
if (allTouchesCount == [touches count]) _isOnScreenControllerBeingPressed = false; // need to reset this flag anyway, when all fingers are lefting
}
// it was a not a perfect choice to code OnScreenWidgetView in Swift...
// we're unable to import this class to swift codebase by the bridging header,and have to exlucde the onscreen button taps here
// by iterating every widget view instances. but it's basically ok since the number of widgetViews are always limited.
// and this method will only be called when the recognizer is active & and the taps passes all the checks and is about to ge triggered.
- (bool)isOnScreenWidgetViewBeingPressed {
NSTimeInterval allFingersTapDownTime = CACurrentMediaTime() - _gestureCapturedTime; //RIGHTCLICK_TAP_DOWN_TIME_THRESHOLD_S has been passed to this recognizer as _tapDownTimeShreshold, we'll decide how to deal with pressed flag of on-screen widget views based on tapDownTime
bool gotOneWidgetPressed = false;
// the tap gestures are all added to the streamFrameTopLayerView, where all the OnScreenWidgetViews are added. so we can iterate them in this way:
for(UIView* view in self.view.subviews){
if ([view isKindOfClass:[OnScreenWidgetView class]]) {
OnScreenWidgetView* widgetView = (OnScreenWidgetView*) view;
if(gotOneWidgetPressed && allFingersTapDownTime <= _tapDownTimeThreshold){ // once we have just 1 button pressed already,& the tapDownTime has not exceeded the threshold, we'll reset pressed flag for the all the widgetViews.
widgetView.pressed = false; //reset the flag for widgetView
continue;
}
if(widgetView.pressed){
gotOneWidgetPressed = true; //got one button pressed
if(allFingersTapDownTime <= _tapDownTimeThreshold) widgetView.pressed = false; // reset the flag for current widgetView if tapDowntime is still within the threshold, if not, leave the flag for mouse scroller gesture in relative touch mode.
}
}
}
return gotOneWidgetPressed;
}
@end
+77 -10
View File
@@ -5,8 +5,12 @@
// Created by Cameron Gutman on 9/17/20.
// Copyright © 2020 Moonlight Game Streaming Project. All rights reserved.
//
// Modified by True砖家 on 2025.10.12
// Copyright © 2025 True砖家 @ Bilibili. All rights reserved.
//
#import "HapticContext.h"
#import "DataManager.h"
@import CoreHaptics;
@import GameController;
@@ -83,19 +87,74 @@
}
}
-(id) initWithGamepad:(GCController*)gamepad locality:(GCHapticsLocality)locality API_AVAILABLE(ios(14.0), tvos(14.0)) {
if (gamepad.haptics == nil) {
Log(LOG_W, @"Controller %d does not support haptics", gamepad.playerIndex);
-(id) initDeviceEngineContextWithGamepad:(GCController*)gamepad API_AVAILABLE(ios(13.0), tvos(13.0)) {
NSError *error = nil;
_hapticEngine = [[CHHapticEngine alloc] initAndReturnError:&error];
if (error != nil) {
Log(LOG_W, @"Controller %d: iPhone Haptic engine failed to start: %@", gamepad.playerIndex, error);
return nil;
}
_playerIndex = gamepad.playerIndex;
if (![[gamepad.haptics supportedLocalities] containsObject:locality]) {
Log(LOG_W, @"Controller %d does not support haptic locality: %@", gamepad.playerIndex, locality);
[_hapticEngine startAndReturnError:&error];
if (error != nil) {
Log(LOG_W, @"Controller %d: Haptic engine failed to start: %@", gamepad.playerIndex, error);
return nil;
}
__weak typeof(self) weakSelf = self;
_hapticEngine.stoppedHandler = ^(CHHapticEngineStoppedReason stoppedReason) {
HapticContext* me = weakSelf;
if (me == nil) {
return;
}
Log(LOG_W, @"Controller %d: Haptic engine stopped: %p", me->_playerIndex, stoppedReason);
me->_hapticPlayer = nil;
me->_hapticEngine = nil;
me->_playing = NO;
};
_hapticEngine.resetHandler = ^{
HapticContext* me = weakSelf;
if (me == nil) {
return;
}
Log(LOG_W, @"Controller %d: Haptic engine reset", me->_playerIndex);
me->_hapticPlayer = nil;
me->_playing = NO;
[me->_hapticEngine startAndReturnError:nil];
};
return self;
}
-(id) initWithGamepad:(GCController*)gamepad locality:(GCHapticsLocality)locality API_AVAILABLE(ios(14.0), tvos(14.0)) {
bool fallBackToPhoneHaptics = false;
if (gamepad.haptics == nil) {
Log(LOG_W, @"Controller %d does not support haptics", gamepad.playerIndex);
fallBackToPhoneHaptics = true;
}
else if (![[gamepad.haptics supportedLocalities] containsObject:locality]) {
Log(LOG_W, @"Controller %d does not support haptic locality: %@", gamepad.playerIndex, locality);
fallBackToPhoneHaptics = true;
}
if (fallBackToPhoneHaptics) {
Log(LOG_W, @"Controller %d falls back to use iPhone Haptics for locality: %@", gamepad.playerIndex, locality);
NSError *error = nil;
_hapticEngine = [[CHHapticEngine alloc] initAndReturnError:&error];
if (error != nil) {
Log(LOG_W, @"Controller %d: iPhone Haptic engine failed to start: %@", gamepad.playerIndex, error);
return nil;
}
}
else {
_hapticEngine = [gamepad.haptics createEngineWithLocality:locality];
}
_playerIndex = gamepad.playerIndex;
_hapticEngine = [gamepad.haptics createEngineWithLocality:locality];
NSError* error;
[_hapticEngine startAndReturnError:&error];
@@ -136,7 +195,9 @@
return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityRightHandle];
}
else {
return nil;
if (@available(iOS 13.0, *)) {
return [[HapticContext alloc] initDeviceEngineContextWithGamepad:gamepad];
} else return nil;
}
}
@@ -145,7 +206,9 @@
return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityLeftHandle];
}
else {
return nil;
if (@available(iOS 13.0, *)) {
return [[HapticContext alloc] initDeviceEngineContextWithGamepad:gamepad];
} else return nil;
}
}
@@ -154,7 +217,9 @@
return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityLeftTrigger];
}
else {
return nil;
if (@available(iOS 13.0, *)) {
return [[HapticContext alloc] initDeviceEngineContextWithGamepad:gamepad];
} else return nil;
}
}
@@ -163,7 +228,9 @@
return [[HapticContext alloc] initWithGamepad:gamepad locality:GCHapticsLocalityRightTrigger];
}
else {
return nil;
if (@available(iOS 13.0, *)) {
return [[HapticContext alloc] initDeviceEngineContextWithGamepad:gamepad];
} else return nil;
}
}
+36
View File
@@ -0,0 +1,36 @@
//
// InertialScroller.swift
// VoidLink
//
// Created by True砖家 on 2025/12/5.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
class InertialScroller {
public var decelerationRateX: CGFloat
public var decelerationRateY: CGFloat
public lazy var timer: SafeTimer? = {
SafeTimer(interval: 1/displayLinkRate) { [weak self] in
guard let self = self, handler != nil else {return}
self.vector.dx = self.vector.dx * decelerationRateX
self.vector.dy = self.vector.dy * decelerationRateY
if abs(self.vector.dx) < self.timerSuspendThreshold && abs(self.vector.dy) < self.timerSuspendThreshold {
self.timer?.pause()
}
(self.handler ?? {})()
}
}()
public var vector: CGVector = .zero
public var displayLinkRate: CGFloat
public var timerSuspendThreshold: CGFloat = 0.06
public var handler: (() -> Void)?
init(decelerationRate: CGFloat = 0.93, displayLinkRate: CGFloat = 60, handler: (() -> Void)? = nil) {
self.decelerationRateX = decelerationRate
self.decelerationRateY = decelerationRate
self.displayLinkRate = displayLinkRate
self.handler = handler
}
}
+545
View File
@@ -0,0 +1,545 @@
//
// MicHandler.swift
// VoidLink
//
// Created by True砖家 on 2025/9/2.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
import AVFoundation
import Collections
@objc public protocol MicHandlerDelegate: AnyObject {
@objc optional func micHandlerDidFinishPlayback(_ handler: MicHandler)
@objc optional func micHandler(_ handler: MicHandler, didFailWithError error: NSError)
}
@objcMembers
public class MicHandler: NSObject {
private var notificationTokens = [NSObjectProtocol]()
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private var audioSink: Any?
private var micInputFormat: AVAudioFormat!
private var isRecording = false
private var useBuiltinMic = false
private var pcm16BufferDeque = Deque<Int16>()
private var pcm16BufferArray: [Int16] = []
private let bufferQueue = DispatchQueue(label: "pcm.buffer.queue")
private var timer: SafeTimer?
private static var volume: Float = 1.0
/*
private var recordedBuffers: [AVAudioPCMBuffer] = []
private var recordedPCM16: [Data] = []
private var recordedOpusPackets: [Data] = []
*/
private var opusEncoder: OpaquePointer?
private var opusDecoder: OpaquePointer?
private var sequenceNumber: UInt16 = 0
private let ssrc: UInt32 = 0x12345678
private var globalTimestamp: TimeInterval = 0
public weak var delegate: MicHandlerDelegate?
@objc public init(useBuiltinMic:Bool) {
super.init()
let token = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.handleInterruption(notification)
}
notificationTokens.append(token)
self.useBuiltinMic = useBuiltinMic
do {
try configureSession()
try configureEngine()
} catch {
notify(error)
}
}
/* ----------- Mic permission -------------*/
/// 请求麦克风权限
/// - Parameter completion: 可选 block,如果为 nil 且未授权,会弹窗提示跳转系统设置
@objc static func requestPermission(_ completion: ((Bool) -> Void)? = nil) {
let permission = AVAudioSession.sharedInstance().recordPermission
switch permission {
case .granted:
completion?(true)
case .denied:
if let callback = completion {
callback(false)
} else {
showSettingsAlert()
}
case .undetermined:
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
if granted {
completion?(true)
} else {
if let callback = completion {
callback(false)
} else {
showSettingsAlert()
}
}
}
}
@unknown default:
if let callback = completion {
callback(false)
} else {
showSettingsAlert()
}
}
}
/// 弹窗提示用户跳转系统设置(英文版)
private static func showSettingsAlert() {
guard let topVC = topViewController() else { return }
let alert = UIAlertController(
title: SwiftLocalizationHelper.localizedString(forKey: "Microphone Permission") ,
message: SwiftLocalizationHelper.localizedString(forKey: "micPermissionTip"),
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Cancel"), style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Go to Settings") , style: .default, handler: { _ in
openSettings()
}))
topVC.present(alert, animated: true, completion: nil)
}
/// 打开系统设置
@objc static func openSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString),
UIApplication.shared.canOpenURL(url) else { return }
UIApplication.shared.open(url)
}
/// 检查麦克风权限状态(返回 Int,OC 可用)
@objc static func permissionGranted() -> Bool {
return AVAudioSession.sharedInstance().recordPermission == AVAudioSession.RecordPermission.granted
}
/// 获取最顶层 UIViewController
private static func topViewController(base: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
if let nav = base as? UINavigationController {
return topViewController(base: nav.visibleViewController)
}
if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
return topViewController(base: selected)
}
if let presented = base?.presentedViewController {
return topViewController(base: presented)
}
return base
}
/* ----------------------------------------*/
/* ----------- Audio Session -------------*/
private func handleInterruption(_ notification: Notification) {
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else { return }
switch type {
case .began:
self.stopTapping(stopEngine: true)
case .ended:
do {
try configureEngine()
} catch {
notify(error)
}
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
self.startTapping()
}
@unknown default:
break
}
}
private func configureOpus(sampleRate: Int32, channels: Int) throws {
var err: Int32 = 0
guard let enc = opus_encoder_create(sampleRate, Int32(channels), OPUS_APPLICATION_VOIP, &err), err == OPUS_OK else {
throw NSError(domain: "Opus", code: Int(err), userInfo: nil)
}
opusEncoder = enc
guard let dec = opus_decoder_create(sampleRate, Int32(channels), &err), err == OPUS_OK else {
throw NSError(domain: "Opus", code: Int(err), userInfo: nil)
}
opusDecoder = dec
// Optional: But defaults are fine. Only change when needed:
opus_encoder_ctl_wrapper(enc, Int32(OPUS_SET_BITRATE_REQUEST), opus_int32(64000)) // Set bitrate
opus_encoder_ctl_wrapper(enc, Int32(OPUS_SET_COMPLEXITY_REQUEST), opus_int32(5)) // Set complexity
opus_encoder_ctl_wrapper(enc, Int32(OPUS_SET_SIGNAL_REQUEST), OPUS_SIGNAL_MUSIC) // Set signal type
}
@objc public func startTapping() {
// recordedBuffers.removeAll()
isRecording = true
self.timer?.start()
/*
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
guard let self = self else { return }
self.isRecording = false
self.stop()
self.playbackRecorded()
self.playbackOpus()
}*/
}
@objc public func stopTapping(stopEngine:Bool) {
isRecording = false
self.timer?.pause()
// engine.inputNode.removeTap(onBus: 0)
if(stopEngine){
playerNode.stop()
engine.stop()
}
}
private func configureSession() throws {
let session = AVAudioSession.sharedInstance()
/*
let bluetoothAudioOption = self.useBuiltinMic ? AVAudioSession.CategoryOptions.allowBluetoothA2DP : AVAudioSession.CategoryOptions.allowBluetooth
try session.setCategory(.playAndRecord, mode: .default, options: [.mixWithOthers, .defaultToSpeaker, bluetoothAudioOption])
if #available(iOS 13.0, *) {
try session.setAllowHapticsAndSystemSoundsDuringRecording(true)
} */
// AVAudioSession Initiailized in Connection.m -> ArInit
// 列出所有可用输入
if self.useBuiltinMic, let inputs = session.availableInputs {
for port in inputs {
if port.portType == .builtInMic {
try session.setPreferredInput(port)
print("Set preferred input to built-in mic")
break
}
}
}
// try session.setActive(true)
}
private func sendOpusFrameFromDequeBuffer() {
bufferQueue.sync {
if pcm16BufferDeque.count >= 960 {
let chunk = Array(pcm16BufferDeque.prefix(960))
var packet = [UInt8](repeating: 0, count: 4000)
guard let enc = self.opusEncoder else {return}
let outBytes = opus_encode(enc, chunk, 960, &packet, Int32(packet.count))
sendMicrophoneData(packet, outBytes)
let removeCount = min(960, pcm16BufferDeque.count)
if removeCount > 0 {
pcm16BufferDeque.removeFirst(removeCount)
}
}
}
}
private func sendOpusFrameFromArrayBuffer() {
bufferQueue.sync {
if pcm16BufferArray.count >= 960 {
let chunk = Array(pcm16BufferArray.prefix(960))
var packet = [UInt8](repeating: 0, count: 4000)
guard let enc = self.opusEncoder else {return}
let outBytes = opus_encode(enc, chunk, 960, &packet, Int32(packet.count))
sendMicrophoneData(packet, outBytes)
let removeCount = min(960, pcm16BufferArray.count)
if removeCount > 0 {
pcm16BufferArray.removeFirst(removeCount)
}
}
}
}
@objc public static func setVolume(_ linearVolume: Float) {
let clamped = max(0.0, min(1.5, linearVolume))
let exponent: Float = 1.7
MicHandler.volume = powf(clamped, exponent)
}
private func configureEngine() throws {
let input = engine.inputNode
micInputFormat = input.inputFormat(forBus: 0)
try self.configureOpus(sampleRate: Int32(micInputFormat.sampleRate), channels: Int(micInputFormat.channelCount))
if #available(iOS 13.0, tvOS 13.0, *) {
try AVAudioSession.sharedInstance().setPreferredIOBufferDuration(0.02)
let sinkNode = AVAudioSinkNode { timestamp, frameCount, audioBufferList -> OSStatus in
guard self.isRecording else { return noErr}
let abl = audioBufferList.pointee.mBuffers
guard let data = abl.mData?.assumingMemoryBound(to: Float.self) else { return noErr }
let samples = UnsafeBufferPointer(start: data, count: Int(frameCount))
// 转 float32 -> int16
var chunk = [Int16](repeating: 0, count: samples.count)
for i in 0..<samples.count {
let clamped = max(min(samples[i] * MicHandler.volume, 1.0), -1.0)
chunk[i] = Int16(clamped * Float(Int16.max))
}
// 追加到缓冲区
self.bufferQueue.sync {
self.pcm16BufferDeque.append(contentsOf: chunk)
}
return noErr
}
audioSink = sinkNode
engine.attach(audioSink! as! AVAudioNode)
engine.connect(engine.inputNode, to: audioSink as! AVAudioNode, format: micInputFormat)
// 开定时器,每 20ms 触发一次
self.timer = SafeTimer(interval:0.02, delay: 0.05) {
self.sendOpusFrameFromDequeBuffer()
}
}
else{
engine.attach(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: micInputFormat)
input.installTap(onBus: 0, bufferSize: 5760, format: micInputFormat) { [weak self] buffer, _ in
guard let self = self, self.isRecording else { return }
// ===============================
// 把 buffer 转成 PCM16 并保存
let frameLength = Int(buffer.frameLength)
let channels = Int(buffer.format.channelCount)
var pcm16InterleavedBuffer = [Int16](repeating: 0, count: frameLength * channels)
if let floatPtrs = buffer.floatChannelData {
for ch in 0..<channels {
let floatPtr = floatPtrs[ch]
for i in 0..<frameLength {
let f = floatPtr[i] * MicHandler.volume
// 把 float 转到 Int16 范围:假设 float 在 -1…+1 之间
// 乘以 Int16.max (32767),再做裁剪
let scaled = f * Float(Int16.max)
let clipped: Float
if scaled > Float(Int16.max) {
clipped = Float(Int16.max)
} else if scaled < Float(Int16.min) {
clipped = Float(Int16.min)
} else {
clipped = scaled
}
pcm16InterleavedBuffer[i * channels + ch] = Int16(clipped)
}
}
}
// 现在 pcm16InterleavedBuffer 里就是转换后的 Int16 数据
self.bufferQueue.sync {
self.pcm16BufferArray.append(contentsOf: pcm16InterleavedBuffer)
}
}
// 开定时器,每 20ms 触发一次
self.timer = SafeTimer(interval:0.02, delay: 0.05) {
self.sendOpusFrameFromArrayBuffer()
}
}
engine.prepare()
try engine.start()
}
@objc public func clean() {
for token in notificationTokens {
NotificationCenter.default.removeObserver(token)
}
notificationTokens.removeAll()
self.timer?.clean()
}
private func notify(_ error: Error) {
delegate?.micHandler?(self, didFailWithError: error as NSError)
}
// ===============================
// 🔹 新增播放 Opus 数据方法
/*
private func playbackOpus() {
guard let dec = opusDecoder else { return }
let channels = Int(micInputFormat.channelCount)
for (index, packet) in recordedOpusPackets.enumerated() {
let maxFrames = 5760 // 最大 120ms
let pcmBuf = UnsafeMutablePointer<Int16>.allocate(capacity: maxFrames * channels)
defer { pcmBuf.deallocate() }
let frameCount = opus_decode(dec,
[UInt8](packet),
Int32(packet.count),
pcmBuf,
Int32(maxFrames),
0)
if frameCount < 0 {
print("Opus decode error: \(frameCount)")
continue
}
// 构造源格式 AVAudioPCMBuffer (PCM16)
let sourceFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: micInputFormat.sampleRate,
channels: micInputFormat.channelCount,
interleaved: true)!
guard let sourceBuffer = AVAudioPCMBuffer(pcmFormat: sourceFormat,
frameCapacity: AVAudioFrameCount(frameCount)) else { continue }
sourceBuffer.frameLength = AVAudioFrameCount(frameCount)
// 填充 PCM16 数据到 sourceBuffer
let srcPointer = sourceBuffer.int16ChannelData![0]
for i in 0..<Int(frameCount * Int32(channels)) {
srcPointer[i] = pcmBuf[i]
}
// 准备目标 buffer (Float32)
guard let floatBuffer = AVAudioPCMBuffer(pcmFormat: micInputFormat,
frameCapacity: AVAudioFrameCount(frameCount)) else { continue }
// 🔹 使用 AVAudioConverter 转换
let converter = AVAudioConverter(from: sourceFormat, to: micInputFormat)!
var error: NSError? = nil
let inputBlock: AVAudioConverterInputBlock = { inNumPackets, outStatus in
outStatus.pointee = .haveData
return sourceBuffer
}
converter.convert(to: floatBuffer, error: &error, withInputFrom: inputBlock)
if let error = error {
print("AVAudioConverter error: \(error)")
continue
}
// 播放
if index == recordedOpusPackets.count - 1 {
playerNode.scheduleBuffer(floatBuffer, at: nil, options: []) { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
self.playerNode.stop()
self.engine.stop()
}
}
} else {
playerNode.scheduleBuffer(floatBuffer, at: nil, options: [], completionHandler: nil)
}
}
// 启动 engine
if !engine.isRunning {
do { try engine.start() } catch { print(error) }
}
if !playerNode.isPlaying {
playerNode.play()
}
}
// ===============================
// 🔹 改动 2:播放 PCM16 数据
private func playbackRecorded() {
do {
try configureEngine()
} catch {
notify(error)
}
let channels = Int(micInputFormat.channelCount)
for (index, pcmData) in recordedPCM16.enumerated() {
let frameCount = pcmData.count / (MemoryLayout<Int16>.size * channels)
guard let buf = AVAudioPCMBuffer(pcmFormat: micInputFormat, frameCapacity: AVAudioFrameCount(frameCount)) else { continue }
buf.frameLength = buf.frameCapacity
// PCM16 -> Float32
pcmData.withUnsafeBytes { rawBuf in
let pcmPtr = rawBuf.bindMemory(to: Int16.self).baseAddress!
for i in 0..<frameCount {
for ch in 0..<channels {
buf.floatChannelData?[ch][i] = Float(pcmPtr[i * channels + ch]) / Float(Int16.max)
}
}
}
// 只在最后一个 buffer 设置 completionHandler
if index == recordedPCM16.count - 1 {
playerNode.scheduleBuffer(buf, at: nil, options: []) { [weak self] in
guard let self = self else { return }
// 🔹 回到主线程安全停止
DispatchQueue.main.async {
self.playerNode.stop()
self.engine.stop()
}
}
} else {
playerNode.scheduleBuffer(buf, at: nil, options: [], completionHandler: nil)
}
}
playerNode.play()
}
private func deepCopy(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) -> AVAudioPCMBuffer? {
guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: buffer.frameLength) else { return nil }
out.frameLength = buffer.frameLength
let channels = Int(format.channelCount)
for ch in 0..<channels {
if let src = buffer.floatChannelData?[ch], let dst = out.floatChannelData?[ch] {
dst.update(from: src, count: Int(buffer.frameLength))
}
}
return out
}
private func merge(buffers: [AVAudioPCMBuffer], format: AVAudioFormat) -> AVAudioPCMBuffer? {
let total = buffers.reduce(0) { $0 + $1.frameLength }
guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: total) else { return nil }
out.frameLength = total
var writePos: AVAudioFrameCount = 0
let channels = Int(format.channelCount)
for b in buffers {
let frames = Int(b.frameLength)
for ch in 0..<channels {
if let src = b.floatChannelData?[ch], let dst = out.floatChannelData?[ch] {
dst.advanced(by: Int(writePos)).update(from: src, count: frames)
}
}
writePos += b.frameLength
}
return out
}
*/
}
/* -------------------------------------------------------*/
+385
View File
@@ -0,0 +1,385 @@
//
// MicHandler.swift
// VoidLink
//
// Created by True砖家 on 2025/9/28.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
import Foundation
import CoreMotion
@objc public protocol OnScreenWidgetStickMixedInputDelegate: AnyObject {
func mixOnScreenRightStickAndGyroInput(x: CGFloat, y: CGFloat)
func mixOnScreenLeftStickAndGyroInput(x: CGFloat, y: CGFloat)
func gyroMixInputStarted() -> Bool
}
@objc class MotionHandler: NSObject, OnScreenWidgetStickMixedInputDelegate{
public func mixOnScreenRightStickAndGyroInput(x: CGFloat, y: CGFloat) {
rightStickTouchInputX = x
rightStickTouchInputY = y
}
public func mixOnScreenLeftStickAndGyroInput(x: CGFloat, y: CGFloat) {
// rollIntegral = 0
leftStickTouchInputX = x
leftStickTouchInputY = y
}
@objc public func mixPhysicalRightStickAndGyroInput(x: CGFloat, y: CGFloat) {
rightStickPhysicalInputX = synthesizePhysicalStick ? x : 0
rightStickPhysicalInputY = synthesizePhysicalStick ? y : 0
}
@objc public func mixPhysicalLeftStickAndGyroInput(x: CGFloat, y: CGFloat) {
// rollIntegral = 0
leftStickPhysicalInputX = synthesizePhysicalStick ? x : 0
leftStickPhysicalInputY = synthesizePhysicalStick ? y : 0
}
public func gyroMixInputStarted() -> Bool {
return gyroIsWorking
}
private static let sharedInstance = MotionHandler()
@objc class func shared(profile: OSCProfile?) -> MotionHandler {
let sharedInstance = MotionHandler.sharedInstance
guard let profile = profile else { return sharedInstance }
sharedInstance.mapGyroTo = profile.mapGyroTo
sharedInstance.synthesizePhysicalStick = profile.synthesizePhysicalStick
sharedInstance.rollToLeftStick = profile.rollToLeftStick
sharedInstance.yawPitchToRightStick = profile.yawPitchToRightStick
sharedInstance.sensitvityYaw = profile.gyroSensitivityYaw
sharedInstance.sensitvityPitch = profile.gyroSensitivityPitch
sharedInstance.sensitvityRoll = profile.gyroSensitivityRoll
sharedInstance.gyroToStickMinOffset = profile.gyroToStickMinOffset
sharedInstance.onScreenControls = OnScreenControls.shared()
return sharedInstance
}
@objc public var gyroControlStarted: Bool = false
private var gyroIsWorking: Bool = false
private var accelControlStarted: Bool = false
private let motionManager = CMMotionManager()
private var synthesizePhysicalStick:Bool = false
private var mapGyroTo:MapGyroTo = .mapGyroToControllerStick
private var rollToLeftStick:Bool = false
private var yawPitchToRightStick:Bool = false
public var sensitvityYaw:CGFloat = 1.0
public var sensitvityPitch:CGFloat = 1.0
public var sensitvityRoll:CGFloat = 1.0
@objc public var widgetYawFactor:CGFloat = 1.0
@objc public var widgetPitchFactor:CGFloat = 1.0
@objc public var widgetRollFactor:CGFloat = 1.0
@objc public var previousWidgetYawFactor:CGFloat = 1.0
@objc public var previousWidgetPitchFactor:CGFloat = 1.0
@objc public var previousWidgetRollFactor:CGFloat = 1.0
public var gyroStarter:Any?
private var windowScene: Any?
@objc public var onScreenControls: OnScreenControls?
public let stickMaxOffset: CGFloat = 0x7FFE
private var stickInputScale: CGFloat = 35
private var yaw:Double = 0
private var pitch:Double = 0
private var roll:Double = 0
private var rollIntegral:Double = 0
private var isCalibrating: Bool = false
private var sumX: Double = 0
private var sumY: Double = 0
private var sumZ: Double = 0
@objc public var gyroBiasX: Double = 0
@objc public var gyroBiasY: Double = 0
@objc public var gyroBiasZ: Double = 0
@objc public var gyroToStickMinOffset:Double = 0
private var yawBias:Double = 0
private var pitchBias:Double = 0
private var rollBias:Double = 0
private var rightStickTouchInputX:Double = 0
private var rightStickTouchInputY:Double = 0
private var leftStickTouchInputX:Double = 0
private var leftStickTouchInputY:Double = 0
private var rightStickPhysicalInputX:Double = 0
private var rightStickPhysicalInputY:Double = 0
public var gyroToStickOffset:CGVector = CGVector(dx: 0, dy: 0)
private var leftStickPhysicalInputX:Double = 0
private var leftStickPhysicalInputY:Double = 0
var updateInterval: TimeInterval = 1.0 / 120.0 {
didSet {
motionManager.accelerometerUpdateInterval = updateInterval
motionManager.gyroUpdateInterval = updateInterval
}
}
@objc public override init() {
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
super.init()
if #available(iOS 13.0, *) {
let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene
self.windowScene = scene
} else {
// Fallback on earlier versions
}
motionManager.accelerometerUpdateInterval = updateInterval
motionManager.gyroUpdateInterval = updateInterval
}
public func startGyroByOnScreenButton(_ sender: OnScreenWidgetView, yawFactor:CGFloat, pitchFactor:CGFloat, rollFactor:CGFloat){
self.previousWidgetYawFactor = self.widgetYawFactor
self.previousWidgetPitchFactor = self.widgetPitchFactor
self.previousWidgetRollFactor = self.widgetRollFactor
self.widgetYawFactor = yawFactor
self.widgetPitchFactor = pitchFactor
self.widgetRollFactor = rollFactor
print("self.widgetRollFactor \(self.widgetRollFactor)")
if self.gyroStarter == nil {
self.gyroStarter = sender
if sender.motionControlButtonString != "GYROPAUSE" {self.startGyroUpdate()}
}
else if sender.motionControlButtonString == "GYROPAUSE" {
self.startGyroUpdate()
}
}
@objc public func startGyroByControllerButton(){
self.previousWidgetYawFactor = self.widgetYawFactor
self.previousWidgetPitchFactor = self.widgetPitchFactor
self.previousWidgetRollFactor = self.widgetRollFactor
self.widgetYawFactor = 1
self.widgetPitchFactor = 1
self.widgetRollFactor = 1
self.startGyroUpdate()
}
/// 启动传感器数据更新
@objc public func startGyroUpdate() {
if !gyroControlStarted {
gyroControlStarted = true
if motionManager.isGyroAvailable {
motionManager.startGyroUpdates(to: .main) { [weak self] gyroData, _ in
guard let self = self, let data = gyroData else { return }
self.handleGyroData(x: data.rotationRate.x,
y: data.rotationRate.y,
z: data.rotationRate.z)
}
}
}
else {return}
}
@objc public func startAccelUpdate() {
accelControlStarted = true
if motionManager.isAccelerometerAvailable {
motionManager.startAccelerometerUpdates(to: .main) { [weak self] accelData, _ in
guard let self = self, let data = accelData else { return }
self.handleAccelerometerData(x: data.acceleration.x,
y: data.acceleration.y,
z: data.acceleration.z)
}
}
}
/// 停止更新
private var interruptNoneGyroInput:Bool = false
@objc public func stopGyroUpdate(interruptNoneGyroInput:Bool=false, resetLeftStick:Bool=false) {
gyroControlStarted = false
gyroIsWorking = false
if motionManager.isGyroActive{
motionManager.stopGyroUpdates()
}
self.clearGyroInput(interruptNonGyroInput:interruptNoneGyroInput, resetLeftStick:resetLeftStick)
}
@objc public func stopAccelUpdate() {
accelControlStarted = false
if motionManager.isAccelerometerActive {
motionManager.stopAccelerometerUpdates()
}
// self.onScreenControls.clearLeftStickTouchPadFlag()
// self.onScreenControls.clearRightStickTouchPadFlag()
}
// MARK: - 私有方法处理数据
private func handleAccelerometerData(x: Double, y: Double, z: Double) {
// 在这里处理加速度数据,比如计算方向、存储或驱动逻辑
}
private func handleGyroData(x: Double, y: Double, z: Double) {
var yawSource:Double = 0
var pitchSource:Double = 0
var rollSource:Double = 0
/*
let correctedX:Double = x - gyroBiasX
let correctedY:Double = y - gyroBiasY
let correctedZ:Double = z - gyroBiasZ */
if #available(iOS 13.0, *) {
let orientation = (windowScene as! UIWindowScene).interfaceOrientation
switch orientation {
case .landscapeLeft:
yawBias = gyroBiasX
pitchBias = -gyroBiasY
rollBias = -gyroBiasZ
yawSource = x-yawBias
pitchSource = -y-pitchBias
rollSource = -z-rollBias
case .landscapeRight:
yawBias = -gyroBiasX
pitchBias = gyroBiasY
rollBias = -gyroBiasZ
yawSource = -x-yawBias
pitchSource = y-pitchBias
rollSource = -z-rollBias
case .portrait:
yawBias = -gyroBiasY
pitchBias = -gyroBiasX
rollBias = -gyroBiasZ
yawSource = -y-yawBias
pitchSource = -x-pitchBias
rollSource = -z-rollBias
case .portraitUpsideDown:
yawBias = gyroBiasY
pitchBias = gyroBiasX
rollBias = -gyroBiasZ
yawSource = y-yawBias
pitchSource = x-pitchBias
rollSource = -z-rollBias
default:
yawBias = gyroBiasX
pitchBias = -gyroBiasY
rollBias = -gyroBiasZ
yawSource = x-yawBias
pitchSource = -y-pitchBias
rollSource = -z-rollBias
}
} else {
let orientation = UIApplication.shared.statusBarOrientation
if orientation.isLandscape {
yawBias = gyroBiasX
pitchBias = -gyroBiasY
rollBias = -gyroBiasZ
yawSource = x-yawBias
pitchSource = -y-pitchBias
rollSource = -z-rollBias
} else if orientation.isPortrait {
yawBias = -gyroBiasY
pitchBias = -gyroBiasX
rollBias = -gyroBiasZ
yawSource = -y-yawBias
pitchSource = -x-pitchBias
rollSource = -z-rollBias
}
}
if !gyroControlStarted {
self.clearGyroInput(interruptNonGyroInput: interruptNoneGyroInput)
print("Gyro: stopped")
return
}
gyroIsWorking = true
if mapGyroTo == MapGyroTo.mapGyroToMouse {
yaw = yawSource*sensitvityYaw*widgetYawFactor*30
pitch = pitchSource*sensitvityPitch*widgetPitchFactor*30
LiSendMouseMoveEvent(Int16(yaw),Int16(pitch))
}
//guard let onScreenControls = onScreenControls else { return }
if mapGyroTo == MapGyroTo.mapGyroToControllerStick {
if yawPitchToRightStick {
gyroToStickOffset.dx = gyroInputToStickInput(input:yawSource*sensitvityYaw*widgetYawFactor*10)
yaw = rightStickTouchInputX + rightStickPhysicalInputX + gyroToStickOffset.dx
yaw = self.clampStickInput(input: yaw)
gyroToStickOffset.dy = -gyroInputToStickInput(input:pitchSource*sensitvityPitch*widgetPitchFactor*10)
pitch = rightStickTouchInputY + rightStickPhysicalInputY + gyroToStickOffset.dy
pitch = self.clampStickInput(input: pitch)
let offsetVector = ControllerUtil.compensated(offsetVector: CGVector(dx: yaw, dy: pitch), minOffset: gyroToStickMinOffset)
onScreenControls?.sendRightStickTouchPadEvent(offsetVector.dx, offsetVector.dy)
}
if rollToLeftStick {
roll = gyroInputToStickInput(input:rollSource*sensitvityRoll*widgetRollFactor*0.2)
rollIntegral = rollIntegral + roll
let mixedLeftStickOffsetX = self.clampStickInput(input: rollIntegral+leftStickTouchInputX+leftStickPhysicalInputX)
let mixedLeftStickOffsetY = self.clampStickInput(input: leftStickTouchInputY+leftStickPhysicalInputY)
let offsetVector = ControllerUtil.compensated(offsetVector: CGVector(dx: mixedLeftStickOffsetX, dy: mixedLeftStickOffsetY), minOffset: gyroToStickMinOffset)
onScreenControls?.sendLeftStickTouchPadEvent(offsetVector.dx, offsetVector.dy)
}
}
}
private func clearGyroInput(interruptNonGyroInput:Bool, resetLeftStick:Bool=false){
// guard let onScreenControls = onScreenControls else { return }
if yawPitchToRightStick{
onScreenControls?.sendRightStickTouchPadEvent(rightStickPhysicalInputX+rightStickTouchInputX-yawBias, rightStickPhysicalInputY+rightStickTouchInputY-pitchBias)
}
if rollToLeftStick{
if resetLeftStick {
rollIntegral = 0
onScreenControls?.sendLeftStickTouchPadEvent(leftStickPhysicalInputX+leftStickTouchInputX-rollBias,leftStickPhysicalInputY+leftStickTouchInputY)
}
}
if(interruptNonGyroInput){
onScreenControls?.clearLeftStickTouchPadFlag()
onScreenControls?.clearRightStickTouchPadFlag()
}
}
private func clampStickInput(input: CGFloat) -> CGFloat{
return fmax(fmin(input, stickMaxOffset),-stickMaxOffset)
}
private func gyroInputToStickInput(input: CGFloat) -> CGFloat{
return self.clampStickInput(input: stickMaxOffset * input / 8)
}
@objc public func calibrateGyroBias(duration: TimeInterval = 5.0, completion: @escaping () -> Void) {
guard motionManager.isGyroAvailable else { return }
isCalibrating = true
var sumX = 0.0, sumY = 0.0, sumZ = 0.0
var sampleCount = 0
motionManager.startGyroUpdates(to: .main) { [weak self] gyroData, _ in
guard let self = self, self.isCalibrating, let data = gyroData else { return }
sumX += data.rotationRate.x
sumY += data.rotationRate.y
sumZ += data.rotationRate.z
sampleCount += 1
}
// 5秒后计算平均值
DispatchQueue.main.asyncAfter(deadline: .now() + duration) { [weak self] in
guard let self = self else { return }
self.motionManager.stopGyroUpdates()
if sampleCount > 0 {
gyroBiasX = sumX / Double(sampleCount)
gyroBiasY = sumY / Double(sampleCount)
gyroBiasZ = sumZ / Double(sampleCount)
}
self.isCalibrating = false
completion()
}
}
}
+2 -2
View File
@@ -14,11 +14,11 @@
NS_ASSUME_NONNULL_BEGIN
@interface NativeTouchHandler : UIResponder
@property (nonatomic, assign) NSMutableSet<UITouch *>* touchesCapturedByOnScreenButtons;
- (id)initWithView:(StreamView* )view andSettings:(TemporarySettings*)settings;
- (void)setAllowSingleTouchEnabled:(BOOL)enabled;
@end
+98 -57
View File
@@ -14,13 +14,11 @@
#include <Limelight.h>
@implementation NativeTouchHandler {
StreamView* streamView;
TemporarySettings* currentSettings;
__weak StreamView* streamView;
bool activateCoordSelector;
bool singleTouchDisabled;
CGFloat pointerVelocityDividerLocationByPoints;
uint16_t touchMoveEventIntervalUs;
bool asyncNativeTouch;
unsigned int touchDownQos;
@@ -35,29 +33,37 @@
NSMutableSet<NSNumber *> *pointerIdPool; //pre-defined pool of pointerIds.
NSMutableSet<NSNumber *> *unassignedPointerIds;
NSMutableSet *blacklistedTouches;
bool trackPointEnabled;
NSMutableArray<CAShapeLayer *> * trackPointPool; //pre-defined pool of pointerIds.
NSMutableDictionary *pointerObjDict;
CGFloat slideGestureVerticalThreshold;
CGFloat screenWidthWithThreshold;
CGFloat EDGE_TOLERANCE;
CGFloat _edgeTolerance;
CGRect streamViewBounds;
int64_t moveEventIntervalNSec;
}
- (id)initWithView:(StreamView*)view andSettings:(TemporarySettings*)settings{
self = [super init];
self->streamView = view;
self->currentSettings = settings;
self->activateCoordSelector = currentSettings.pointerVelocityModeDivider.floatValue != 1.0;
self->touchMoveEventIntervalUs = currentSettings.touchMoveEventInterval.intValue;
self->activateCoordSelector = settings.pointerVelocityModeDivider.floatValue != 1.0;
self->moveEventIntervalNSec = (int64_t)(settings.touchMoveEventInterval.intValue * 1000);;
self->streamViewBounds = view.bounds;
self->pointerIdDict = [NSMutableDictionary dictionary];
self->pointerIdPool = [NSMutableSet set];
self->trackPointEnabled = settings.touchPointTracking;
self->trackPointPool = [NSMutableArray array];
for (uint8_t i = 0; i <= 10; i++) { //ipadOS supports upto 11 finger touches
[self->pointerIdPool addObject:@(i)];
[self->trackPointPool addObject:[GraphicUtils makeTouchTrackpointIn:streamView]];
}
self->activePointerIds = [NSMutableSet set];
self->blacklistedTouches = [NSMutableSet set];
@@ -85,9 +91,9 @@
self->pointerObjDict = [NSMutableDictionary dictionary];
EDGE_TOLERANCE = 10.0;
_edgeTolerance = settings.edgeSlidingSensitivity.floatValue;
slideGestureVerticalThreshold = CGRectGetHeight([[UIScreen mainScreen] bounds]) * 0.4;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - EDGE_TOLERANCE;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - _edgeTolerance;
self->pointerVelocityDividerLocationByPoints = self->streamView.bounds.size.width * settings.pointerVelocityModeDivider.floatValue;
@@ -164,7 +170,7 @@
//check if touch point is spawned on the left or right upper half screen edges, event to remote PC. this is for better handling in-stream slide gesture
CGPoint initialPoint = [touch locationInView:self->streamView];
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < EDGE_TOLERANCE || initialPoint.x > screenWidthWithThreshold)) {
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < _edgeTolerance || initialPoint.x > screenWidthWithThreshold)) {
[blacklistedTouches addObject:touchAddrObj];
}
}
@@ -187,10 +193,18 @@
}
- (void)sendTouchEvent:(UITouch*)touch withTouchtype:(uint8_t)touchType{
- (void)sendTouchEvent:(UITouch*)touch withTouchtype:(uint8_t)touchType withEvent:(UIEvent *)event{
//if(touchPointSpawnedAtUpperScreenEdge && touchType != LI_TOUCH_EVENT_UP) return; // we're done here. this touch event will not be sent to the remote PC. and this must be checked after coord selector finishes populating new relative coords, or the app will crash
if([blacklistedTouches containsObject:@((uintptr_t)touch)]) return;
if(singleTouchDisabled
&& [UITouchUtil touchesIn:streamView from:event].count != 2
&& touchType != LI_TOUCH_EVENT_UP) return;
if(PencilHandler.pencilPausesNativeTouch
&& PencilHandler.isDrawing
&& touchType != LI_TOUCH_EVENT_UP) return;
CGPoint targetCoords;
//NSLog(@"selecting coords: %d", touch.phase == UITouchPhaseMoved);
// NSLog(@"excluded count: %d", (uint32_t)[excludedPointerIds count]);
@@ -210,78 +224,87 @@
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
if (asyncNativeTouch) dispatch_async(dispatch_get_global_queue(touchDownQos, 0), ^{
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]) continue;
if([OnScreenControls.touchesCapturedByOnScreenControls containsObject:touch]) continue;
[self handleTouchDown:touch]; //generate & populate pointerId
if(self->activateCoordSelector) [self populatePointerObjIntoDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_DOWN];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_DOWN withEvent:event];
}
if(self->trackPointEnabled){
dispatch_async(dispatch_get_main_queue(), ^{
[CATransaction begin];
[CATransaction setDisableActions:YES];
for (UITouch* touch in touches){
uint8_t pointerId = [self retrievePointerIdFromDict:touch];
CAShapeLayer* trackPoint = self->trackPointPool[pointerId];
trackPoint.position = [touch locationInView:self->streamView];
trackPoint.hidden = false;
}
[CATransaction commit];
});
}
});
else{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]) continue;
[self handleTouchDown:touch]; //generate & populate pointerId
if(self->activateCoordSelector) [self populatePointerObjIntoDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_DOWN];
}
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
if (asyncNativeTouch) dispatch_async(dispatch_get_global_queue(touchMoveQos, 0), ^{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, moveEventIntervalNSec), dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]) continue;
if([OnScreenControls.touchesCapturedByOnScreenControls containsObject:touch]) continue;
if(self->activateCoordSelector) [self updatePointerObjInDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_MOVE];
[[self getPointerObjFromDict:touch] doesNeedResetCoords]; // execute the judging of doesReachBoundary for current pointer instance. (happens after the event is sent to Sunshine service)
usleep(self->touchMoveEventIntervalUs);
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_MOVE withEvent:event];
[[self getPointerObjFromDict:touch] doesNeedResetCoords];
}
});
else {
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]) continue;
if(self->activateCoordSelector) [self updatePointerObjInDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_MOVE];
[[self getPointerObjFromDict:touch] doesNeedResetCoords]; // execute the judging of doesReachBoundary for current pointer instance. (happens after the event is sent to Sunshine service)
// usleep(self->touchMoveEventIntervalUs);
}
if(self->trackPointEnabled){
dispatch_async(dispatch_get_main_queue(), ^{
[CATransaction begin];
[CATransaction setDisableActions:YES];
for (UITouch* touch in touches){
uint8_t pointerId = [self retrievePointerIdFromDict:touch];
CAShapeLayer* trackPoint = self->trackPointPool[pointerId];
trackPoint.position = [touch locationInView:self->streamView];
}
[CATransaction commit];
});
}
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
if(asyncNativeTouch) dispatch_async(dispatch_get_global_queue(touchEndQos, 0), ^{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, moveEventIntervalNSec), dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]) continue;
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_UP]; //send touch event before remove pointerId
[self removePointerId:touch]; //then remove pointerId
if([OnScreenControls.touchesCapturedByOnScreenControls containsObject:touch]){
[OnScreenControls.touchesCapturedByOnScreenControls removeObject:touch];
continue;
}
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_UP withEvent:event]; //send touch event before remove pointerId
if(self->trackPointEnabled){
dispatch_async(dispatch_get_main_queue(), ^{
uint8_t pointerId = [self retrievePointerIdFromDict:touch];
CAShapeLayer* trackPoint = self->trackPointPool[pointerId];
[CATransaction begin];
[CATransaction setDisableActions:YES];
trackPoint.hidden = true;
[CATransaction commit];
[self removePointerId:touch];
});
}
else [self removePointerId:touch];
if(self->activateCoordSelector) [self removePointerObjFromDict:touch];
[self->blacklistedTouches removeObject:@((uintptr_t)touch)];
}
//if(self->touchPointSpawnedAtUpperScreenEdge && [[event allTouches] count] == [touches count])
});
else{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]) continue;
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_UP]; //send touch event before remove pointerId
[self removePointerId:touch]; //then remove pointerId
if(self->activateCoordSelector) [self removePointerObjFromDict:touch];
}
}
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
[self touchesEnded:touches withEvent:event];
}
- (void)populatePointerObjIntoDict:(UITouch*)touch{
NativeTouchPointer* pointer = [[NativeTouchPointer alloc] initWithTouch:touch];
pointer.useRelativeCoords = pointer.initialPoint.x > pointerVelocityDividerLocationByPoints;
@@ -310,4 +333,22 @@
return pointer.useRelativeCoords ? pointer.latestRelativePoint : pointer.latestPoint;
}
- (void)setAllowSingleTouchEnabled:(BOOL)enabled{
singleTouchDisabled = !enabled;
}
- (void)dealloc{
NSLog(@"dealloc nativeTouchHanlder %f", CACurrentMediaTime());
pointerIdDict = nil;
activePointerIds = nil;
pointerIdPool= nil;
unassignedPointerIds = nil;
blacklistedTouches = nil;
for(CAShapeLayer* layer in trackPointPool){
[layer removeFromSuperlayer];
}
trackPointPool = nil;
pointerObjDict = nil;
}
@end
+1
View File
@@ -22,6 +22,7 @@ NS_ASSUME_NONNULL_BEGIN
+ (void)initContextWithView:(StreamView*)view andSettings:(TemporarySettings*)settings;
+ (void)cleanUpContext;
- (bool)doesNeedResetCoords;
- (void)updatePointerCoords:(UITouch *)touch;
+4
View File
@@ -63,4 +63,8 @@ static StreamView* streamView;
NSLog(@"stream wdith %f, stream height %f", streamViewWidth, streamViewHeight);
}
+ (void)cleanUpContext {
streamView = nil;
}
@end
+19 -25
View File
@@ -17,12 +17,7 @@
@class ControllerSupport;
@class StreamConfiguration;
// sending self as an instance to OnScreenWidgetView or other related classes
@protocol InstanceProviderDelegate <NSObject>
- (void)getOnScreenControlsInstance:(id)sender; // Renamed delegate method
@end
@class LayoutOnScreenControls;
static const float D_PAD_DIST = 10;
static const float BUTTON_DIST = 20;
@@ -36,13 +31,13 @@ static const float BUTTON_DIST = 20;
CGFloat _dPadSizeFactor;
}
+ (NSMutableDictionary *)layerVibrationStyleDic;
+ (NSMutableDictionary *_Nullable)layerVibrationStyleDic;
typedef NS_ENUM(NSInteger, OnScreenControlsLevel) {
OnScreenControlsLevelOff,
OnScreenControlsLevelCustom,
OnScreenControlsLevelSimple,
OnScreenControlsLevelFull,
OnScreenControlsLevelCustom,
OnScreenControlsLevelAuto, // move it here instead of delete , to ensure integrity of the codes
// Internal levels selected by ControllerSupport
@@ -52,11 +47,6 @@ typedef NS_ENUM(NSInteger, OnScreenControlsLevel) {
};
@property (nonatomic, weak) id<InstanceProviderDelegate> delegate; // Delegate property
- (void)sendInstance;
// @property (nonatomic, assign) CustomTapGestureRecognizer* mouseRightClickTapRecognizer; // this object will be passed to onscreencontrols class for areVirtualControllerTaps flag setting
@property (nonatomic, assign) bool isLayingOut;
//@property (nonatomic) NSMutableSet<UITouch* >* touchesCapturedByOnScreenButtons;
@@ -105,12 +95,15 @@ typedef NS_ENUM(NSInteger, OnScreenControlsLevel) {
@property OnScreenControlsLevel _level;
@property NSMutableArray *OSCButtonLayers;
@property NSMutableSet *OSCButtonLayerPool;
@property NSMutableDictionary* layerVibrationStyleDic;
+ (NSMutableSet* )touchAddrsCapturedByOnScreenControls;
+ (NSMutableSet* )touchesCapturedByOnScreenControls;
+ (OnScreenControls* )shared;
+ (void)setShared:(OnScreenControls*)instance;
- (void) sendRightStickTouchPadEvent:(CGFloat) stickX : (CGFloat) stickY;
- (void) clearRightStickTouchPadFlag;
- (void) sendLeftStickTouchPadEvent:(CGFloat) stickX : (CGFloat) stickY;
@@ -125,18 +118,19 @@ typedef NS_ENUM(NSInteger, OnScreenControlsLevel) {
- (BOOL) handleTouchUpEvent:(NSSet*)touches;
- (BOOL) handleTouchMovedEvent:(NSSet*)touches;
- (void) setLevel:(OnScreenControlsLevel)level;
- (void) show;
- (void) showLegacyWidgetsWith:(OSCProfile* )profile;
- (void) regenLayerPool;
- (void) setupComplexControls;
- (void) drawButtons;
- (void) drawButtons:(OSCProfile* _Nullable)profile;
- (void) drawBumpers;
- (void) updateControls;
- (void) updateLegacyWidgetsWith:(OSCProfile* _Nullable )profile;
- (OnScreenControlsLevel) getLevel;
- (void) setDPadCenter;
- (void) setAnalogStickPositions;
- (void) positionAndResizeSingleControllerLayers;
- (void) resizeControllerLayerWith:(CALayer*)layer and:(CGFloat)sizeFactor;
- (void) adjustControllerLayerOpacityWith:(CALayer*)layer and:(CGFloat)alpha;
+ (CGFloat) getControllerLayerSizeFactor:(CALayer*)layer;
- (CGFloat) getControllerLayerOpacity:(CALayer*)layer;
- (void) setDPadCenter:(OSCProfile *_Nonnull)profile;;
- (void) setAnalogStickPositions:(OSCProfile *_Nonnull)profile;
- (void) positionAndResizeSingleControllerLayers:(OSCProfile *_Nonnull)profile;
- (void) resizeControllerLayerWith:(CALayer*_Nonnull)layer and:(CGFloat)sizeFactor;
- (void) adjustControllerLayerOpacityWith:(CALayer*_Nonnull)layer and:(CGFloat)alpha;
+ (CGFloat) getControllerLayerSizeFactor:(CALayer*_Nonnull)layer;
- (CGFloat) getControllerLayerOpacity:(CALayer*_Nonnull)layer;
@end
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+541
View File
@@ -0,0 +1,541 @@
//
// PencilHandler.swift
// VoidLink
//
// Created by True砖家 on 2025/11/17.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
import UIKit
@objc class PencilHandler: UIResponder, UIPencilInteractionDelegate {
@objc static var shared: PencilHandler?
/// 用来处理所有 touch 事件的宿主视图
weak var streamView: UIView?
// private var tickTimer: SafeTimer
private var pencilInteraction: Any?
private var streamAspectRatio: Float
private var tickInterval: TimeInterval
private var manualTick: Bool
private var pencilTickEnabled: Bool
private var pressureCurveEnabled: Bool = false
private var manualHoverFlag: Bool = false
@objc static var hoverSupported: Bool = false
// @objc static private(set) var autoHoverTermination: Bool = false
@objc static private(set) var hoverMode: PencilHoverMode = .HoverPencil
private(set) var pencilProEnabled: Bool = false
private var isFirstMove: Bool = false
private var moveEventIndex: Int64 = 0
private var initialMoveEventIndexLimit: Int64
private var touchBeganForce: Float = 0
@objc static private(set) var isDrawing: Bool = false
@objc static private(set) var pencilPausesNativeTouch: Bool = false
// static private let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
static private var selectedProfile:OSCProfile?
private var pressureLUT = PressureCurveLUT(curve: PressureCurve())
@objc init(streamView: UIView, settings: TemporarySettings) {
self.streamView = streamView
streamAspectRatio = settings.width.floatValue/settings.height.floatValue
tickInterval = TimeInterval(settings.pencilTickIntervalUs.floatValue/1000000)
manualTick = settings.pencilTickMode.intValue == PencilTickMode.ManualTick.rawValue
pencilTickEnabled = settings.pencilTickMode.intValue != PencilTickMode.PencilTickDisabled.rawValue
// initialMoveEventIndexLimit = UIScreen.main.maximumFramesPerSecond > 60 ? 4 : 2
initialMoveEventIndexLimit = 0
super.init()
setupPressureLUT()
PencilHandler.shared = self
}
@objc public func setupPressureLUT(profile:OSCProfile? = nil){
var selectedProfile:OSCProfile
if profile == nil {
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
selectedProfile = oscProfileMan.getSelectedProfile()
}
else{
selectedProfile = profile!
}
doubleTapShorcuts.removeAll()
if selectedProfile.doubleTapShorcutEnabled {
if selectedProfile.eraserShortcut != "" {
doubleTapShorcuts.append(selectedProfile.eraserShortcut)
}
if selectedProfile.brushShortcut != "" {
doubleTapShorcuts.append(selectedProfile.brushShortcut)
}
}
PencilHandler.squeezeStartShortcut = ""
PencilHandler.squeezeEndShortcut = ""
if selectedProfile.squeezeShorcutEnabled {
PencilHandler.squeezeStartShortcut = selectedProfile.squeezeStartShortcut
PencilHandler.squeezeEndShortcut = selectedProfile.squeezeEndShortcut
print("squeezeShorcutEnabled \(CACurrentMediaTime())")
}
// PencilHandler.autoHoverTermination = selectedProfile.autoPencilHoverTermination
PencilHandler.hoverMode = selectedProfile.pencilHoverMode
pressureCurveEnabled = selectedProfile.pressureCurveEnabled
if #available(iOS 15.0, *) {
IAPManager.checkPurchaseInfo(.PencilProPack) { info in
self.pressureCurveEnabled = self.pressureCurveEnabled && info.valid
self.pencilTickEnabled = self.pencilTickEnabled && info.valid
self.pencilProEnabled = info.valid
PencilHandler.pencilPausesNativeTouch = selectedProfile.pencilPausesNativeTouch && info.valid
if info.valid {
self.setupPencilInteraction(view: self.streamView)
}
}
}
let persistedCurvePoints = PressureCurve.importCurvePoints(selectedProfile.pressureCurvePoints)
let curve = PressureCurve()
curve.polylinePoints = persistedCurvePoints
curve.buildCurveSegments()
DispatchQueue.main.asyncAfter(deadline: .now()) {
self.pressureLUT = PressureCurveLUT(curve: curve)
}
}
// MARK: - Touch Events
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let event = event else { return }
PencilHandler.isDrawing = true
// isFirstMove = true
moveEventIndex = 0
for touch in touches {
let coalesced = event.coalescedTouches(for: touch) ?? []
_ = self.sendStylusEvent(touchBatch: pencilTickEnabled ? coalesced : [touch])
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let event = event else { return }
for touch in touches {
let coalesced = event.coalescedTouches(for: touch) ?? []
_ = self.sendStylusEvent(touchBatch: pencilTickEnabled ? coalesced : [touch])
}
moveEventIndex += 1
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let event = event else { return }
for touch in touches {
let coalesced = event.coalescedTouches(for: touch) ?? []
_ = self.sendStylusEvent(touchBatch: pencilTickEnabled ? coalesced : [touch])
}
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
self.touchesEnded(touches, with: event)
}
// MARK: - Core Logic
func getVideoAreaSize()-> CGSize {
guard let streamView = streamView else { return CGSize(width: 0, height: 0) }
if streamView.bounds.size.width > streamView.bounds.size.height * CGFloat(streamAspectRatio) {
return CGSizeMake(streamView.bounds.size.height * CGFloat(streamAspectRatio), streamView.bounds.size.height);
} else {
return CGSizeMake(streamView.bounds.size.width, streamView.bounds.size.width / CGFloat(streamAspectRatio));
}
}
func adjustCoordinatesForVideoArea(point: CGPoint) -> CGPoint {
guard let streamView = streamView else { return CGPoint(x: 0, y: 0) }
var x = point.x - streamView.bounds.origin.x
var y = point.y - streamView.bounds.origin.y
if x < streamView.bounds.width / 2 {
x -= 1
} else {
x += 1
}
if y < streamView.bounds.height / 2 {
y -= 1
} else {
y += 1
}
let videoSize = getVideoAreaSize() // 返回 CGSize
let videoOrigin = CGPoint(
x: streamView.bounds.width / 2 - videoSize.width / 2,
y: streamView.bounds.height / 2 - videoSize.height / 2
)
let confinedX = min(max(x, videoOrigin.x), videoOrigin.x + videoSize.width) - videoOrigin.x
let confinedY = min(max(y, videoOrigin.y), videoOrigin.y + videoSize.height) - videoOrigin.y
return CGPoint(x: confinedX, y: confinedY)
}
func getRotation(fromAzimuthAngle azimuthAngle: Float) -> UInt16 {
var rotationAngle = (azimuthAngle - .pi / 2) * (180.0 / .pi) // 弧度转角度
if rotationAngle < 0 {
rotationAngle += 360
}
return UInt16(rotationAngle)
}
func getTilt(fromAltitudeAngle altitudeAngle: Float) -> UInt8 {
let altitudeDegs = abs(Int16(altitudeAngle * (180.0 / .pi)))
return UInt8(90 - min(90, Int(altitudeDegs)))
}
private func sendStylusEvent(touchBatch: [UITouch], with event: UIEvent? = nil) -> DispatchTime {
guard let streamView = streamView, touchBatch.count>0 else { return .now() }
var tickMoment: TimeInterval = 0
var previousTimeStamp: TimeInterval = 0
var delay:TimeInterval = 0
var dispatchMoment: DispatchTime = .now()
for touch in touchBatch {
let point = touch.preciseLocation(in: streamView)
let azimuth = touch.azimuthAngle(in: streamView)
let altitude = touch.altitudeAngle
let location = self.adjustCoordinatesForVideoArea(point: point)
let videoSize = self.getVideoAreaSize()
let normalizedLocation = CGPoint(x: location.x/videoSize.width, y: location.y/videoSize.height)
let force = Float(touch.force/touch.maximumPossibleForce)/sin(Float(altitude))
var targetForce = pressureCurveEnabled ? self.pressureLUT.value(at: force) : force
let eventType:UInt8
switch touch.phase {
case .began:
eventType = UInt8(manualHoverFlag ? LI_TOUCH_EVENT_HOVER : LI_TOUCH_EVENT_DOWN)
touchBeganForce = targetForce
case .moved:
eventType = UInt8(manualHoverFlag ? LI_TOUCH_EVENT_HOVER : LI_TOUCH_EVENT_MOVE)
targetForce = moveEventIndex < initialMoveEventIndexLimit ? touchBeganForce : targetForce
case .ended:
eventType = UInt8(manualHoverFlag ? LI_TOUCH_EVENT_HOVER_LEAVE : LI_TOUCH_EVENT_UP)
case .cancelled:
eventType = UInt8(manualHoverFlag ? LI_TOUCH_EVENT_HOVER_LEAVE : LI_TOUCH_EVENT_UP)
default:
eventType = UInt8(LI_TOUCH_EVENT_HOVER)
}
delay = manualTick ? 0.0086 : 0.0086
delay = eventType == UInt8(LI_TOUCH_EVENT_UP) ? delay : 0
if previousTimeStamp == 0 {
previousTimeStamp = touch.timestamp
tickMoment = 0
dispatchMoment = .now()
}
else {
tickMoment += (manualTick ? tickInterval : touch.timestamp - previousTimeStamp)
}
let sendableForce = targetForce
DispatchQueue.global().asyncAfter(deadline: dispatchMoment + tickMoment + delay) {
/*
if PencilHandler.autoHoverEnabled, eventType == UInt8(LI_TOUCH_EVENT_DOWN) {
LiSendPenEvent(UInt8(LI_TOUCH_EVENT_HOVER_LEAVE), UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), 0, 0, 0, self.getRotation(fromAzimuthAngle: Float(azimuth)), self.getTilt(fromAltitudeAngle: Float(altitude)))
}
*/
LiSendPenEvent(eventType, UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), sendableForce, 0, 0, self.getRotation(fromAzimuthAngle: Float(azimuth)), self.getTilt(fromAltitudeAngle: Float(altitude)))
if eventType == UInt8(LI_TOUCH_EVENT_UP) {
PencilHandler.isDrawing = false
if PencilHandler.hoverMode == .HoverDisabled || !PencilHandler.hoverSupported {
LiSendPenEvent(UInt8(LI_TOUCH_EVENT_HOVER), UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), 0, 0, 0, self.getRotation(fromAzimuthAngle: Float(azimuth)), self.getTilt(fromAltitudeAngle: Float(altitude)))
DispatchQueue.global().asyncAfter(deadline: .now() + 0.0086){
if !PencilHandler.isDrawing {
LiSendPenEvent(UInt8(LI_TOUCH_EVENT_HOVER_LEAVE), UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), 0, 0, 0, self.getRotation(fromAzimuthAngle: Float(azimuth)), self.getTilt(fromAltitudeAngle: Float(altitude)))
}
}
}
}
}
}
return dispatchMoment + tickMoment + delay
}
@objc public func switchPencilHover(){
manualHoverFlag = !manualHoverFlag
}
@objc public func enablePencilHover(){
manualHoverFlag = true
}
@objc public func disablePencilHover(){
manualHoverFlag = false
}
@available(iOS 12.1, *)
private func setupPencilInteraction(view:UIView?) {
let interaction = UIPencilInteraction()
guard let view else { return }
if pencilInteraction != nil {return}
interaction.delegate = self
view.addInteraction(interaction)
pencilInteraction = interaction
}
@available(iOS 12.1, *)
func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
if !pencilProEnabled {return}
if doubleTapShorcuts.isEmpty {return}
let keyStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: doubleTapShorcuts[shortcutIndex])
print("pencilInteractionDidTap \(CACurrentMediaTime())")
CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: keyStrings, delay: 0.1)
shortcutIndex = (shortcutIndex + 1) % doubleTapShorcuts.count
}
@available(iOS 17.5, *)
func pencilInteraction(
_ interaction: UIPencilInteraction,
didReceiveSqueeze squeeze: UIPencilInteraction.Squeeze
) {
if !pencilProEnabled {return}
if PencilHandler.squeezeStartShortcut == "", PencilHandler.squeezeEndShortcut == "" {return}
let keepPressedUntilRelease = PencilHandler.squeezeEndShortcut.uppercased() == "NULL"
let squeezePressKeyStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: PencilHandler.squeezeStartShortcut)
let squeezeReleaseKeyStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: PencilHandler.squeezeEndShortcut)
guard let squeezePressKeyStrings = squeezePressKeyStrings else {return}
guard squeezePressKeyStrings.count>0 else {return}
switch squeeze.phase {
case .began:
if keepPressedUntilRelease {CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: squeezePressKeyStrings, delay: 0.1, pressOnly: true)}
else {CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: squeezePressKeyStrings, delay: 0.1)}
break
case .ended, .cancelled:
if keepPressedUntilRelease {CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: squeezePressKeyStrings, releaseOnly: true)}
else {CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: squeezeReleaseKeyStrings, delay: 0.1)}
break
default:
break
}
}
@objc static private(set) var eraserShortcut:String = ""
@objc static private(set) var brushShortcut:String = ""
private var doubleTapShorcuts: Array<String> = []
private var shortcutIndex: Int = 0
@objc func replaceBrush(with shortcut:String){
if let i = doubleTapShorcuts.indices.last {
doubleTapShorcuts[i] = shortcut
}
}
@objc func replaceEraser(with shortcut:String){
if let i = doubleTapShorcuts.indices.first {
doubleTapShorcuts[i] = shortcut
}
}
@objc static public func enterDoubleTapShortcuts(in viewController: UIViewController){
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
selectedProfile = oscProfileMan.getSelectedProfile()
guard let selectedProfile = selectedProfile else {return}
let alert = UIAlertController(title: SwiftLocalizationHelper.localizedString(forKey: "Eraser Shortcut"),
message: SwiftLocalizationHelper.localizedString(forKey: "Enter eraser keyboard shortcut:"),
preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = SwiftLocalizationHelper.localizedString(forKey:"Example: e, ctrl+e, alt+e ...")
textField.keyboardType = .asciiCapable
textField.autocorrectionType = .no
textField.spellCheckingType = .no
textField.text = selectedProfile.eraserShortcut
}
let okAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "OK"), style: .default) { _ in
let comboButtons = alert.textFields?[0].text ?? ""
let keyStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: comboButtons)
if keyStrings?.count ?? 0 > 0 || comboButtons == "" {
eraserShortcut = comboButtons
}
enterBrushShortcut(in: viewController)
}
let learnMoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Learn More"), style: .default) { _ in
if let url = URL(string: SwiftLocalizationHelper.localizedString(forKey: "pencilKeyboardCmdURL")) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
alert.addAction(learnMoreAction)
alert.addAction(okAction)
viewController.present(alert, animated: true, completion: {
})
}
@objc static public func enterBrushShortcut(in viewController: UIViewController){
let alert = UIAlertController(title: SwiftLocalizationHelper.localizedString(forKey: "Brush Shortcut"),
message: SwiftLocalizationHelper.localizedString(forKey: "Enter brush keyboard shortcut:"),
preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = SwiftLocalizationHelper.localizedString(forKey:"Example: b, ctrl+b, alt+b ...")
textField.keyboardType = .asciiCapable
textField.autocorrectionType = .no
textField.spellCheckingType = .no
textField.text = selectedProfile?.brushShortcut
}
let okAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "OK"), style: .default) { _ in
let comboButtons = alert.textFields?[0].text ?? ""
let keyStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: comboButtons)
if keyStrings?.count ?? 0 > 0 || comboButtons == "" {
brushShortcut = comboButtons
}
if selectedProfile?.brushShortcut != brushShortcut
|| selectedProfile?.eraserShortcut != eraserShortcut {
guard let selectedProfile = selectedProfile else {return}
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
selectedProfile.brushShortcut = brushShortcut
selectedProfile.eraserShortcut = eraserShortcut
oscProfileMan.replaceSelectedProfile(with: selectedProfile, overwriteDefault: true)
}
}
let learnMoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Learn More"), style: .default) { _ in
if let url = URL(string: SwiftLocalizationHelper.localizedString(forKey: "pencilKeyboardCmdURL")) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
alert.addAction(learnMoreAction)
alert.addAction(okAction)
viewController.present(alert, animated: true, completion: {
})
}
@objc static private(set) var squeezeStartShortcut:String = ""
@objc static private(set) var squeezeEndShortcut:String = ""
@objc static public func enterSqueezeShortcuts(in viewController: UIViewController){
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
selectedProfile = oscProfileMan.getSelectedProfile()
guard let selectedProfile = selectedProfile else {return}
let alert = UIAlertController(title: SwiftLocalizationHelper.localizedString(forKey: "Squeeze Shortcut"),
message: SwiftLocalizationHelper.localizedString(forKey: "enterSqueezePressShort"),
preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = SwiftLocalizationHelper.localizedString(forKey:"Example: e, ctrl+e, alt+e ...")
textField.keyboardType = .asciiCapable
textField.autocorrectionType = .no
textField.spellCheckingType = .no
textField.text = selectedProfile.squeezeStartShortcut
}
let okAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "OK"), style: .default) { _ in
let comboButtons = alert.textFields?[0].text ?? ""
let keyStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: comboButtons)
if keyStrings?.count ?? 0 > 0 || comboButtons == "" {
squeezeStartShortcut = comboButtons
}
enterSqueezeEndShortcut(in: viewController)
}
let learnMoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Learn More"), style: .default) { _ in
if let url = URL(string: SwiftLocalizationHelper.localizedString(forKey: "pencilKeyboardCmdURL")) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
alert.addAction(learnMoreAction)
alert.addAction(okAction)
viewController.present(alert, animated: true, completion: {
})
}
@objc static public func enterSqueezeEndShortcut(in viewController: UIViewController){
let alert = UIAlertController(title: SwiftLocalizationHelper.localizedString(forKey: "Squeeze Shortcut"),
message: SwiftLocalizationHelper.localizedString(forKey: "enterSqueezeReleaseShort"),
preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = SwiftLocalizationHelper.localizedString(forKey:"Example: b, ctrl+b, alt+b ...")
textField.keyboardType = .asciiCapable
textField.autocorrectionType = .no
textField.spellCheckingType = .no
textField.text = selectedProfile?.squeezeEndShortcut
}
let okAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "OK"), style: .default) { _ in
let comboButtons = alert.textFields?[0].text ?? ""
let keyStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: comboButtons)
if keyStrings?.count ?? 0 > 0 || comboButtons == "" {
squeezeEndShortcut = comboButtons
}
if selectedProfile?.squeezeEndShortcut != squeezeEndShortcut
|| selectedProfile?.squeezeStartShortcut != squeezeStartShortcut {
guard let selectedProfile = selectedProfile else {return}
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
selectedProfile.squeezeEndShortcut = squeezeEndShortcut
selectedProfile.squeezeStartShortcut = squeezeStartShortcut
oscProfileMan.replaceSelectedProfile(with: selectedProfile, overwriteDefault: true)
}
}
let learnMoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Learn More"), style: .default) { _ in
if let url = URL(string: SwiftLocalizationHelper.localizedString(forKey: "pencilKeyboardCmdURL")) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
alert.addAction(learnMoreAction)
alert.addAction(okAction)
viewController.present(alert, animated: true, completion: {
})
}
private func attachHoverLeave(normalizedLocation:CGPoint){
DispatchQueue.global().asyncAfter(deadline: .now()+0.0086) {
LiSendPenEvent(UInt8(LI_TOUCH_EVENT_HOVER), UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), 0, 0, 0, 0, 0)
DispatchQueue.global().asyncAfter(deadline: .now()+0.0086) {
// LiSendPenEvent(UInt8(LI_TOUCH_EVENT_HOVER_LEAVE), UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), 0, 0, 0, 0, 0)
}
}
}
private func preTouchHoverActionAt(normalizedLocation:CGPoint){
DispatchQueue.global().asyncAfter(deadline: .now()+0.0086) {
LiSendPenEvent(UInt8(LI_TOUCH_EVENT_HOVER), UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), 0, 0, 0, 0, 0)
DispatchQueue.global().asyncAfter(deadline: .now()+0.0086) {
LiSendPenEvent(UInt8(LI_TOUCH_EVENT_HOVER_LEAVE), UInt8(LI_TOOL_TYPE_PEN), 0, Float(normalizedLocation.x), Float(normalizedLocation.y), 0, 0, 0, 0, 0)
}
}
}
}
+734
View File
@@ -0,0 +1,734 @@
//
// PressureCurve.swift
// VoidLink
//
// Created by True砖家 on 2026/1/5.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
import UIKit
// MARK: - PressureCurve
final class PressureCurve {
var polylinePoints: [CGPoint] = [
CGPoint(x: 0, y: 0.0),
CGPoint(x: 1.0, y: 1.0)
]
private var factor: CGFloat = 0.39
private var cachedSegments: [(p0: CGPoint, c1: CGPoint, c2: CGPoint, p1: CGPoint)] = []
private var curveCutPoints: [CGPoint] {
guard polylinePoints.count >= 2 else { return polylinePoints }
var cuts: [CGPoint] = []
cuts.append(polylinePoints[0]) // 起点
for i in 0..<(polylinePoints.count - 2) {
let mid = CGPoint(
x: (polylinePoints[i+1].x + polylinePoints[i+2].x)/2,
y: (polylinePoints[i+1].y + polylinePoints[i+2].y)/2
)
if i < polylinePoints.count - 3 {
cuts.append(mid)
}
}
cuts.append(polylinePoints.last!) // 终点
return cuts
}
private func curveFactor(for middlePointCount: Int) -> CGFloat {
guard middlePointCount > 0 else { return 2.0 / 3.0 }
let n = CGFloat(middlePointCount)
return 0.30538319 * pow(n, -2.1518917) + 0.36128348
}
public func buildCurveSegments() {
let cuts = curveCutPoints
var segments: [(CGPoint, CGPoint, CGPoint, CGPoint)] = []
factor = curveFactor(for: polylinePoints.count-2)
for i in 0..<(cuts.count - 1) {
let p0 = cuts[i]
let p1 = cuts[i+1]
// segment index
let startIndex = i
let endIndex = i+1
let tangentStart: CGPoint
if startIndex < polylinePoints.count - 1 {
tangentStart = CGPoint(
x: (polylinePoints[startIndex + 1].x - polylinePoints[startIndex].x) * factor,
y: (polylinePoints[startIndex + 1].y - polylinePoints[startIndex].y) * factor
)
} else {
tangentStart = .zero
}
let tangentEnd: CGPoint
if endIndex < polylinePoints.count - 1 {
tangentEnd = CGPoint(
x: (polylinePoints[endIndex + 1].x - polylinePoints[endIndex].x) * factor,
y: (polylinePoints[endIndex + 1].y - polylinePoints[endIndex].y) * factor
)
} else {
tangentEnd = .zero
}
let c1 = CGPoint(x: p0.x + tangentStart.x, y: p0.y + tangentStart.y)
let c2 = CGPoint(x: p1.x - tangentEnd.x, y: p1.y - tangentEnd.y)
segments.append((p0: p0, c1: c1, c2: c2, p1: p1))
}
cachedSegments = segments
}
/// 采样 曲线
func sampleCurve(stepsPerSegment: Int = 50) -> [CGPoint] {
var points: [CGPoint] = []
buildCurveSegments()
for seg in cachedSegments {
let sampled = sampleCurve(p0: seg.p0, c1: seg.c1, c2: seg.c2, p1: seg.p1, steps: stepsPerSegment)
if !points.isEmpty { points.removeLast() } // 避免重复点
points.append(contentsOf: sampled)
}
return points
}
private func sampleCurve(p0: CGPoint, c1: CGPoint, c2: CGPoint, p1: CGPoint, steps: Int) -> [CGPoint] {
var result: [CGPoint] = []
for i in 0...steps {
let t = CGFloat(i)/CGFloat(steps)
let mt = 1-t
let x = mt*mt*mt*p0.x + 3*mt*mt*t*c1.x + 3*mt*t*t*c2.x + t*t*t*p1.x
let y = mt*mt*mt*p0.y + 3*mt*mt*t*c1.y + 3*mt*t*t*c2.y + t*t*t*p1.y
result.append(CGPoint(x:x, y:y))
}
return result
}
var tangentPoints: [CGPoint] { polylinePoints }
func value(at x: CGFloat) -> CGFloat {
guard let firstPoint = polylinePoints.first else { return 0 }
if x < firstPoint.x {return 0}
let xClamped = min(max(x, 0), 1)
let segments = cachedSegments
for seg in segments {
if xClamped >= seg.p0.x && xClamped <= seg.p1.x {
let t = solveT(forX: xClamped,
p0: seg.p0.x,
c1: seg.c1.x,
c2: seg.c2.x,
p1: seg.p1.x)
return cubic(p0: seg.p0.y,
c1: seg.c1.y,
c2: seg.c2.y,
p1: seg.p1.y,
t: t)
}
}
return segments.last?.p1.y ?? 0
}
private func solveT(
forX x: CGFloat,
p0: CGFloat,
c1: CGFloat,
c2: CGFloat,
p1: CGFloat,
iterations: Int = 18
) -> CGFloat {
var low: CGFloat = 0
var high: CGFloat = 1
var t: CGFloat = 0.5
for _ in 0..<iterations {
t = (low + high) * 0.5
let xt = cubic(p0: p0, c1: c1, c2: c2, p1: p1, t: t)
if xt < x {
low = t
} else {
high = t
}
}
return t
}
@inline(__always)
private func cubic(
p0: CGFloat,
c1: CGFloat,
c2: CGFloat,
p1: CGFloat,
t: CGFloat
) -> CGFloat {
let mt = 1 - t
return mt*mt*mt*p0
+ 3*mt*mt*t*c1
+ 3*mt*t*t*c2
+ t*t*t*p1
}
static func exportCurvePoints(_ curve: PressureCurve) -> [NSNumber] {
var arr: [NSNumber] = []
for p in curve.polylinePoints {
arr.append(NSNumber(value: Float(p.x)))
arr.append(NSNumber(value: Float(p.y)))
}
return arr
}
static func importCurvePoints(_ numbers: [NSNumber]) -> [CGPoint] {
var pts: [CGPoint] = []
let count = numbers.count / 2
for i in 0..<count {
let x = CGFloat(numbers[i*2].floatValue)
let y = CGFloat(numbers[i*2 + 1].floatValue)
pts.append(CGPoint(x: x, y: y))
}
return pts
}
static func generateLinearCurvePoints(from pressures: [CGFloat]) -> [CGPoint] {
guard !pressures.isEmpty else { return [] }
let samples = pressures.map { min(max($0, 0), 1) }
let maxPressure = samples.max() ?? 0
if maxPressure < 1 {
let platformX = maxPressure
return [
CGPoint(x: 0, y: 0),
CGPoint(x: platformX, y: 1),
CGPoint(x: platformX, y: 1),
CGPoint(x: 1, y: 1)
]
} else {
return [
CGPoint(x: 0, y: 0),
CGPoint(x: 1, y: 1)
]
}
}
}
// MARK: - PressureCurveView
final class PressureCurveView: UIView {
let curve = PressureCurve()
private var draggingIndex: Int? = nil
private let hitRadius: CGFloat = 35
enum PressureTestStage: UInt8 {
case drawingStage
case curveStage
}
public var testStage: PressureTestStage = .curveStage
private var pressures:[CGFloat] = []
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
}
required init?(coder: NSCoder) { fatalError() }
public var showGraph: Bool = false {
didSet { setNeedsDisplay() } // 改变开关立即刷新
}
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
ctx.saveGState()
// 缩放和平移
let scale: CGFloat = 0.95
let dx = (bounds.width - bounds.width * scale) / 2
let dy = (bounds.height - bounds.height * scale) / 2
ctx.translateBy(x: dx, y: dy)
ctx.scaleBy(x: scale, y: scale)
if showGraph {
drawGrid(ctx)
drawCurve(ctx)
drawTangents(ctx)
}
ctx.restoreGState()
}
private func drawGrid(_ ctx: CGContext) {
ctx.setStrokeColor(UIColor.darkGray.cgColor)
ctx.setLineWidth(1)
for i in 0...4 {
let t = CGFloat(i)/4
let x = bounds.width*t
let y = bounds.height*t
ctx.move(to: CGPoint(x:x, y:0))
ctx.addLine(to: CGPoint(x:x, y:bounds.height))
ctx.move(to: CGPoint(x:0, y:y))
ctx.addLine(to: CGPoint(x:bounds.width, y:y))
}
ctx.strokePath()
}
private func drawCurve(_ ctx: CGContext) {
ctx.setStrokeColor(UIColor.systemBlue.cgColor)
ctx.setLineWidth(6)
let points = curve.sampleCurve(stepsPerSegment: 60)
for (i,p) in points.enumerated() {
let mapped = map(p)
if i==0 { ctx.move(to:mapped) } else { ctx.addLine(to:mapped) }
}
ctx.strokePath()
}
private func drawTangents(_ ctx: CGContext) {
let pts = curve.tangentPoints
ctx.setStrokeColor(UIColor.systemPurple.cgColor)
ctx.setLineWidth(2)
for i in 0..<pts.count {
let p = map(pts[i])
if i==0 { ctx.move(to:p) } else { ctx.addLine(to:p) }
}
ctx.strokePath()
for p in pts {
let c = map(p)
let r: CGFloat = 16
ctx.setFillColor(UIColor.systemPurple.cgColor)
ctx.fill(CGRect(x:c.x-r/2, y:c.y-r/2, width:r, height:r))
}
}
private func map(_ p: CGPoint) -> CGPoint {
CGPoint(x:p.x*bounds.width, y:(1-p.y)*bounds.height)
}
private func unmap(_ p: CGPoint) -> CGPoint {
CGPoint(
x: p.x / bounds.width,
y: 1 - (p.y / bounds.height)
)
}
private func hitTestPoint(_ location: CGPoint) -> Int? {
let pts = curve.tangentPoints
for (i, p) in pts.enumerated() {
let c = map(p)
let dx = c.x - location.x
let dy = c.y - location.y
if dx*dx + dy*dy <= hitRadius*hitRadius {
return i
}
}
return nil
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
if testStage == .drawingStage {
return
}
draggingIndex = hitTestPoint(location)
if draggingIndex == nil {
guard
let touch = touches.first
else { return }
let location = touch.location(in: self)
var point = unmap(location)
point.x = min(max(point.x, 0), 1)
point.y = min(max(point.y, 0), 1)
var insertIdx:Int = -1
for (idx, polylinePoint) in curve.polylinePoints.enumerated() {
if point.x <= polylinePoint.x {
insertIdx = idx
break
}
}
if insertIdx >= 0 {
curve.polylinePoints.insert(point, at: insertIdx)
draggingIndex = insertIdx
}
setNeedsDisplay()
}
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
if testStage == .drawingStage {
let altitude = touches.first!.altitudeAngle
let force = (touches.first!.force/touches.first!.maximumPossibleForce)/sin(altitude)
pressures.append(force)
return
}
guard
let touch = touches.first,
let idx = draggingIndex
else { return }
let location = touch.location(in: self)
var point = unmap(location)
if draggingIndex == 0 {
let nextPoint = curve.polylinePoints[idx+1]
point.x = min(max(point.x, 0), nextPoint.x)
// point.x = 0
point.y = min(max(point.y, 0), 1)
}
else if draggingIndex == curve.polylinePoints.count-1 {
point.x = 1
point.y = min(max(point.y, 0), 1)
}
else {
let nextPoint = curve.polylinePoints[idx+1]
let previousPoint = curve.polylinePoints[idx-1]
point.x = min(max(point.x, previousPoint.x), nextPoint.x)
point.y = min(max(point.y, 0), 1)
}
// print("currenPoint \(point)")
curve.polylinePoints[idx] = point
setNeedsDisplay()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
if testStage == .drawingStage {
if pressures.isEmpty { return }
curve.polylinePoints = PressureCurve.generateLinearCurvePoints(from: pressures)
testStage = .curveStage
showGraph = true
let parentVC = parentViewController as! PressureCurveViewController
parentVC.setupNavigationBar()
// parentVC.showCurveStageTips()
}
draggingIndex = nil
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
draggingIndex = nil
}
}
final class PressureCurveLUT {
static let levels = 2048
public let table: [Float]
init(curve: PressureCurve) {
curve.buildCurveSegments()
var t = [Float](repeating: 0, count: Self.levels)
let maxIndex = Float(Self.levels - 1)
for i in 0..<Self.levels {
let x = CGFloat(Float(i) / maxIndex)
let y = curve.value(at: x)
t[i] = Float(y)
}
self.table = t
}
@inline(__always)
func value(at x: Float) -> Float {
let clamped = min(max(x, 0), 1)
let index = Int((clamped * Float(Self.levels - 1)).rounded())
return table[index]
}
}
extension UIView {
var parentViewController: UIViewController? {
var responder: UIResponder? = self
while let r = responder {
if let vc = r as? UIViewController {
return vc
}
responder = r.next
}
return nil
}
}
// MARK: - ViewController
class PressureCurveViewController: UIViewController {
private var navBar = UINavigationBar()
private var container = UIView()
private var curveView = PressureCurveView()
private var minPressure: CGFloat = 0
private var maxPressure: CGFloat = 1
private var isFirstLaunch: Bool = GenericUtils.isFirstLaunchPressureCurveTool()
override func viewDidLoad() {
super.viewDidLoad()
self.view.isOpaque = false
self.view.backgroundColor = UIColor.clear // 半透明遮罩
let curveWidth: CGFloat = 530
let curveHeight: CGFloat = 530
curveView = PressureCurveView(frame: CGRect(x: 0, y: 0, width: curveWidth, height: curveHeight))
curveView.center = self.view.center
curveView.backgroundColor = .white
curveView.layer.cornerRadius = 0
curveView.layer.masksToBounds = true
container = UIView(frame: CGRect(x: 0, y: 0, width: curveWidth+10, height: curveHeight+50))
container.center = CGPoint(x: self.view.bounds.midX, y: self.view.bounds.midY-20)
container.backgroundColor = .white
container.layer.cornerRadius = 12
container.layer.masksToBounds = true
container.layer.borderWidth = 2
container.layer.borderColor = ThemeManager.separatorColor.cgColor
container.addSubview(curveView)
self.loadPersistedCurve()
self.curveView.showGraph = true
self.setupNavigationBar()
self.view.addSubview(container)
curveView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
curveView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
curveView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
curveView.widthAnchor.constraint(equalToConstant: curveWidth),
curveView.heightAnchor.constraint(equalToConstant: curveHeight)
])
NotificationCenter.default.addObserver(
self,
selector: #selector(pencilProPurchaseAborted(_:)),
name: AddOnProduct.PencilProPack.purchaseAbortedNotification(),
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(persistCurve),
name: AddOnProduct.PencilProPack.purchaseSucceededNotification(),
object: nil
)
}
override func viewDidAppear(_ animated: Bool) {
/*
AlertControllerUtil.cancelButtonString = SwiftLocalizationHelper.localizedString(forKey: "No")
AlertControllerUtil.showAlert(
in: self,
title: SwiftLocalizationHelper.localizedString(forKey: "Pen Pressure Curve"),
message: SwiftLocalizationHelper.localizedString(forKey:"Reset the pen pressure curve by starting from the pen pressure test?"),
withCancel: true,
buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "Yes"),
countdown: 0,
action: {},
completion: {
if AlertControllerUtil.actionCancelled {
self.curveView.showGraph = true
self.curveView.testStage = .curveStage
self.curveView.setNeedsDisplay()
self.setupNavigationBar()
self.showCurveStageTips()
}
else {
self.pressureRangeTest()
}
}
) */
if isFirstLaunch {
showCurveStageTips()
}
}
override func viewWillDisappear(_ animated: Bool) {
NotificationCenter.default.removeObserver(self)
}
private func loadPersistedCurve() {
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
let persistedCurvePoints = PressureCurve.importCurvePoints(oscProfileMan.getSelectedProfile().pressureCurvePoints)
curveView.curve.polylinePoints = persistedCurvePoints
curveView.setNeedsDisplay()
}
public func setupNavigationBar() {
// 1. 创建 UINavigationBar
navBar.removeFromSuperview()
navBar = UINavigationBar()
navBar.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(navBar)
var navItem = UINavigationItem()
if #available(iOS 13.0, *) {
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground() // 透明背景
appearance.shadowColor = nil // 去掉底部细线
appearance.backgroundColor = .clear // 可以额外设置完全透明
navBar.standardAppearance = appearance
navBar.scrollEdgeAppearance = appearance
} else {
navBar.setBackgroundImage(UIImage(), for: .default)
navBar.shadowImage = UIImage() // 去掉底部线
navBar.isTranslucent = true
navBar.backgroundColor = .clear
}
// 2. 添加约束 (顶部,左右)
NSLayoutConstraint.activate([
navBar.topAnchor.constraint(equalTo: container.safeAreaLayoutGuide.topAnchor),
navBar.leadingAnchor.constraint(equalTo: container.leadingAnchor),
navBar.trailingAnchor.constraint(equalTo: container.trailingAnchor),
navBar.heightAnchor.constraint(equalToConstant: 42) // 导航栏标准高度
])
if curveView.testStage == .curveStage {
let resetButton = UIBarButtonItem(title: SwiftLocalizationHelper.localizedString(forKey: "Reset"), style: .plain, target: self, action: #selector(resetTapped))
let rangeTestButton = UIBarButtonItem(title: SwiftLocalizationHelper.localizedString(forKey: "Pressure-range-test"), style: .plain, target: self, action: #selector(pressureRangeTest))
let saveButton = UIBarButtonItem(title: SwiftLocalizationHelper.localizedString(forKey: "Save"), style: .plain, target: self, action: #selector(saveTapped))
let quitButton = UIBarButtonItem(title: SwiftLocalizationHelper.localizedString(forKey: "Exit"), style: .plain, target: self, action: #selector(exitTapped))
navItem.leftBarButtonItems = [resetButton, rangeTestButton]
navItem.rightBarButtonItems = [quitButton, saveButton]
}
if curveView.testStage == .drawingStage {
navItem = UINavigationItem(title: SwiftLocalizationHelper.localizedString(forKey: "Pressure Range Test"))
}
// 4. 将 UINavigationItem 添加到导航栏
navBar.items = [navItem]
}
public func showCurveStageTips() {
AlertControllerUtil.showAlert(
in: self,
title: SwiftLocalizationHelper.localizedString(forKey: "Pen Pressure Curve"),
message: SwiftLocalizationHelper.localizedString(forKey:"Adjust the pressure curve by adding or dragging the purple squares."),
withCancel: false,
buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "This tip won't be shown again"),
countdown: 6
)
}
@objc private func resetTapped() {
curveView.curve.polylinePoints = [CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 1)]
DispatchQueue.main.async {
self.curveView.setNeedsDisplay()
}
}
@objc private func pencilProPurchaseAborted(_ notification: Notification) {
guard let interruption = notification.object as? PurchaseInterruption else { return }
if interruption != .learnMore {
resetTapped()
}
if interruption == .lowOSVersion {
AlertControllerUtil.showAlert(
in: self,
title: "",
message: SwiftLocalizationHelper.localizedString(forKey:"PencilProPackLowOSVersionTip"),
withCancel: false,
buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "OK"),
countdown: 0)
}
}
@objc private func pressureRangeTest() {
AlertControllerUtil.showAlert(
in: self,
title: SwiftLocalizationHelper.localizedString(forKey: "Pressure Range Test"),
message: SwiftLocalizationHelper.localizedString(forKey:"pressureRangeTestTip"),
withCancel: true,
buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "OK"),
countdown: 0,
completion:{
if AlertControllerUtil.actionCancelled { return }
self.resetTapped()
self.curveView.showGraph = false
self.curveView.testStage = .drawingStage
self.curveView.setNeedsDisplay()
self.setupNavigationBar()
})
}
@objc private func persistCurve() {
let oscProfileMan = OSCProfilesManager.sharedManager(CGRectZero)
let selectedProfile = oscProfileMan.getSelectedProfile()
selectedProfile.pressureCurvePoints = PressureCurve.exportCurvePoints(curveView.curve)
oscProfileMan.replaceSelectedProfile(with: selectedProfile, overwriteDefault: true)
DispatchQueue.main.async {
if self.isFirstLaunch {
AlertControllerUtil.showAlert(
in: self,
title: SwiftLocalizationHelper.localizedString(forKey: "Pen Pressure Curve"),
message: SwiftLocalizationHelper.localizedString(forKey:"firstPressureCurvePersistTip"),
withCancel: false,
buttonTitle: SwiftLocalizationHelper.localizedString(forKey: "This tip won't be shown again"),
countdown: 11,
completion: {
PencilHandler.shared?.setupPressureLUT()
})
self.isFirstLaunch = false
}
else{
AlertControllerUtil.autoCompletion = true
AlertControllerUtil.showAlert(
in: self,
title: SwiftLocalizationHelper.localizedString(forKey: ""),
message: SwiftLocalizationHelper.localizedString(forKey:"Pressure curve saved"),
withCancel: false,
buttonTitle: "",
countdown: 1,
completion: {
PencilHandler.shared?.setupPressureLUT()
})
}
}
}
@objc private func saveTapped() {
IAPManager.checkPurchaseInfo(.PencilProPack) { info in
if info.valid {
self.persistCurve()
}
else{
IAPManager.inAppPurchaseAction(viewController: self, product: .PencilProPack)
}
}
}
@objc private func exitTapped() {
self.dismiss(animated: true)
}
}
+2
View File
@@ -14,6 +14,8 @@
NS_ASSUME_NONNULL_BEGIN
@interface PureNativeTouchHandler : UIResponder
- (id)initWithView:(StreamView*)view andSettings:(TemporarySettings*)settings;
+75 -45
View File
@@ -15,11 +15,9 @@
#include <Limelight.h>
@implementation PureNativeTouchHandler {
StreamView* streamView;
TemporarySettings* currentSettings;
__weak StreamView* streamView;
bool activateCoordSelector;
CGFloat pointerVelocityDividerLocationByPoints;
uint16_t touchMoveEventIntervalUs;
bool asyncNativeTouch;
unsigned int touchDownQos;
@@ -34,29 +32,38 @@
NSMutableSet<NSNumber *> *pointerIdPool; //pre-defined pool of pointerIds.
NSMutableSet<NSNumber *> *unassignedPointerIds;
NSMutableSet *blacklistedTouches;
bool trackPointEnabled;
NSMutableArray<CAShapeLayer *> * trackPointPool;
NSMutableDictionary *pointerObjDict;
CGFloat slideGestureVerticalThreshold;
CGFloat screenWidthWithThreshold;
CGFloat EDGE_TOLERANCE;
CGFloat _edgeTolerance;
CGRect streamViewBounds;
int64_t moveEventIntervalNSec;
}
- (id)initWithView:(StreamView*)view andSettings:(TemporarySettings*)settings{
self = [super init];
self->streamView = view;
self->currentSettings = settings;
self->activateCoordSelector = currentSettings.pointerVelocityModeDivider.floatValue != 1.0;
self->touchMoveEventIntervalUs = currentSettings.touchMoveEventInterval.intValue;
self->activateCoordSelector = settings.pointerVelocityModeDivider.floatValue != 1.0;
self->moveEventIntervalNSec = (int64_t)(settings.touchMoveEventInterval.intValue * 1000);;
self->streamViewBounds = view.bounds;
self->pointerIdDict = [NSMutableDictionary dictionary];
self->pointerIdPool = [NSMutableSet set];
self->trackPointEnabled = settings.touchPointTracking;
self->trackPointPool = [NSMutableArray array];
for (uint8_t i = 0; i <= 10; i++) { //ipadOS supports upto 11 finger touches
[self->pointerIdPool addObject:@(i)];
[self->trackPointPool addObject:[GraphicUtils makeTouchTrackpointIn:streamView]];
}
self->activePointerIds = [NSMutableSet set];
self->blacklistedTouches = [NSMutableSet set];
@@ -81,12 +88,11 @@
default: break;
}
self->pointerObjDict = [NSMutableDictionary dictionary];
EDGE_TOLERANCE = 10.0;
_edgeTolerance = settings.edgeSlidingSensitivity.floatValue;
slideGestureVerticalThreshold = CGRectGetHeight([[UIScreen mainScreen] bounds]) * 0.4;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - EDGE_TOLERANCE;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - _edgeTolerance;
self->pointerVelocityDividerLocationByPoints = self->streamView.bounds.size.width * settings.pointerVelocityModeDivider.floatValue;
@@ -163,7 +169,7 @@
//check if touch point is spawned on the left or right upper half screen edges, event to remote PC. this is for better handling in-stream slide gesture
CGPoint initialPoint = [touch locationInView:self->streamView];
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < EDGE_TOLERANCE || initialPoint.x > screenWidthWithThreshold)) {
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < _edgeTolerance || initialPoint.x > screenWidthWithThreshold)) {
[blacklistedTouches addObject:touchAddrObj];
}
}
@@ -209,65 +215,75 @@
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
if (asyncNativeTouch) dispatch_async(dispatch_get_global_queue(touchDownQos, 0), ^{
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
[self handleTouchDown:touch]; //generate & populate pointerId
if(self->activateCoordSelector) [self populatePointerObjIntoDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_DOWN];
}
if(self->trackPointEnabled){
dispatch_async(dispatch_get_main_queue(), ^{
[CATransaction begin];
[CATransaction setDisableActions:YES];
for (UITouch* touch in touches){
uint8_t pointerId = [self retrievePointerIdFromDict:touch];
CAShapeLayer* trackPoint = self->trackPointPool[pointerId];
trackPoint.position = [touch locationInView:self->streamView];
trackPoint.hidden = false;
}
[CATransaction commit];
});
}
});
else{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
[self handleTouchDown:touch]; //generate & populate pointerId
if(self->activateCoordSelector) [self populatePointerObjIntoDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_DOWN];
}
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
if (asyncNativeTouch) dispatch_async(dispatch_get_global_queue(touchMoveQos, 0), ^{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, moveEventIntervalNSec), dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if(self->activateCoordSelector) [self updatePointerObjInDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_MOVE];
[[self getPointerObjFromDict:touch] doesNeedResetCoords]; // execute the judging of doesReachBoundary for current pointer instance. (happens after the event is sent to Sunshine service)
usleep(self->touchMoveEventIntervalUs);
[[self getPointerObjFromDict:touch] doesNeedResetCoords];
}
});
else {
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
if(self->activateCoordSelector) [self updatePointerObjInDict:touch];
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_MOVE];
[[self getPointerObjFromDict:touch] doesNeedResetCoords]; // execute the judging of doesReachBoundary for current pointer instance. (happens after the event is sent to Sunshine service)
// usleep(self->touchMoveEventIntervalUs);
}
if(self->trackPointEnabled){
dispatch_async(dispatch_get_main_queue(), ^{
[CATransaction begin];
[CATransaction setDisableActions:YES];
for (UITouch* touch in touches){
uint8_t pointerId = [self retrievePointerIdFromDict:touch];
CAShapeLayer* trackPoint = self->trackPointPool[pointerId];
trackPoint.position = [touch locationInView:self->streamView];
}
[CATransaction commit];
});
}
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
if(asyncNativeTouch) dispatch_async(dispatch_get_global_queue(touchEndQos, 0), ^{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, moveEventIntervalNSec), dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_UP]; //send touch event before remove pointerId
[self removePointerId:touch]; //then remove pointerId
if(self->trackPointEnabled){
dispatch_async(dispatch_get_main_queue(), ^{
uint8_t pointerId = [self retrievePointerIdFromDict:touch];
CAShapeLayer* trackPoint = self->trackPointPool[pointerId];
[CATransaction begin];
[CATransaction setDisableActions:YES];
trackPoint.hidden = true;
[CATransaction commit];
[self removePointerId:touch];
});
}
else [self removePointerId:touch];
if(self->activateCoordSelector) [self removePointerObjFromDict:touch];
[self->blacklistedTouches removeObject:@((uintptr_t)touch)];
}
//if(self->touchPointSpawnedAtUpperScreenEdge && [[event allTouches] count] == [touches count])
});
else{
for (UITouch* touch in touches){
// continue to the next loop if current touch is already captured by OSC. works only in regular native touch
[self sendTouchEvent:touch withTouchtype:LI_TOUCH_EVENT_UP]; //send touch event before remove pointerId
[self removePointerId:touch]; //then remove pointerId
if(self->activateCoordSelector) [self removePointerObjFromDict:touch];
}
}
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
@@ -303,5 +319,19 @@
return pointer.useRelativeCoords ? pointer.latestRelativePoint : pointer.latestPoint;
}
- (void)dealloc{
NSLog(@"dealloc purePativeTouchHanlder %f", CACurrentMediaTime());
pointerIdDict = nil;
activePointerIds = nil;
pointerIdPool= nil;
unassignedPointerIds = nil;
blacklistedTouches = nil;
for(CAShapeLayer* layer in trackPointPool){
[layer removeFromSuperlayer];
}
trackPointPool = nil;
pointerObjDict = nil;
}
@end
+2 -2
View File
@@ -13,13 +13,13 @@
#import "DataManager.h"
#import "CustomTapGestureRecognizer.h"
#define RIGHTCLICK_TAP_DOWN_TIME_THRESHOLD_S 0.15
#define RIGHTCLICK_TAP_DOWN_TIME_THRESHOLD_S 0.1
NS_ASSUME_NONNULL_BEGIN
@interface RelativeTouchHandler : UIResponder
@property (nonatomic, readonly) CustomTapGestureRecognizer* mouseRightClickTapRecognizer; // this object will be passed to onscreencontrol class for areVirtualControllerTaps flag setting
@property (nonatomic, readonly) CustomTapGestureRecognizer* mouseRightClickTapRecognizer; // this object will be passed to onscreencontrol class for areVirtualControllerTaps flag setting
- (id)initWithView:(StreamView*)view andSettings:(TemporarySettings*)settings;
+90 -100
View File
@@ -1,6 +1,6 @@
//
// RelativeTouchHandler.m
// Moonlight
// VoidLink
//
// Completely refactored by True砖家 on 2024.9.13
// Copyright © 2024 True砖家 on Bilibili. All rights reserved.
@@ -23,22 +23,26 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
BOOL firstTouchMoved;
BOOL mousePointerMoved;
BOOL quickTapDetected;
BOOL isInMouseWheelScrollingMode;
// upper screen edge check
bool touchPointSpawnedAtUpperScreenEdge;
CGFloat slideGestureVerticalThreshold;
CGFloat screenWidthWithThreshold;
CGFloat EDGE_TOLERANCE;
CGFloat _edgeTolerance;
UITouch* touchLockedForMouseMove;
UITouch* quickTapTouch;
bool multiTouchesDetected;
CADisplayLink *displayLink;
#if TARGET_OS_TV
UIGestureRecognizer* remotePressRecognizer;
UIGestureRecognizer* remoteLongPressRecognizer;
#endif
StreamView* streamView;
__weak StreamView* streamView;
}
- (id)initWithView:(StreamView*)view andSettings:(TemporarySettings*)settings {
@@ -53,18 +57,21 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
_mouseRightClickTapRecognizer.delaysTouchesBegan = NO;
_mouseRightClickTapRecognizer.delaysTouchesEnded = NO;
[self->streamView.streamFrameTopLayerView addGestureRecognizer:_mouseRightClickTapRecognizer]; // add all additional gestures to the streamFrameTopLayerView instead of the streamview.
_mouseRightClickTapRecognizer.touchCapturingView = streamView;
isInMouseWheelScrollingMode = false;
firstTouchMoved = false;
mousePointerMoved = false;
mousePointerTimestamp = 0;
// upper screen check
EDGE_TOLERANCE = 10.0;
_edgeTolerance = settings.edgeSlidingSensitivity.floatValue;
slideGestureVerticalThreshold = CGRectGetHeight([[UIScreen mainScreen] bounds]) * 0.4;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - EDGE_TOLERANCE;
screenWidthWithThreshold = CGRectGetWidth([[UIScreen mainScreen] bounds]) - _edgeTolerance;
self->touchPointSpawnedAtUpperScreenEdge = false;
// self->displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkCallback:)];
// [self->displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
#if TARGET_OS_TV
remotePressRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(remoteButtonPressed:)];
remotePressRecognizer.allowedPressTypes = @[@(UIPressTypeSelect)];
@@ -81,42 +88,21 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
- (bool)isOnScreenControllerBeingPressed:(NSSet* )touches{
for(UITouch* touch in touches){
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]) return true;
if([OnScreenControls.touchesCapturedByOnScreenControls containsObject:touch]) return true;
}
return false;
}
- (bool)isOnScreenWidgetViewBeingPressed {
bool gotOneButtonPressed = false;
for(UIView* view in self->streamView.superview.subviews){ // iterates all on-screen widget views in StreamFrameView
if ([view isKindOfClass:[OnScreenWidgetView class]]) {
OnScreenWidgetView* widgetView = (OnScreenWidgetView*) view;
if(widgetView.pressed){
gotOneButtonPressed = true; //got one button pressed
}
}
}
return gotOneButtonPressed;
}
- (void)resetAllPressedFlagsForOnScreenWidgetViews {
for(UIView* view in self->streamView.superview.subviews){ // iterates all on-screen widget views in StreamFrameView
if ([view isKindOfClass:[OnScreenWidgetView class]]) {
OnScreenWidgetView* widgetView = (OnScreenWidgetView*) view;
widgetView.pressed = false;
}
}
}
- (void)mouseRightClick {
multiTouchesDetected = false;
dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC));
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
Log(LOG_D, @"Sending right mouse button press");
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
Log(LOG_D, @"Sending right mouse button press");
// Wait 100 ms to simulate a real button press
usleep(100 * 1000);
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
dispatch_after(delay, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
});
});
}
@@ -130,35 +116,34 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
return hypotf(originalPoint.x - currentPoint.x, originalPoint.y - currentPoint.y) <= 300;
}
- (BOOL)isAdjacentPoints:(CGPoint)currentPoint from:(CGPoint)originalPoint tolerance:(CGFloat)tolerance {
bool isAdjacent = hypotf(originalPoint.x - currentPoint.x, originalPoint.y - currentPoint.y) <= hypot(tolerance, tolerance);
return isAdjacent;
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
firstTouchMoved = false;
//check if touch point is spawned on the left or right upper half screen edges, this is the highest priority
CGPoint initialPoint = [[touches anyObject] locationInView:streamView];
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < EDGE_TOLERANCE || initialPoint.x > screenWidthWithThreshold)) {
if(initialPoint.y < slideGestureVerticalThreshold && (initialPoint.x < _edgeTolerance || initialPoint.x > screenWidthWithThreshold)) {
self->touchPointSpawnedAtUpperScreenEdge = true;
return;
}
firstTouchMoved = false;
touchPointSpawnedAtUpperScreenEdge = false; // reset this flag immediately if we get a touch event passing the check above, this fixes irresponsive touch after closing the command tool menu.
if([[event allTouches] count] == 2 && ![self isOnScreenWidgetViewBeingPressed] && ![self isOnScreenControllerBeingPressed:[event allTouches]]){
NSLog(@"get in scrolling mode");
isInMouseWheelScrollingMode = true;
return; // if we got 2 touches on the blank area, it's gonna be a mouse scroll touch, and must prevent UITtouch object for mouse pointer being captured & locked
if([UITouchUtil touchesIn:streamView from:event].count>=2){
multiTouchesDetected = true;
return;
}
// NSLog(@"touches count in began stage: %llu", (uint64_t)[touches count]);
UITouch* candidateTouch = nil;
// the onscreen controllers are implmented by CALayer, which can not intercept UITouch event, touch will penetrate to the streamView level and captured in the touches callback of this touchHandler class.
// the onscreen button are UIViews, they intercept UITouch events, so we don't need to worry about them.
for(UITouch* touch in touches){
// NSLog(@"candidate touch test: %llu", (uint64_t)touch);
if([OnScreenControls.touchAddrsCapturedByOnScreenControls containsObject:@((uintptr_t)touch)]){
// NSLog(@"%f controller tap detected", CACurrentMediaTime());
if([OnScreenControls.touchesCapturedByOnScreenControls containsObject:touch]){
continue;
}
else candidateTouch = touch;
@@ -166,15 +151,18 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
// quick double tap detection for dragging. simulates a real notebook computer touchpad
CGPoint currentTouchLocation = [candidateTouch locationInView:streamView];
NSTimeInterval tapInterval = CACurrentMediaTime() - mousePointerTimestamp;
if(tapInterval < QUICK_TAP_TIME_INTERVAL && [self isAdjacentTouches:currentTouchLocation from:initialMousePointerLocation] ) {
// NSLog(@"quick click detected");
quickTapDetected = true;
NSLog(@"quick Tap Detected");
// LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT); // do not press down mouse button here, or it wiil easily turn to double click on remote PC
if([UITouchUtil touchesIn:streamView from:event].count == 1){
NSTimeInterval tapInterval = CACurrentMediaTime() - mousePointerTimestamp;
if(tapInterval < QUICK_TAP_TIME_INTERVAL
&& [self isAdjacentTouches:currentTouchLocation from:initialMousePointerLocation]) {
quickTapDetected = true;
NSLog(@"quick Tap Detected");
}
quickTapTouch = touches.anyObject;
}
// we must use [event allTouches] to check if touchLockedForMouseMove is captured, because the UITouch object could be captured by upper layer of UIView(in cases like tap gestures), not passed to the touches callbacks in this class, but still available in [event allTouches]
// use [event allTouches] to check if touchLockedForMouseMove is captured, if already captured, don't update touchLockedForMouseMove
if(candidateTouch != nil && ![[event allTouches] containsObject:touchLockedForMouseMove]){
touchLockedForMouseMove = candidateTouch;
// NSLog(@"Candidate touch for mouse movement locked");
@@ -184,37 +172,37 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
//NSLog(@"%f, touchesMoved callback, is scrolling: %d, touches count: %d", CACurrentMediaTime(), isInMouseWheelScrollingMode, (uint32_t)[touches count]);
if(isInMouseWheelScrollingMode && [[event allTouches] count] == 2 && ![self isOnScreenWidgetViewBeingPressed] && ![self isOnScreenControllerBeingPressed:touches]){
NSSet* twoTouches = [event allTouches];
CGPoint firstLocation = [[[twoTouches allObjects] objectAtIndex:0] locationInView:streamView];
CGPoint secondLocation = [[[twoTouches allObjects] objectAtIndex:1] locationInView:streamView];
CGPoint avgLocation = CGPointMake((firstLocation.x + secondLocation.x) / 2, (firstLocation.y + secondLocation.y) / 2);
if ((CACurrentMediaTime() - _mouseRightClickTapRecognizer.gestureCapturedTime > RIGHTCLICK_TAP_DOWN_TIME_THRESHOLD_S) && twoFingerTouchLocation.y != avgLocation.y) { //prevent sending scrollevent while right click gesture is being recognized. The time threshold is only 150ms, resulting in a barely noticeable delay before the scroll event is activated.
// and we must exclude onscreen button taps & on-screen controller taps
LiSendHighResScrollEvent((avgLocation.y - twoFingerTouchLocation.y) * 10);
}
twoFingerTouchLocation = avgLocation;
return;
}
// NSLog(@"%f touchesMoved callback, locked touch: %llu", CACurrentMediaTime(), (uintptr_t)touchLockedForMouseMove);
NSSet* currentTouches = [UITouchUtil touchesIn:streamView from:event];
if(![self isOnScreenControllerBeingPressed:currentTouches]) [TouchPadGestureHandler handleGestureIn:streamView with:event];
if(multiTouchesDetected) return;
if([touches containsObject:touchLockedForMouseMove]){
mousePointerMoved = true;
[self sendMouseMoveEvent:touchLockedForMouseMove];
CGPoint currentLocation = [touchLockedForMouseMove locationInView:streamView];
bool isAdjacentPoints = [self isAdjacentPoints:initialMousePointerLocation from:currentLocation tolerance:currentSettings.singleTapSensitivity.doubleValue];
if(!mousePointerMoved && !isAdjacentPoints){
mousePointerMoved = true;
}
[self sendMouseMoveEvent:currentLocation];
}
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
if(isInMouseWheelScrollingMode){
isInMouseWheelScrollingMode = false;
if(TouchPadGestureHandler.ctrlDown) LiSendKeyboardEvent(CommandManager.keyboardButtonMappings[@"CTRL"].shortValue,KEY_ACTION_UP,0);
[TouchPadGestureHandler startInertialScroll];
if(multiTouchesDetected){
if([UITouchUtil touchesIn:streamView from:event].count == touches.count){
multiTouchesDetected = false;
}
return;
}
if([UITouchUtil touchesIn:streamView from:event].count == touches.count) multiTouchesDetected = false;
if([touches containsObject:touchLockedForMouseMove]){
// dealing with a single first tap, whether the button will be released, is going to be decided in sendLongMouseLeftButtonClickEvent
if(!mousePointerMoved && !self->quickTapDetected) [self sendLongMouseLeftButtonClickEvent];
@@ -230,24 +218,23 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
mousePointerMoved = false;
}
if([[event allTouches] count] == [touches count]){
isInMouseWheelScrollingMode = false;
for(UITouch* touch in touches){
[OnScreenControls.touchesCapturedByOnScreenControls removeObject:touch];
}
if([UITouchUtil touchesIn:streamView from:event].count == [touches count]){
touchLockedForMouseMove = nil;
mousePointerMoved = false; // need to reset this anyway
[self resetAllPressedFlagsForOnScreenWidgetViews]; // reset all pressed flag for on-screen widget views after all fingers lifted from screen.
}
touchPointSpawnedAtUpperScreenEdge = false;
}
- (void)sendMouseMoveEvent:(UITouch* )touch{
- (void)sendMouseMoveEvent:(CGPoint)currentLocation{
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
if(self->touchPointSpawnedAtUpperScreenEdge) return; // we're done here. this touch event will not be sent to the remote PC.
CGPoint currentLocation = [touch locationInView:self->streamView];
if (!self->firstTouchMoved) {
bool isAdjacentPoints = [self isAdjacentPoints:self->initialMousePointerLocation from:currentLocation tolerance:self->currentSettings.relativeTouchSlideThreshold.floatValue];
if (!self->firstTouchMoved && !isAdjacentPoints) {
self->latestMousePointerLocation = currentLocation;
self->firstTouchMoved = true;
}
@@ -259,37 +246,40 @@ static const float QUICK_TAP_TIME_INTERVAL = 0.2;
int deltaY = (currentLocation.y - self->latestMousePointerLocation.y) * 1.35 * self->currentSettings.mousePointerVelocityFactor.floatValue;
if (deltaX != 0 || deltaY != 0) {
LiSendMouseMoveEvent(deltaX, deltaY);
self->latestMousePointerLocation = currentLocation;
if(self->touchPointSpawnedAtUpperScreenEdge) return; // we're done here. this touch event will not be sent to the remote PC.
if(self->firstTouchMoved) LiSendMouseMoveEvent(deltaX, deltaY);
}
}
});
}
// this will turn into a dragging anytime...
- (void)sendLongMouseLeftButtonClickEvent{
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
// if (!self->isDragging){
Log(LOG_D, @"Sending left mouse button press");
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
// Wait 100 ms to simulate a real button press
usleep(QUICK_TAP_TIME_INTERVAL * 1000000);
if(!self->quickTapDetected){
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
}
// else NSLog(@"Left mouse button release cancelled, keep pressing down, turning into dragging...");
dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(QUICK_TAP_TIME_INTERVAL * NSEC_PER_SEC));
dispatch_after(delay, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
if(!self->quickTapDetected){
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
}
// else NSLog(@"Left mouse button release cancelled, keep pressing down, turning into dragging...");
});
// do not release the button if we're still dragging, this will prevent the dragging from being interrupted.
});
}
- (void)sendShortMouseLeftButtonClickEvent{
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
usleep(50 * 1000);
dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC));
dispatch_after(delay, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
usleep(50 * 1000);
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
dispatch_after(delay, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
});
});
}
+96
View File
@@ -0,0 +1,96 @@
//
// SafeTimer.swift
// VoidLink
//
// Created by True砖家 on 2025/9/9.
// Copyright © 2025 True砖家@Bilibili. All rights reserved.
//
import Foundation
import QuartzCore
@objc(SafeTimer)
final class SafeTimer: NSObject {
private var timer: DispatchSourceTimer?
private let timerQueue: DispatchQueue // 私有串行队列
private let userHandler: () -> Void
private let interval: TimeInterval
private let delay: TimeInterval
private var shouldRunHandler: Bool = false // 控制逻辑上的暂停/恢复
private var isCleaned: Bool = false // 是否已经 clean
@objc init(interval: TimeInterval = 1.0,
delay: TimeInterval = 0,
queueLabel: String = "com.example.safetimer",
handler: @escaping () -> Void) {
self.interval = interval
self.delay = delay
self.userHandler = handler
self.timerQueue = DispatchQueue(label: queueLabel)
super.init()
let t = DispatchSource.makeTimerSource(queue: timerQueue)
t.schedule(deadline: .now() + delay, repeating: interval)
t.setEventHandler { [weak self] in
guard let self = self, !self.isCleaned else { return }
if !self.shouldRunHandler { return }
self.userHandler()
}
t.resume() // timer 一开始就 resume,避免 suspend/resume 的计数问题
self.timer = t
}
deinit {
clean()
}
/// 开始逻辑上的计时
@objc func start() {
timerQueue.async { [weak self] in
guard let self = self, !self.isCleaned else { return }
self.shouldRunHandler = true
}
}
/// 暂停逻辑上的计时
@objc func pause() {
timerQueue.async { [weak self] in
guard let self = self, !self.isCleaned else { return }
self.shouldRunHandler = false
}
}
/// 重置下一次触发时间,并开始执行 handler
@objc func restart() {
timerQueue.async { [weak self] in
guard let self = self, let t = self.timer, !self.isCleaned else { return }
t.schedule(deadline: .now() + self.delay, repeating: self.interval)
self.shouldRunHandler = true
}
}
@objc func isRunning() -> Bool {
return shouldRunHandler;
}
/// 彻底清理 timer,返回后保证 handler 不再执行
@objc func clean() {
if isCleaned { return }
// 同步在 timerQueue 执行,保证已排队事件全部处理完(或跳过)
timerQueue.sync {
self.isCleaned = true
self.shouldRunHandler = false
if let t = self.timer {
t.setEventHandler {} // 清空 handler
t.cancel() // 取消 timer
self.timer = nil
}
}
}
}
+20 -5
View File
@@ -24,6 +24,7 @@
- (void)toggleStatsOverlay;
- (void)toggleMouseCapture;
- (void)toggleMouseVisible;
- (void)disconnectAndQuitApp;
@end
@@ -33,18 +34,24 @@
@interface StreamView : UIView <X1KitMouseDelegate, UITextFieldDelegate, UIPointerInteractionDelegate>
#endif
@property (assign, nonatomic) UIView* streamFrameTopLayerView;
@property (weak, nonatomic) UIView* streamFrameTopLayerView;
@property (weak, nonatomic) id<UserInteractionDelegate> interactionDelegate;
@property (assign, nonatomic) CGFloat streamAspectRatio;
@property (assign, nonatomic) CGRect originalFrame;
@property (assign, nonatomic) bool widgetToolOpened;
@property (strong, nonatomic) OnScreenControls* onScreenControls;
@property (weak, nonatomic) PencilHandler* pencilHandler;
@property (weak, nonatomic) UIViewController* streamFrameVC;
- (void) setupStreamView:(ControllerSupport*)controllerSupport
interactionDelegate:(id<UserInteractionDelegate>)interactionDelegate
config:(StreamConfiguration*)streamConfig
streamFrameTopLayerView:(UIView* )topLayerView
;
- (void) showOnScreenControls;
- (void)cleanUp;
- (void) reloadLegacyWidgets:(OSCProfile* )profile;
- (void) setOnScreenControls;
- (void) disableOnScreenControls;
- (void) reloadOnScreenControlsRealtimeWith:(ControllerSupport*)controllerSupport
@@ -52,7 +59,9 @@
- (void) reloadOnScreenControlsWith:(ControllerSupport*)controllerSupport
andConfig:(StreamConfiguration*)streamConfig;
- (void) clearOnScreenWidgets;
- (void) reloadOnScreenWidgetViews;
- (void) reloadOnScreenWidgetViews:(bool)reload;
- (void) saveStreamViewWidgetChanges;
- (bool) isOnScreenWidgetEnabled;
- (CGSize) getVideoAreaSize;
- (CGPoint) adjustCoordinatesForVideoArea:(CGPoint)point;
@@ -60,11 +69,17 @@
- (OnScreenControlsLevel) getCurrentOscState;
-(void)readyToBringUpSoftKeyboardByToolbox;
- (void)readyToBringUpSoftKeyboardByToolbox;
- (void)keyboardWillShow:(NSNotification *)notification;
- (void)keyboardWillHide;
- (void)liftMetalVideoViewIfNeeded:(CGFloat)liftHeight;
- (void)alterAbsTouchDragWith:(int32_t)mouseButton;
- (void)enablePencilHover;
- (void)disablePencilHover;
- (void)setAllowSingleTouchEnabled:(BOOL)enabled;
#if !TARGET_OS_TV
- (void) updateCursorLocation:(CGPoint)location isMouse:(BOOL)isMouse;
#endif
+440 -136
View File
@@ -26,6 +26,7 @@
#import "KeyboardInputField.h"
#import "CustomTapGestureRecognizer.h"
#import "LocalizationHelper.h"
#import "StreamFrameViewController.h"
static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
@@ -34,17 +35,14 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
Stream Video has been moved out of this class to _renderView in StreamFrameViewController.
*/
@implementation StreamView {
UIView* streamFrameTopLayerView;
TemporarySettings* settings;
OnScreenControls* onScreenControls;
KeyboardInputField* keyInputField;
BOOL isInputingText;
bool isPencilHovering;
NSMutableSet* keysDown;
float streamAspectRatio;
// iOS 13.4 mouse support
NSInteger lastMouseButtonMask;
@@ -60,8 +58,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
int localMousePointerMode;
UIResponder* touchHandler;
id<UserInteractionDelegate> interactionDelegate;
NSTimer* interactionTimer;
BOOL hasUserInteracted;
@@ -76,6 +73,9 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
UILabel* keyboardToggleTip;
UIKeyModifierFlags comboKeyModifierFlags;
WidgetSizeTransition _widgetSizeTransition;
OSCProfilesManager* oscProfileMan;
}
- (void) setupStreamView:(ControllerSupport*)controllerSupport
@@ -84,11 +84,11 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
streamFrameTopLayerView:(UIView* )topLayerView{
self->comboKeyModifierFlags = (UIKeyModifierControl|UIKeyModifierAlternate|UIKeyModifierShift);
self->streamFrameTopLayerView = topLayerView;
self.streamFrameTopLayerView = topLayerView; // this will be used as a read-only pointer for other class
self->interactionDelegate = interactionDelegate;
self->_streamFrameTopLayerView = topLayerView;
self->_interactionDelegate = interactionDelegate;
self->streamAspectRatio = (float)streamConfig.width / (float)streamConfig.height;
self.streamAspectRatio = self->streamAspectRatio;
_widgetSizeTransition = keepWidgetSize;
settings = [[[DataManager alloc] init] getSettings];
@@ -115,7 +115,6 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
keyboardToggleTip.numberOfLines = 1;
keyboardToggleTip.layer.cornerRadius = 10;
keyboardToggleTip.clipsToBounds = true;
NSLog(@"setup streamView %f", CACurrentMediaTime());
// if(settings.touchMode.intValue == NativeTouchOnly) [self addGestureRecognizer:keyboardToggleRecognizer]; //keep legacy approach in pure native mode
// else [self->streamFrameTopLayerView addGestureRecognizer:keyboardToggleRecognizer]; //add to the superview in other modes
@@ -126,12 +125,17 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
// tvOS requires RelativeTouchHandler to manage Apple Remote input
self->touchHandler = [[RelativeTouchHandler alloc] initWithView:self];
#else
PencilHandler.shared = [[PencilHandler alloc] initWithStreamView:self settings:settings];
_pencilHandler = PencilHandler.shared;
// iOS uses touch Mode depending on user preference
switch (settings.touchMode.intValue) {
case NativeTouch:
keyboardToggleRecognizer.immediateTriggering = false;
self->touchHandler = [[NativeTouchHandler alloc] initWithView:self andSettings:settings];break;
self->touchHandler = [[NativeTouchHandler alloc] initWithView:self andSettings:settings];
break;
case NativeTouchOnly:
keyboardToggleRecognizer.immediateTriggering = false;
self->touchHandler = [[PureNativeTouchHandler alloc] initWithView:self andSettings:settings];break;
@@ -141,16 +145,21 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
// if(settings.onscreenControls.intValue == OnScreenControlsLevelCustom) keyboardToggleRecognizer.numberOfTouchesRequired = 3; //deprecated: fixing keyboard taps to 3, in order to invoke OSC rebase in stream view by 4-finger tap.
break;
case AbsoluteTouch:
self->touchHandler = [[AbsoluteTouchHandler alloc] initWithView:self];
self->touchHandler = [[AbsoluteTouchHandler alloc] initWithView:self andSettings:settings];
keyboardToggleRecognizer.immediateTriggering = true; //triggers signal in touchesBegan callback stage
break;
case TouchDisabled:
self->touchHandler = nil;
keyboardToggleRecognizer.immediateTriggering = false;
break;
default:
break;
}
// we'll render on-screen controls on the toplayer too:
onScreenControls = [[OnScreenControls alloc] initWithView:self->streamFrameTopLayerView controllerSup:controllerSupport streamConfig:streamConfig]; // don't delete, this is mandatory
_onScreenControls = [[OnScreenControls alloc] initWithView:self->_streamFrameTopLayerView controllerSup:controllerSupport streamConfig:streamConfig]; // don't delete, this is mandatory
// OnScreenControls.shared = _onScreenControls;
/*
// here we pass the tap recognizer to the onscreencontrols obj
if (settings.touchMode.intValue == RelativeTouch){
@@ -161,10 +170,10 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
OnScreenControlsLevel level = (OnScreenControlsLevel)[settings.onscreenControls integerValue];
if (settings.touchMode.intValue != RelativeTouch && settings.touchMode.intValue != NativeTouch ) {
Log(LOG_I, @"On-screen controls disabled in non-relative touch mode");
[onScreenControls setLevel:OnScreenControlsLevelOff];
[_onScreenControls setLevel:OnScreenControlsLevelOff];
//pass touchesCaptureByOnScreenButtons Set to the native touchhandler, this NSSet is init witihin onscreencontrols class, don't do it again in native touch handler class
[OnScreenControls.touchAddrsCapturedByOnScreenControls removeAllObjects]; // reset the attribute to nil
[OnScreenControls.touchesCapturedByOnScreenControls removeAllObjects]; // reset the attribute to nil
/*
if(settings.touchMode.intValue == NativeTouch){
@@ -176,11 +185,11 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
}
//else if (level == OnScreenControlsLevelAuto) {
else if (false) { // level auto cancelled in settings
[controllerSupport initAutoOnScreenControlMode:onScreenControls];
[controllerSupport initAutoOnScreenControlMode:_onScreenControls];
}
else {
Log(LOG_I, @"Setting manual on-screen controls level: %d", (int)level);
[onScreenControls setLevel:level];
[_onScreenControls setLevel:level];
}
// It would be nice to just use GCMouse on iOS 14+ and the older API on iOS 13
// but unfortunately that isn't possible today. GCMouse doesn't recognize many
@@ -200,8 +209,11 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
continuousMouseWheelRecognizer.allowedScrollTypesMask = UIScrollTypeMaskContinuous;
continuousMouseWheelRecognizer.allowedTouchTypes = @[@(UITouchTypeIndirectPointer)];
[self addGestureRecognizer:continuousMouseWheelRecognizer];
isPencilHovering = false;
}
#if defined(__IPHONE_16_1) || defined(__TVOS_16_1)
if (@available(iOS 16.1, *)) {
UIHoverGestureRecognizer *stylusHoverRecognizer = [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(sendStylusHoverEvent:)];
@@ -231,17 +243,36 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
keyboardToggleRecognizer.delaysTouchesBegan = NO;
keyboardToggleRecognizer.delaysTouchesEnded = NO;
[self->_streamFrameTopLayerView addGestureRecognizer:keyboardToggleRecognizer];
keyboardToggleRecognizer.touchCapturingView = self;
}
- (void)keyboardWillShow:(NSNotification *)notification{
// NSLog(@"keyboard will show markmark %f", CACurrentMediaTime());
if(settings.liftStreamViewForKeyboard && !isInputingText){
isInputingText = true;
NSDictionary *userInfo = notification.userInfo;
// Get the keyboard size from the notification
CGRect keyboardFrame = [userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
// NSLog(@"keyboard will show markmark, lowest height %f", keyboardToggleRecognizer.lowestTouchPointHeight);
if(keyboardFrame.size.height < CGRectGetHeight([[UIScreen mainScreen] bounds]) * 0.25) return; // return in case of abnormal keyboard height
HeightViewLiftedTo = keyboardFrame.size.height - keyboardToggleRecognizer.lowestTouchPointHeight + CGRectGetHeight([[UIScreen mainScreen] bounds]) * 0.1; // lift the StreamView to the height of lowest touch point of multi-finger tap gesture, while reserving the view of 1/10 screen height for remote typing.
CGFloat screenHeight = CGRectGetHeight([[UIScreen mainScreen] bounds]);
CGFloat keyboardHeight = keyboardFrame.size.height;
CGFloat toolbarHeight = settings.showKeyboardToolbar ? GenericUtils.legacyToolbarHeight : 0;
if(keyboardHeight < screenHeight * 0.33333333333 + toolbarHeight
|| keyboardHeight > screenHeight*0.8 + toolbarHeight){
keyboardHeight = screenHeight*0.5 + toolbarHeight;
// [self toggleKeyboard];
/*
if(keyboardToggleRecognizer.lowestTouchPointHeight < screenHeight/2){
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[self toggleKeyboard];
});
return; // return in case of abnormal keyboard height
}*/
}
HeightViewLiftedTo = keyboardHeight - keyboardToggleRecognizer.lowestTouchPointHeight + CGRectGetHeight([[UIScreen mainScreen] bounds]) * 0.15; // lift the StreamView to the height of lowest touch point of multi-finger tap gesture, while reserving the view of 1/10 screen height for remote typing.
if(HeightViewLiftedTo < 0) HeightViewLiftedTo = 0; // set HeightViewLiftedTo to 0 if it is high enough and not going to be covered by keyboard.
CGRect liftedStreamFrame = self.frame;
liftedStreamFrame.origin.y -= HeightViewLiftedTo;
@@ -250,13 +281,23 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
// Also lift Metal video view if using Metal rendering backend
[self liftMetalVideoViewIfNeeded:HeightViewLiftedTo];
isInputingText = true;
[self refreshKeyboardToggleRecognizer:settings.keyboardToggleFingers.intValue];
[keyboardToggleTip removeFromSuperview];
}
NSLog(@"keyboard will show %f", CACurrentMediaTime());
}
- (UIViewController *)parentViewController {
UIResponder *responder = self;
while (responder) {
if ([responder isKindOfClass:[UIViewController class]]) {
return (UIViewController *)responder;
}
responder = responder.nextResponder;
}
return nil;
}
// this method also deals with recovering streamview when local keyboard is turned off
- (void)keyboardWillHide{
// NSLog(@"keyboard will hide markmark %f", CACurrentMediaTime());
@@ -329,6 +370,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
if (isInputingText) {
Log(LOG_D, @"Closing the keyboard");
[keyInputField resignFirstResponder];
[keyboardToggleTip removeFromSuperview];
} else {
Log(LOG_D, @"Opening the keyboard");
// Prepare the textbox used to capture keyboard events.
@@ -337,18 +379,35 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
#if !TARGET_OS_TV
// Prepare the toolbar above the keyboard for more options
if(settings.showKeyboardToolbar){
UIToolbar *customToolbarView = [[UIToolbar alloc] initWithFrame:CGRectMake(0, 0, self.bounds.size.width, 44)];
UIBarButtonItem *doneBarButton = [self createButtonWithImageNamed:@"DoneIcon.png" backgroundColor:[UIColor clearColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x00 isToggleable:NO];
UIBarButtonItem *windowsBarButton = [self createButtonWithImageNamed:@"WindowsIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x5B isToggleable:YES];
UIBarButtonItem *tabBarButton = [self createButtonWithImageNamed:@"TabIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x09 isToggleable:NO];
UIBarButtonItem *shiftBarButton = [self createButtonWithImageNamed:@"ShiftIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0xA0 isToggleable:YES];
UIBarButtonItem *escapeBarButton = [self createButtonWithImageNamed:@"EscapeIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x1B isToggleable:NO];
UIBarButtonItem *controlBarButton = [self createButtonWithImageNamed:@"ControlIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x11 isToggleable:YES];
UIBarButtonItem *altBarButton = [self createButtonWithImageNamed:@"AltIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x12 isToggleable:YES];
UIBarButtonItem *deleteBarButton = [self createButtonWithImageNamed:@"DeleteIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x2E isToggleable:NO];
UIToolbar *customToolbarView = [[UIToolbar alloc] initWithFrame:CGRectMake(0, 0, self.bounds.size.width, GenericUtils.legacyToolbarHeight)];
UIBarButtonItem *doneBarButton = [self createButtonWithImageNamed:@"DoneIcon.png" backgroundColor:[UIColor clearColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x00 isToggleable:NO isDoneButton:true];
UIBarButtonItem *windowsBarButton = [self createButtonWithImageNamed:@"WindowsIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x5B isToggleable:YES isDoneButton:false];
UIBarButtonItem *tabBarButton = [self createButtonWithImageNamed:@"TabIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x09 isToggleable:NO isDoneButton:false];
UIBarButtonItem *shiftBarButton = [self createButtonWithImageNamed:@"ShiftIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0xA0 isToggleable:YES isDoneButton:false];
UIBarButtonItem *escapeBarButton = [self createButtonWithImageNamed:@"EscapeIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x1B isToggleable:NO isDoneButton:false];
UIBarButtonItem *controlBarButton = [self createButtonWithImageNamed:@"ControlIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x11 isToggleable:YES isDoneButton:false];
UIBarButtonItem *altBarButton = [self createButtonWithImageNamed:@"AltIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x12 isToggleable:YES isDoneButton:false];
UIBarButtonItem *deleteBarButton = [self createButtonWithImageNamed:@"DeleteIcon.png" backgroundColor:[UIColor blackColor] target:self action:@selector(toolbarButtonClicked:) keyCode:0x2E isToggleable:NO isDoneButton:false];
UIBarButtonItem *flexibleSpace = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
[customToolbarView setItems:[NSArray arrayWithObjects:doneBarButton, windowsBarButton, escapeBarButton, tabBarButton, shiftBarButton, controlBarButton, altBarButton, deleteBarButton, flexibleSpace, nil]];
if (GenericUtils.liquidGlassEnabled) {
if (@available(iOS 26.0, *)) {
for(UIBarButtonItem *button in customToolbarView.items){
button.hidesSharedBackground = true;
}
// customToolbarView.barTintColor = UIColor.systemGrayColor;
customToolbarView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:0.3];
customToolbarView.layer.cornerRadius = customToolbarView.bounds.size.height/2;
customToolbarView.layer.masksToBounds = true;
UIVisualEffectView *glassView =
[[UIVisualEffectView alloc] initWithEffect:[UIGlassEffect effectWithStyle:UIGlassEffectStyleClear]];
glassView.frame = customToolbarView.bounds;
glassView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[customToolbarView addSubview:glassView];
[customToolbarView sendSubviewToBack:glassView];
}
}
keyInputField.inputAccessoryView = customToolbarView;
}
#endif
@@ -377,7 +436,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
// Notify the delegate if this was a new user interaction
if (!timerAlreadyRunning) {
[interactionDelegate userInteractionBegan];
[_interactionDelegate userInteractionBegan];
}
}
@@ -385,7 +444,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
if (!hasUserInteracted) {
// User has finished touching the screen
interactionTimer = nil;
[interactionDelegate userInteractionEnded];
[_interactionDelegate userInteractionEnded];
}
else {
// User is still touching the screen. Restart the timer.
@@ -393,9 +452,9 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
}
}
- (void) showOnScreenControls {
- (void) reloadLegacyWidgets:(OSCProfile* )profile {
#if !TARGET_OS_TV
if(!_widgetToolOpened) [onScreenControls show];
if(!_widgetToolOpened) [_onScreenControls showLegacyWidgetsWith:profile];
#endif
}
@@ -405,34 +464,35 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
- (void) disableOnScreenControls {
#if !TARGET_OS_TV
[onScreenControls setLevel:OnScreenControlsLevelOff];
[_onScreenControls setLevel:OnScreenControlsLevelOff];
#endif
}
// we'll enable on screen buttons, and disable on screen controllers for absolute touch
- (bool) isOscEnabled{
return (settings.touchMode.intValue == RelativeTouch || settings.touchMode.intValue == NativeTouch) && settings.onscreenControls.intValue != OnScreenControlsLevelOff;
return (settings.touchMode.intValue == RelativeTouch || settings.touchMode.intValue == NativeTouch || settings.touchMode.intValue == AbsoluteTouch || settings.touchMode.intValue == TouchDisabled) && settings.onscreenControls.intValue != OnScreenControlsLevelOff;
}
// we'll enable on screen buttons, and disable on screen controllers for absolute touch
- (bool) isOnScreenButtonEnabled{
return (settings.touchMode.intValue == RelativeTouch || settings.touchMode.intValue == NativeTouch || settings.touchMode.intValue == AbsoluteTouch) && settings.onscreenControls.intValue == OnScreenControlsLevelCustom;
- (bool) isOnScreenWidgetEnabled{
return [self isOscEnabled] && settings.onscreenControls.intValue == OnScreenControlsLevelCustom;
}
- (void) reloadOnScreenControlsRealtimeWith:(ControllerSupport*)controllerSupport
andConfig:(StreamConfiguration*)streamConfig {
[self reloadOnScreenControlsWith:controllerSupport andConfig:streamConfig];
if([self isOscEnabled]) [self showOnScreenControls];
else [self disableOnScreenControls];
/*
StreamFrameViewController* vc = (StreamFrameViewController* )_streamFrameVC;
if([self isOscEnabled]) [self reloadLegacyWidgets:nil];
else [self disableOnScreenControls];*/
}
- (void) reloadOnScreenControlsWith:(ControllerSupport*)controllerSupport
andConfig:(StreamConfiguration*)streamConfig {
// we'll render on-screen controllers on the toplayer too.
onScreenControls = [[OnScreenControls alloc] initWithView:self->streamFrameTopLayerView controllerSup:controllerSupport streamConfig:streamConfig];
_onScreenControls = [[OnScreenControls alloc] initWithView:self->_streamFrameTopLayerView controllerSup:controllerSupport streamConfig:streamConfig];
/*
// pass mouseRightClickTapRecognizer to onScreenControls obj here:
if([self isOscEnabled]){
@@ -440,12 +500,14 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
RelativeTouchHandler* relativeTouchHandler = (RelativeTouchHandler *)touchHandler;
onScreenControls.mouseRightClickTapRecognizer = relativeTouchHandler.mouseRightClickTapRecognizer;
} */
if([self isOscEnabled]) [onScreenControls setLevel:(OnScreenControlsLevel)settings.onscreenControls.intValue];
if([self isOscEnabled]) [_onScreenControls setLevel:(OnScreenControlsLevel)settings.onscreenControls.intValue];
}
- (void) clearOnScreenWidgets{
for (UIView *subview in self->streamFrameTopLayerView.subviews) {
OnScreenWidgetView.isTweakingHighlight = false;
[OnScreenWidgetView clearMappings];
for (UIView *subview in self->_streamFrameTopLayerView.subviews) {
// 检查子视图是否是特定类型的实例
if ([subview isKindOfClass:[OnScreenWidgetView class]]) {
// 如果是,就添加到将要被移除的数组中
@@ -456,8 +518,8 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
- (CGPoint)denormalizeWidgetPosition:(CGPoint)position {
if(position.x < 1.0 && position.y < 1.0){
position.x = position.x * streamFrameTopLayerView.bounds.size.width;
position.y = position.y * streamFrameTopLayerView.bounds.size.height;
position.x = position.x * _streamFrameTopLayerView.bounds.size.width;
position.y = position.y * _streamFrameTopLayerView.bounds.size.height;
}
else{
NSLog(@"invalid coords");
@@ -465,59 +527,203 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
return position;
}
- (void) reloadOnScreenWidgetViews{
// NSLog(@"reload on screen keyboard buttons here");
- (WidgetSizeReference)getCurrentWidgetSizeReference{
if(_widgetSizeTransition == keepWidgetSize) return longSide;
else if(_widgetSizeTransition == transitionWithOrientation) return self.bounds.size.width > self.bounds.size.height ? longSide : shortSide;
else return longSide;
}
// remove all keyboard widget views first
[self clearOnScreenWidgets];
- (void)saveStreamViewWidgetChanges{
/*
NSMutableDictionary* relocatedWidgetDict = [NSMutableDictionary dictionary];
// bool customOscEnabled = [self isOscEnabled] && settings.onscreenControls.intValue == OnScreenControlsLevelCustom;
if(![self isOnScreenButtonEnabled]) return;
OSCProfilesManager* profilesManager = [OSCProfilesManager sharedManager: self.bounds];
OSCProfile *oscProfile = [profilesManager getSelectedProfile]; //returns the currently selected OSCProfile
if(!OnScreenWidgetView.editMode){ // in edit mode, keyboard widget view will be updated within layoutool view controller.
for (NSData *buttonStateEncoded in oscProfile.buttonStates) {
OnScreenButtonState* buttonState = [profilesManager unarchiveButtonStateEncoded:buttonStateEncoded];
if(buttonState.buttonType == CustomOnScreenWidget){
OnScreenWidgetView* widgetView = [[OnScreenWidgetView alloc] initWithCmdString:buttonState.name buttonLabel:buttonState.alias shape:buttonState.widgetShape]; //reconstruct widgetView
//--------------------------------------------------
onScreenControls.delegate = widgetView; // connecting onScreenControls to OnScreenWidgetView, sending the active instance for touchPad stick control
[onScreenControls sendInstance];
//--------------------------------------------------
widgetView.translatesAutoresizingMaskIntoConstraints = NO; // weird but this is mandatory, or you will find no key views added to the right place
widgetView.widthFactor = buttonState.widthFactor;
widgetView.heightFactor = buttonState.heightFactor;
widgetView.borderWidth = buttonState.borderWidth;
[widgetView setVibrationWithStyle:buttonState.vibrationStyle];
widgetView.mouseButtonAction = buttonState.mouseButtonAction;
widgetView.sensitivityFactorX = buttonState.sensitivityFactorX;
widgetView.sensitivityFactorY = buttonState.sensitivityFactorY;
widgetView.trackballDecelerationRate = buttonState.decelerationRate;
widgetView.stickIndicatorOffset = buttonState.stickIndicatorOffset;
widgetView.minStickOffset = buttonState.minStickOffset;
widgetView.slideMode = buttonState.slideMode;
// Add the widgetView to the view controller's view
[self->streamFrameTopLayerView addSubview:widgetView]; // add keyboard button to the stream frame view. must add it to the target view before setting location.
buttonState.position = [self denormalizeWidgetPosition:buttonState.position];
[widgetView setLocationWithPosition:buttonState.position];
[widgetView resizeWidgetView]; // resize must be called after relocation
[widgetView adjustTransparencyWithAlpha:buttonState.backgroundAlpha];
[widgetView adjustBorderWithWidth:buttonState.borderWidth];
}
for(UIView* subview in self->_streamFrameTopLayerView.subviews){
if([subview isKindOfClass:[OnScreenWidgetView class]]){
OnScreenWidgetView* widget = (OnScreenWidgetView* )subview;
if(widget.relocatedDuringStreaming) [relocatedWidgetDict setObject:widget forKey:@(widget.sequence)];
OnScreenWidgetView* widget = [OnScreenWidgetView widgetForKey:newButtonState.sequence];
if(widget.isFolder)
}
}
*/
NSMutableSet* relocatedWidgetSequences = [NSMutableSet set];
bool hasFolderStateChanged = false;
for(OnScreenWidgetView* widget in [OnScreenWidgetView.mapping allValues]){
if(widget.relocatedDuringStreaming) [relocatedWidgetSequences addObject:@(widget.sequence)];
if(!hasFolderStateChanged) hasFolderStateChanged = widget.folded != widget.persistedFolded;
}
if(relocatedWidgetSequences.count == 0 && !hasFolderStateChanged) return;
NSLog(@"relocatedWidgetSequences.count %lu, hasFolderStateChanged %d %F", relocatedWidgetSequences.count, hasFolderStateChanged, CACurrentMediaTime());
oscProfileMan = [OSCProfilesManager sharedManager:self->_streamFrameTopLayerView.bounds];
OSCProfile *newProfile = [oscProfileMan getSelectedProfile];
for (NSInteger i = 0; i < newProfile.buttonStatesEncoded.count; i++) {
NSData* buttonStateEncoded = newProfile.buttonStatesEncoded[i];
OnScreenButtonState *newButtonState = [oscProfileMan unarchiveButtonStateEncoded:buttonStateEncoded];
OnScreenWidgetView* widget = [OnScreenWidgetView.mapping objectForKey:@(newButtonState.sequence)];
NSData *newButtonStateEncoded;
if([relocatedWidgetSequences containsObject:@(widget.sequence)]
|| widget.isFolder){
newButtonState.position = [oscProfileMan normalizeWidgetPosition:widget.storedCenter];
newButtonState.folded = widget.folded;
newButtonStateEncoded = [NSKeyedArchiver archivedDataWithRootObject:newButtonState requiringSecureCoding:YES error:nil];
newProfile.buttonStatesEncoded[i] = newButtonStateEncoded;
}
}
newProfile.unfoldedExclusiveFolderSequence = OnScreenWidgetView.unfoldedExclusiveFolderSequence;
newProfile.postExclusiveUnfoldedSequences = OnScreenWidgetView.postExclusiveUnfoldedSequences;
[oscProfileMan replaceSelectedProfileWith:newProfile overwriteDefault:YES];
}
- (void) reloadOnScreenWidgetViews:(bool)reload{
NSLog(@"reloadOnScreenWidgets in streamview %f", CACurrentMediaTime());
dispatch_async(dispatch_get_main_queue(), ^{
if(!self->oscProfileMan) self->oscProfileMan = [OSCProfilesManager sharedManager:self->_streamFrameTopLayerView.bounds];
OSCProfile *oscProfile = [self->oscProfileMan getSelectedProfile]; //returns the currently selected OSCProfile
MotionHandler* motionHandler = [MotionHandler sharedWithProfile:oscProfile];
// get streamFrameVC
if(!self->_streamFrameVC) self->_streamFrameVC = [self parentViewController];
// bool customOscEnabled = [self isOscEnabled] && settings.onscreenControls.intValue == OnScreenControlsLevelCustom;
if(![self isOnScreenWidgetEnabled]) return;
OnScreenWidgetView.buttonVisualFeedbackEnabled = self->settings.buttonVisualFeedback;
bool hasLegacyWidget = false;
if(reload && !OnScreenWidgetView.editMode){
// remove all keyboard widget views first
[self clearOnScreenWidgets];
bool sequenceGenerated = false;
bool hasMovableWidget = false;
for (NSInteger i = 0; i < oscProfile.buttonStatesEncoded.count; i++) {
NSData* buttonStateEncoded = oscProfile.buttonStatesEncoded[i];
OnScreenButtonState* buttonState = [self->oscProfileMan unarchiveButtonStateEncoded:buttonStateEncoded];
if(buttonState.widgetType == CustomOnScreenWidget){
OnScreenWidgetView* widgetView = [[OnScreenWidgetView alloc] initWithCmdString:buttonState.name buttonLabel:buttonState.alias shape:buttonState.widgetShape profile:oscProfile]; //reconstruct widgetView
widgetView.functionalButtonDelegate = (id<OnScreenFunctionalButtonDelegate>)self->_streamFrameVC;
widgetView.motionHandler = motionHandler;
widgetView.sequence = buttonState.sequence == -1 ? i : buttonState.sequence;
if(!sequenceGenerated) sequenceGenerated = buttonState.sequence == -1;
if(!hasMovableWidget) hasMovableWidget = buttonState.buttonMode == movable;
[OnScreenWidgetView setWithWidget:widgetView for:widgetView.sequence];
widgetView.sequenceSet = buttonState.sequenceSet;
widgetView.parentSequence = buttonState.parentSequence;
widgetView.folded = buttonState.folded;
widgetView.persistedFolded = buttonState.folded;
widgetView.revealMode = buttonState.revealMode;
widgetView.translatesAutoresizingMaskIntoConstraints = NO; // weird but this is mandatory, or you will find no key views added to the right place
widgetView.widthFactor = buttonState.widthFactor;
widgetView.heightFactor = buttonState.heightFactor;
widgetView.componentSizeFactor = buttonState.componentSizeFactor;
widgetView.borderWidth = buttonState.borderWidth;
widgetView.highlightSizeFactor = buttonState.highlightSizeFactor;
widgetView.autoTapInterval = buttonState.autoTapInterval;
[widgetView setVibrationWithStyle:buttonState.vibrationStyle];
widgetView.mouseButtonAction = buttonState.mouseButtonAction;
widgetView.sensitivityFactorX = buttonState.sensitivityFactorX;
widgetView.sensitivityFactorY = buttonState.sensitivityFactorY;
widgetView.slideThreshold = buttonState.slideThreshold;
widgetView.yawFactor = buttonState.yawFactor;
widgetView.pitchFactor = buttonState.pitchFactor;
widgetView.rollFactor = buttonState.rollFactor;
widgetView.decelerationRateX = buttonState.decelerationRateX;
widgetView.decelerationRateY = buttonState.decelerationRateY;
widgetView.stickIndicatorOffset = buttonState.stickIndicatorOffset;
widgetView.minStickOffset = buttonState.minStickOffset;
widgetView.dWheelWalkModeThreshold = buttonState.walkModeThreshold;
widgetView.buttonMode = buttonState.buttonMode;
// Add the widgetView to the view controller's view
[self->_streamFrameTopLayerView addSubview:widgetView]; // add keyboard button to the stream frame view. must add it to the target view before setting location.
buttonState.position = [self denormalizeWidgetPosition:buttonState.position];
[widgetView setLocationWithPosition:buttonState.position];
widgetView.sizeReference = [self getCurrentWidgetSizeReference];
// widgetView.sizeReference = buttonState.sizeReference;
//portrait markmarkmark
[widgetView resizeWidgetView]; // resize must be called after relocation
[widgetView adjustTransparencyWithAlpha:buttonState.backgroundAlpha tweakBorderAlpha:NO];
[widgetView adjustBorderWithWidth:buttonState.borderWidth];
[widgetView tweakLabelAlphaWithAlpha:buttonState.labelAlpha];
[widgetView tweakBorderAlphaWithAlpha:buttonState.borderAlpha];
[widgetView tweakHighlightAlphaWithAlpha:buttonState.highlightAlpha];
[widgetView setupAutoTapTimer];
[widgetView setupInertialScrollerWithFps:self->settings.framerate.intValue];
if(sequenceGenerated){
// NSLog(@"widgetView.sequence %d %f", widgetView.sequence, CACurrentMediaTime());
buttonState.sequence = widgetView.sequence;
oscProfile.buttonStatesEncoded[i] = [NSKeyedArchiver archivedDataWithRootObject:buttonState requiringSecureCoding:YES error:nil];
}
[widgetView accessWidgetAttributes];
}
else if(buttonState.widgetType == LegacyOnScreenControls) hasLegacyWidget = true;
}
if(sequenceGenerated && hasMovableWidget){
[self->oscProfileMan replaceSelectedProfileWith:oscProfile overwriteDefault:YES];
}
uint64_t buttonIndex = 9999999;
UIView* deepestButton;
for (UIView *subview in self->_streamFrameTopLayerView.subviews) {
if ([subview isKindOfClass:[OnScreenWidgetView class]]) {
OnScreenWidgetView* widget = (OnScreenWidgetView* ) subview;
if(widget.widgetType == WidgetTypeEnumButton){
uint64_t index = [self->_streamFrameTopLayerView.subviews indexOfObject:subview];
if (index<buttonIndex){
buttonIndex = index;
deepestButton = subview;
}
}
}
}
if(deepestButton){
for (UIView *subview in self->_streamFrameTopLayerView.subviews) {
if ([subview isKindOfClass:[OnScreenWidgetView class]]) {
OnScreenWidgetView* widget = (OnScreenWidgetView* ) subview;
if(widget.widgetType == WidgetTypeEnumTouchPad){
[self->_streamFrameTopLayerView insertSubview:subview belowSubview:deepestButton];
}
}
}
}
}
OnScreenWidgetView.unfoldedExclusiveFolderSequence = oscProfile.unfoldedExclusiveFolderSequence;
[OnScreenWidgetView setPostExclusiveUnfoldeds:oscProfile.postExclusiveUnfoldedSequences];
[OnScreenWidgetView restoreFoldedStates];
NSLog(@"hasLegacyWidget %d %f", hasLegacyWidget, CACurrentMediaTime());
// legacy widgets
if(!OnScreenWidgetView.editMode && (hasLegacyWidget || !reload)){
if([self isOscEnabled]) [self reloadLegacyWidgets:oscProfile];
else [self disableOnScreenControls];
}
if(reload && !OnScreenWidgetView.editMode){
[PencilHandler.shared setupPressureLUTWithProfile:oscProfile];
}
});
}
- (OnScreenControlsLevel) getCurrentOscState {
if (onScreenControls == nil) {
if (_onScreenControls == nil) {
return OnScreenControlsLevelOff;
}
else {
return [onScreenControls getLevel];
return [_onScreenControls getLevel];
}
}
@@ -615,6 +821,8 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
CGPoint location = [self adjustCoordinatesForVideoArea:[event locationInView:self]];
CGSize videoSize = [self getVideoAreaSize];
// CGFloat pressure = (event.force / event.maximumPossibleForce) / sin(event.altitudeAngle);
return LiSendPenEvent(type, LI_TOOL_TYPE_PEN, 0, location.x / videoSize.width, location.y / videoSize.height,
(event.force / event.maximumPossibleForce) / sin(event.altitudeAngle),
0.0f, 0.0f,
@@ -624,20 +832,31 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
- (void)sendStylusHoverEvent:(UIHoverGestureRecognizer*)gesture API_AVAILABLE(ios(13.0)) {
uint8_t type;
switch (gesture.state) {
case UIGestureRecognizerStateBegan:
case UIGestureRecognizerStateChanged:
type = LI_TOUCH_EVENT_HOVER;
isPencilHovering = true;
if(!PencilHandler.hoverSupported) PencilHandler.hoverSupported = true;
// if(gesture.state == UIGestureRecognizerStateChanged) NSLog(@"UIGestureRecognizerStateChanged %f", CACurrentMediaTime());
break;
case UIGestureRecognizerStateEnded:
type = LI_TOUCH_EVENT_HOVER_LEAVE;
// NSLog(@"UIGestureRecognizerStateEnded %f", CACurrentMediaTime());
break;
default:
return;
}
if(gesture.state==UIGestureRecognizerStateEnded){
dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.008 * NSEC_PER_SEC));
dispatch_after(delayTime, dispatch_get_main_queue(), ^{// Code to execute after the delay
self->isPencilHovering = false;
});
}
CGPoint location = [self adjustCoordinatesForVideoArea:[gesture locationInView:self]];
CGSize videoSize = [self getVideoAreaSize];
@@ -658,8 +877,26 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
}
#endif
LiSendPenEvent(type, LI_TOOL_TYPE_PEN, 0, location.x / videoSize.width, location.y / videoSize.height,
distance, 0.0f, 0.0f, rotationAngle, tiltAngle);
dispatch_after(0, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE,0), ^{// Code to execute after the delay
if(PencilHandler.isDrawing) return;
switch (PencilHandler.hoverMode) {
case HoverPencil:
LiSendPenEvent(type, LI_TOOL_TYPE_PEN, 0, location.x / videoSize.width, location.y / videoSize.height, distance, 0.0f, 0.0f, rotationAngle, tiltAngle);
break;
case HoverMouse:
[self updateCursorLocation:location isMouse:YES];
break;
case HoverDisabled:
break;
case HoverBoth:
LiSendPenEvent(type, LI_TOOL_TYPE_PEN, 0, location.x / videoSize.width, location.y / videoSize.height, distance, 0.0f, 0.0f, rotationAngle, tiltAngle);
[self updateCursorLocation:location isMouse:YES];
break;
default:
break;
}
});
}
#endif
@@ -672,13 +909,18 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
[touchHandler touchesBegan:touches withEvent:event];
return; //This is a native touch oriented fork, in pure native touch mode, this call back method deals with native touch only.
}
if (@available(iOS 13.4, *)) { // now only pencil events are restricted
for (UITouch* touch in touches) {
if (touch.type == UITouchTypePencil) {
if ([self sendStylusEvent:touch]) return;
}
NSMutableSet* pencilTouches = [NSMutableSet set];
for (UITouch* touch in touches) {
if (touch.type == UITouchTypePencil) {
[pencilTouches addObject:touch];
}
}
NSMutableSet* nonPencilTouches;
if(pencilTouches.count>0){
[_pencilHandler touchesBegan:pencilTouches withEvent:event];
nonPencilTouches = [touches mutableCopy];
[nonPencilTouches minusSet:pencilTouches];
}
#endif
if ([self handleMouseButtonEvent:BUTTON_ACTION_PRESS
@@ -693,22 +935,29 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
// Notify of user interaction and start expiration timer
[self startInteractionTimer];
NSSet* targetTouches = nonPencilTouches ? nonPencilTouches : touches;
if(settings.touchMode.intValue == NativeTouch || settings.touchMode.intValue == RelativeTouch){
[self->onScreenControls handleTouchDownEvent:touches];
[self->touchHandler touchesBegan:touches withEvent:event];
[self->_onScreenControls handleTouchDownEvent:targetTouches];
[self->touchHandler touchesBegan:targetTouches withEvent:event];
}
else if(![onScreenControls handleTouchDownEvent:touches]) [touchHandler touchesBegan:touches withEvent:event];
else if(![_onScreenControls handleTouchDownEvent:targetTouches]) [touchHandler touchesBegan:targetTouches withEvent:event];
}
- (UIBarButtonItem *)createButtonWithImageNamed:(NSString *)imageName backgroundColor:(UIColor *)backgroundColor target:(id)target action:(SEL)action keyCode:(NSInteger)keyCode isToggleable:(BOOL)isToggleable {
- (UIBarButtonItem *)createButtonWithImageNamed:(NSString *)imageName backgroundColor:(UIColor *)backgroundColor target:(id)target action:(SEL)action keyCode:(NSInteger)keyCode isToggleable:(BOOL)isToggleable isDoneButton:(bool)isDoneButton {
UIImage *image = [UIImage imageNamed:imageName];
UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
// [button setTitle:@"666" forState:UIControlStateNormal];
[button setImage:image forState:UIControlStateNormal];
button.frame = CGRectMake(0, 0, 30, 30);
button.frame = GenericUtils.liquidGlassEnabled ? CGRectMake(0, 0, 30, 30) : CGRectMake(0, 0, 30, 30);
button.imageView.contentMode = UIViewContentModeScaleAspectFit;
button.imageView.backgroundColor = backgroundColor;
button.imageView.layer.cornerRadius = 10.0;
button.imageEdgeInsets = UIEdgeInsetsMake(6, 6, 6, 6);
button.imageEdgeInsets = (GenericUtils.liquidGlassEnabled
? (isDoneButton ? UIEdgeInsetsMake(16, 16, 16, 16) : UIEdgeInsetsMake(27.5, 27.5, 27.5, 27.5))
: UIEdgeInsetsMake(6, 6, 6, 6));
[button addTarget:target action:action forControlEvents:UIControlEventTouchUpInside];
objc_setAssociatedObject(button, "keyCode", @(keyCode), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
objc_setAssociatedObject(button, "isToggleable", @(isToggleable), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
@@ -724,7 +973,7 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
isOn = !isOn;
// Update the button's appearance based on its new state
if (isOn) {
sender.imageView.backgroundColor = [UIColor lightGrayColor];
sender.imageView.backgroundColor = GenericUtils.liquidGlassEnabled ? [UIColor.systemGrayColor colorWithAlphaComponent:0.5] : [UIColor lightGrayColor];
} else {
sender.imageView.backgroundColor = [UIColor blackColor];
}
@@ -760,8 +1009,6 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
- (BOOL)handleMouseButtonEvent:(int)buttonAction forTouches:(NSSet *)touches withEvent:(UIEvent *)event {
// NSLog(@"mouse click time: %f", CACurrentMediaTime()*1000);
#if !TARGET_OS_TV
if (@available(iOS 13.4, *)) {
UITouch* touch = [touches anyObject];
@@ -830,9 +1077,10 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
return; //This is a native touch oriented fork, in pure native touch mode, this call back method deals with native touch only.
}
NSMutableSet* pencilTouches = [NSMutableSet set];
for (UITouch* touch in touches) {
if (touch.type == UITouchTypePencil) {
if ([self sendStylusEvent:touch]) return;
[pencilTouches addObject:touch];
}
if (@available(iOS 13.4, *)) {
UITouch *touch = [touches anyObject];
@@ -849,20 +1097,27 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
// don't require this, but we do it anyway for them too.
// Cursor movement without a button held down is handled
// in pointerInteraction:regionForRequest:defaultRegion.
[self updateCursorLocation:[touch locationInView:self] isMouse:YES];
if(!isPencilHovering) [self updateCursorLocation:[touch locationInView:self] isMouse:YES];
return;
}
}
#endif
}
NSMutableSet* nonPencilTouches;
if(pencilTouches.count>0){
[_pencilHandler touchesMoved:pencilTouches withEvent:event];
nonPencilTouches = [touches mutableCopy];
[nonPencilTouches minusSet:pencilTouches];
}
hasUserInteracted = YES;
NSSet* targetTouches = nonPencilTouches ? nonPencilTouches : touches;
if(self->settings.touchMode.intValue == NativeTouch || self->settings.touchMode.intValue == RelativeTouch){
[self->touchHandler touchesMoved:touches withEvent:event];
[self->onScreenControls handleTouchMovedEvent:touches];
[self->touchHandler touchesMoved:targetTouches withEvent:event];
[self->_onScreenControls handleTouchMovedEvent:targetTouches];
}
else if(![self->onScreenControls handleTouchMovedEvent:touches]) [self->touchHandler touchesMoved:touches withEvent:event];
else if(![self->_onScreenControls handleTouchMovedEvent:targetTouches]) [self->touchHandler touchesMoved:targetTouches withEvent:event];
}
- (void) handleKeyCombos:(UIPress*) press{
@@ -871,16 +1126,19 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
}
switch (press.key.keyCode) {
case UIKeyboardHIDUsageKeyboardQ:
[interactionDelegate streamExitRequested];
[_interactionDelegate streamExitRequested];
break;
case UIKeyboardHIDUsageKeyboardS:
[interactionDelegate toggleStatsOverlay];
[_interactionDelegate toggleStatsOverlay];
break;
case UIKeyboardHIDUsageKeyboardM:
[interactionDelegate toggleMouseCapture];
[_interactionDelegate toggleMouseCapture];
break;
case UIKeyboardHIDUsageKeyboardC:
[interactionDelegate toggleMouseVisible];
[_interactionDelegate toggleMouseVisible];
break;
case UIKeyboardHIDUsageKeyboardD:
[_interactionDelegate disconnectAndQuitApp];
break;
default:
break;
@@ -934,17 +1192,22 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
return; //This is a native touch oriented fork, in pure native touch mode, this call back method deals with native touch only.
}
if (@available(iOS 13.4, *)){ //now only pencil events are restricted
for (UITouch* touch in touches) {
if (touch.type == UITouchTypePencil) {
if ([self sendStylusEvent:touch]) return;
}
NSMutableSet* pencilTouches = [NSMutableSet set];
for (UITouch* touch in touches) {
if (touch.type == UITouchTypePencil) {
[pencilTouches addObject:touch];
}
}
NSMutableSet* nonPencilTouches;
if(pencilTouches.count>0){
[_pencilHandler touchesEnded:pencilTouches withEvent:event];
nonPencilTouches = [touches mutableCopy];
[nonPencilTouches minusSet:pencilTouches];
}
#endif
if ([self handleMouseButtonEvent:BUTTON_ACTION_RELEASE
forTouches:touches
forTouches:nonPencilTouches ? nonPencilTouches : touches
withEvent:event]) {
// If it's a mouse event, we're done
return;
@@ -954,22 +1217,21 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
hasUserInteracted = YES;
NSSet* targetTouches = nonPencilTouches ? nonPencilTouches : touches;
if(settings.touchMode.intValue == NativeTouch || settings.touchMode.intValue == RelativeTouch){
[self->touchHandler touchesEnded:touches withEvent:event]; // when touches ended, must call the native touchhandler before onScreenControls, since the NSSet of touches captured by on screen button shall be updated later
[self->onScreenControls handleTouchUpEvent:touches];
[self->touchHandler touchesEnded:targetTouches withEvent:event]; // when touches ended, must call the native touchhandler before onScreenControls, since the NSSet of touches captured by on screen button shall be updated later
[self->_onScreenControls handleTouchUpEvent:targetTouches];
}
else if(![onScreenControls handleTouchUpEvent:touches]) [touchHandler touchesEnded:touches withEvent:event];
else if(![_onScreenControls handleTouchUpEvent:targetTouches]) [touchHandler touchesEnded:targetTouches withEvent:event];
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
[touchHandler touchesCancelled:touches withEvent:event];
#if !TARGET_OS_TV
if (settings.touchMode.intValue == NativeTouchOnly) return; //This is a native touch oriented fork, in pure native touch mode, this call back method deals with native touch only.
if (@available(iOS 13.4, *)){ //now only pencil events are restricted
for (UITouch* touch in touches) {
if (touch.type == UITouchTypePencil) {
if ([self sendStylusEvent:touch]) return;
}
for (UITouch* touch in touches) {
if (touch.type == UITouchTypePencil) {
[self touchesEnded:touches withEvent:event];
}
}
#endif
@@ -1026,7 +1288,10 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
// Move the cursor on the host if no buttons are pressed.
// Motion with buttons pressed in handled in touchesMoved:
if (lastMouseButtonMask == 0) {
[self updateCursorLocation:request.location isMouse:YES];
dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.001 * NSEC_PER_SEC));
dispatch_after(delayTime, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE,0), ^{
if(!self->isPencilHovering) [self updateCursorLocation:request.location isMouse:YES];
});
}
// The pointer interaction should cover the video region only
@@ -1140,6 +1405,9 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
}
- (void)onKeyboardPressed:(UITextField *)textField {
if (textField.markedTextRange) return;
NSString* inputText = textField.text;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
// If the text became empty, we know the user pressed the backspace key.
@@ -1302,6 +1570,42 @@ static const double X1_MOUSE_SPEED_DIVISOR = 2.5;
LiSendScrollEvent(deltaZ);
}
- (void)alterAbsTouchDragWith:(int32_t)mouseButton{
if([touchHandler isKindOfClass:[AbsoluteTouchHandler class]]){
AbsoluteTouchHandler* handler = (AbsoluteTouchHandler* )touchHandler;
AbsoluteTouchHandler.mouseButtonForCursorMove = mouseButton;
[handler pauseLeftButtonDrag];
}
else return;
}
- (void)switchPencilHover{
[_pencilHandler switchPencilHover];
}
- (void)enablePencilHover{
[_pencilHandler enablePencilHover];
}
- (void)disablePencilHover{
[_pencilHandler disablePencilHover];
}
- (void)setAllowSingleTouchEnabled:(BOOL)enabled{
if([touchHandler isKindOfClass:[NativeTouchHandler class]]){
NativeTouchHandler* handler = (NativeTouchHandler* )touchHandler;
[handler setAllowSingleTouchEnabled:enabled];
}
}
- (void)cleanUp{
[keyInputField resignFirstResponder];
keyInputField.delegate = nil;
}
- (void)dealloc{
NSLog(@"dealloc streamView %f", CACurrentMediaTime());
}
#if !TARGET_OS_TV
- (BOOL)isMultipleTouchEnabled {
@@ -0,0 +1,70 @@
//
// TouchPadGestureHandler.swift
// VoidLink
//
// Created by True砖家 on 2025/11/5.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
import UIKit
@objc class TouchPadGestureHandler: NSObject {
@objc public static var ctrlDown:Bool = false
@objc public static var enablePinch:Bool = true
@objc public static var ctrlDownForPinch:Bool = true
@objc public static var enableHorizontalScroll:Bool = true
@objc public static var scrollSensitivity:CGFloat = 1.0
@objc public static var pinchSensitivity:CGFloat = 1.0
@objc public static var displayLinkRate:CGFloat = 60
private static var inertialScroller: InertialScroller = InertialScroller(decelerationRate: displayLinkRate > 110 ? 0.96 : 0.9, displayLinkRate: displayLinkRate) {
if ctrlDown {return}
LiSendHighResScrollEvent(Int16(inertialScroller.vector.dy*7*scrollSensitivity))
if TouchPadGestureHandler.enableHorizontalScroll {LiSendHighResHScrollEvent(Int16(-inertialScroller.vector.dx*7*scrollSensitivity))}
}
@objc public static func startInertialScroll(){
inertialScroller.timer?.restart()
}
@objc public static func handleGesture(in view: UIView, with event: UIEvent) {
inertialScroller.timer?.pause()
let currentTouches = UITouchUtil.touches(in: view, from: event)
guard currentTouches.count == 2 else { return }
LiSendMouseButtonEvent(CChar(BUTTON_ACTION_RELEASE), BUTTON_LEFT)
LiSendMouseButtonEvent(CChar(BUTTON_ACTION_RELEASE), BUTTON_RIGHT)
guard let touch1 = currentTouches.first else { return }
var mutable = Array(currentTouches)
mutable.removeAll { $0 == touch1 }
guard let touch2 = mutable.first else { return }
let currentDistance = UITouchUtil.distance(between: touch1, and: touch2, in: view)
let previousDistance = UITouchUtil.previousDistance(between: touch1, and: touch2, in: view)
let midPointVector = UITouchUtil.midPointVector(between: touch1, and: touch2, in: view)
let midPointDeltaX = midPointVector.dx
let midPointDeltaY = midPointVector.dy
inertialScroller.vector = CGVector(dx: midPointDeltaX, dy: midPointDeltaY)
let originalPinchDelta = currentDistance-previousDistance;
let pinchDelta = enablePinch ? originalPinchDelta*7*pinchSensitivity : 0;
LiSendHighResScrollEvent(Int16(pinchDelta + midPointDeltaY*7*scrollSensitivity))
if enableHorizontalScroll {LiSendHighResHScrollEvent(Int16(-midPointDeltaX*7*scrollSensitivity))}
if enablePinch, ctrlDownForPinch {
let midPointDelta = hypot(midPointDeltaX, midPointDeltaY)
if abs(originalPinchDelta) > midPointDelta*1.3, midPointDelta < 2 {
LiSendKeyboardEvent(CommandManager.keyboardButtonMappings["CTRL"]!, CChar(KEY_ACTION_DOWN), 0)
ctrlDown = true
} else {
LiSendKeyboardEvent(CommandManager.keyboardButtonMappings["CTRL"]!, CChar(KEY_ACTION_UP), 0)
ctrlDown = false
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
//
// UITouchUtil.swift
// VoidLink
//
// Created by True砖家 on 2025/11/5.
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
import UIKit
@objc class UITouchUtil: NSObject {
@objc public static func touches(in view: UIView, from event: UIEvent?) -> Set<UITouch> {
guard (event?.allTouches) != nil else { return [] }
return event?.allTouches?.filter({ $0.view == view && $0.type == .direct}) ?? []
}
@objc public static func distance(between touch1: UITouch?, and touch2: UITouch?, in view: UIView?) -> CGFloat {
guard let view = view, let touch1 = touch1, let touch2 = touch2 else { return 0 }
let p1 = touch1.location(in: view)
let p2 = touch2.location(in: view)
return hypot(p1.x - p2.x, p1.y - p2.y)
}
@objc public static func previousDistance(between touch1: UITouch?, and touch2: UITouch?, in view: UIView?) -> CGFloat {
guard let view = view, let touch1 = touch1, let touch2 = touch2 else { return 0 }
let p1 = touch1.previousLocation(in: view)
let p2 = touch2.previousLocation(in: view)
return hypot(p1.x - p2.x, p1.y - p2.y)
}
@objc public static func vector(of touch: UITouch?, in view: UIView?) -> CGVector {
guard let view = view, let touch = touch else { return CGVectorMake(0,0) }
let p2 = touch.location(in: view)
let p1 = touch.previousLocation(in: view)
return CGVectorMake(p2.x - p1.x, p2.y - p1.y)
}
@objc public static func midPointVector(between touch1: UITouch?, and touch2: UITouch?, in view: UIView?) -> CGVector {
guard let view = view, let touch1 = touch1, let touch2 = touch2 else { return CGVector(dx: 0, dy: 0)}
let vector1 = vector(of: touch1, in: view)
let vector2 = vector(of: touch2, in: view)
let deltaX = (vector1.dx+vector2.dx)/2
let deltaY = (vector1.dy+vector2.dy)/2
return CGVector(dx: deltaX, dy: deltaY)
}
}
+19 -15
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>GCSupportsGameMode</key>
<true/>
<key>CADisableMinimumFrameDuration</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
@@ -63,22 +61,19 @@
<string>_nvstream._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Moonlight uses the local network to connect to your gaming PC for streaming.</string>
<string>VoidLink uses the local network to connect to your gaming PC for streaming.</string>
<key>NSMicrophoneUsageDescription</key>
<string>VoidLink需要麦克风访问权限。现阶段,麦克风重定向仅支持Sunshine基地版,且麦克风音频流【未经加密】。
VoidLink uses microphone to stream your voice to gaming PC. Microphone redirection currently works with Sunshine-Foundation (a fork of Sunshine) only, and the mic audio stream is NOT ENCRYPTED at this stage.</string>
<key>NSMotionUsageDescription</key>
<string>Moonlight uses the device Gyro and Acceleration data to emulate motion controls when streaming.</string>
<string>VoidLink uses the device Gyro and Acceleration data to emulate motion controls when streaming.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleExternalDisplay</key>
<array>
<dict>
<key>UISceneDelegateClassName</key>
<string>SceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>External Display Configuration</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
@@ -88,9 +83,16 @@
<string>SceneDelegate</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleExternalDisplay</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>External Display Configuration</string>
<key>UISceneDelegateClassName</key>
<string>SceneDelegate</string>
</dict>
</array>
</dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
@@ -98,6 +100,8 @@
<array>
<string>audio</string>
</array>
<key>UIDesignRequiresCompatibility</key>
<false/>
<key>UILaunchStoryboardName</key>
<string>Launch Screen</string>
<key>UIMainStoryboardFile</key>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="24F74" minimumToolsVersion="Xcode 7.3" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="24G419" minimumToolsVersion="Xcode 7.3" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="">
<entity name="App" representedClassName="App" syncable="YES" codeGenerationType="class">
<attribute name="hdrSupported" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="hidden" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
@@ -8,6 +8,7 @@
<relationship name="host" maxCount="1" deletionRule="Nullify" destinationEntity="Host" inverseName="appList" inverseEntity="Host" syncable="YES"/>
</entity>
<entity name="Host" representedClassName="Host" syncable="YES" codeGenerationType="class">
<attribute name="activeAddressPool" optional="YES" attributeType="Binary" syncable="YES"/>
<attribute name="address" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="externalAddress" optional="YES" attributeType="String" syncable="YES"/>
<attribute name="ipv6Address" optional="YES" attributeType="String" syncable="YES"/>
@@ -21,52 +22,92 @@
<relationship name="appList" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="App" inverseName="host" inverseEntity="App" syncable="YES"/>
</entity>
<entity name="Settings" representedClassName="Settings" syncable="YES" codeGenerationType="class">
<attribute name="appTheme" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="asyncFrameDequeue" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="asyncNativeTouchPriority" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="audioConfig" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="NO" syncable="YES"/>
<attribute name="audioEngine" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="backroundSessionTimer" optional="YES" attributeType="Integer 32" defaultValueString="32767" usesScalarValueType="NO" syncable="YES"/>
<attribute name="bitrate" attributeType="Integer 32" defaultValueString="10000" usesScalarValueType="NO" syncable="YES"/>
<attribute name="btMouseSupport" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="emulatedControllerType" optional="YES" attributeType="Integer 16" defaultValueString="2" usesScalarValueType="NO" syncable="YES"/>
<attribute name="buttonVisualFeedback" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="controllerMouseExpo" optional="YES" attributeType="Float" defaultValueString="1.6" usesScalarValueType="NO" syncable="YES"/>
<attribute name="controllerMouseLeftButton" attributeType="Integer 32" defaultValueString="16777215" usesScalarValueType="NO" syncable="YES"/>
<attribute name="controllerMousePointerVelocity" optional="YES" attributeType="Float" defaultValueString="12" usesScalarValueType="NO" syncable="YES"/>
<attribute name="controllerMouseRightButton" attributeType="Integer 32" defaultValueString="16777215" usesScalarValueType="NO" syncable="YES"/>
<attribute name="controllerMouseStick" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="controllerMouseSwitch" attributeType="Integer 32" defaultValueString="16777215" usesScalarValueType="NO" syncable="YES"/>
<attribute name="ctrlDownForPinch" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="delayLeftClick" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="deviceGyroMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="duckOtherApps" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="edgeSlidingSensitivity" optional="YES" attributeType="Float" defaultValueString="22" usesScalarValueType="NO" syncable="YES"/>
<attribute name="emulatedControllerType" optional="YES" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="enableFrameTimebase" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="enableGraphs" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="enableHdr" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="enablePinch" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="enablePIP" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="enableYUV444" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="externalDisplayMode" optional="YES" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="frameQueueSize" optional="YES" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="NO" syncable="YES"/>
<attribute name="framePacingMode" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="framePacingMode" optional="YES" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="frameQueueSize" optional="YES" attributeType="Integer 32" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="framerate" attributeType="Integer 32" defaultValueString="60" usesScalarValueType="NO" syncable="YES"/>
<attribute name="fullColorRange" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="graphOpacity" optional="YES" attributeType="Integer 32" defaultValueString="50" usesScalarValueType="NO" syncable="YES"/>
<attribute name="gyroBiasX" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="gyroBiasY" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="gyroBiasZ" optional="YES" attributeType="Double" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="gyroMode" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="gyroSensitivity" optional="YES" attributeType="Float" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="graphOpacity" optional="YES" attributeType="Integer 32" defaultValueString="50" usesScalarValueType="NO" syncable="YES"/>
<attribute name="hapticEngine" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="height" attributeType="Integer 32" defaultValueString="720" usesScalarValueType="NO" syncable="YES"/>
<attribute name="keyboardToggleFingers" optional="YES" attributeType="Integer 16" defaultValueString="3" usesScalarValueType="NO" syncable="YES"/>
<attribute name="leftClickDelayMs" optional="YES" attributeType="Float" defaultValueString="15" usesScalarValueType="NO" syncable="YES"/>
<attribute name="liftStreamViewForKeyboard" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="localMousePointerMode" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="localVolume" optional="YES" attributeType="Float" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="mapControllerToMouse" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="micVolume" optional="YES" attributeType="Float" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="mousePointerVelocityFactor" optional="YES" attributeType="Float" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="multiController" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="muteInBackground" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="onscreenControls" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="optimizeGames" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="oscLayoutToolFingers" optional="YES" attributeType="Integer 16" defaultValueString="4" usesScalarValueType="NO" syncable="YES"/>
<attribute name="passthroughGestures" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="pencilTickIntervalUs" optional="YES" attributeType="Float" defaultValueString="2000" usesScalarValueType="NO" syncable="YES"/>
<attribute name="pencilTickMode" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="pinchSensitivity" optional="YES" attributeType="Float" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="playAudioOnPC" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="pointerVelocityModeDivider" optional="YES" attributeType="Float" defaultValueString="0.5" usesScalarValueType="NO" syncable="YES"/>
<attribute name="preferredCodec" attributeType="Integer 32" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="preferredCodec" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="YES" syncable="YES"/>
<attribute name="redirectMic" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="relativeTouchSlideThreshold" optional="YES" attributeType="Float" defaultValueString="6" usesScalarValueType="NO" syncable="YES"/>
<attribute name="rememberFoldState" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="renderingBackend" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="resolutionSelected" optional="YES" attributeType="Integer 16" defaultValueString="4" usesScalarValueType="NO" syncable="YES"/>
<attribute name="reverseMouseWheelDirection" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="scrollSensitivity" optional="YES" attributeType="Float" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="sdrPerformanceWorkaround" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="sendDummyEvent" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="settingsMenuMode" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="settingsMenuOffset" optional="YES" attributeType="Float" defaultValueString="22" usesScalarValueType="NO" syncable="YES"/>
<attribute name="settingsMenuWidth" attributeType="Float" defaultValueString="370" usesScalarValueType="NO" syncable="YES"/>
<attribute name="showKeyboardToolbar" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="showKeyboardToolbar" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES" syncable="YES"/>
<attribute name="singleTapSensitivity" optional="YES" attributeType="Double" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="slideToSettingsDistance" optional="YES" attributeType="Float" defaultValueString="0.2" usesScalarValueType="NO" syncable="YES"/>
<attribute name="slideToSettingsScreenEdge" optional="YES" attributeType="Integer 32" defaultValueString="2" usesScalarValueType="NO" syncable="YES"/>
<attribute name="statsOverlayEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="statsOverlayLevel" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="renderingBackend" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="swapABXYButtons" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="touchMode" optional="YES" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="touchMoveEventInterval" optional="YES" attributeType="Integer 16" defaultValueString="320" usesScalarValueType="NO" syncable="YES"/>
<attribute name="touchMoveEventInterval" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
<attribute name="touchPointerVelocityFactor" optional="YES" attributeType="Float" defaultValueString="1" usesScalarValueType="NO" syncable="YES"/>
<attribute name="touchPointTracking" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="uniqueId" attributeType="String" syncable="YES"/>
<attribute name="unlockDisplayOrientation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="useBuiltinMic" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
<attribute name="width" attributeType="Integer 32" defaultValueString="1280" usesScalarValueType="NO" syncable="YES"/>
</entity>
</model>
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -19,25 +19,36 @@
@interface MenuSectionView : UIView
@property (class, nonatomic, assign) BOOL overridePersistedFoldState;
// 外部可访问属性
@property (nonatomic, strong) UIStackView *rootStackView;
@property (nonatomic, assign) CGFloat leadingTrailingPadding;
@property (nonatomic, assign) CGFloat separatorLinePadding;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, copy) NSString *sectionTitle;
@property (nonatomic, assign) BOOL expanded;
@property (nonatomic, copy) NSString *identifier;
@property (nonatomic, assign) BOOL isExpanded;
@property (nonatomic, assign) BOOL expandable;
@property (nonatomic, copy) void (^lockedSectionHandler)(void);
@property (nonatomic, strong) UIColor *backgroundColor;
@property (nonatomic, assign) CGFloat rootStackViewSpacing;
@property (nonatomic, assign) CGFloat headerViewHeight;
@property (nonatomic, assign) CGFloat headerViewVerticalSpacing;
@property (nonatomic, strong) NSMutableArray<UIStackView *> *subStackViews;
@property (nonatomic, strong) UIImageView *iconImageView;
@property (nonatomic, strong) UIView *separatorLine;
@property (nonatomic, weak) id<MenuSectionDelegate> delegate; // Delegate property
// 方法
- (void)setSectionWithIcon:(UIImage *)icon andSize:(CGFloat)size;
- (void)setSectionWithIcon:(UIImage *)icon size:(CGFloat)size sizeConstraint:(CGFloat)constant;
- (void)setSectionWithIcon:(UIImage *)icon size:(CGFloat)size weight:(UIImageSymbolWeight)weight sizeConstraint:(CGFloat)constant API_AVAILABLE(ios(13.0));
- (void)addSubStackView:(UIStackView *)stackView;
- (void)addToParentStack:(UIStackView *)parentStack;
- (void)removeSubStackView:(UIStackView *)stackView;
- (void)updateLayout;
- (void)updateViewForFoldState;
- (void)setExpanded:(BOOL)overridePersistedState;
@end
+89 -36
View File
@@ -8,24 +8,32 @@
// MenuSectionView.m
#import "MenuSectionView.h"
#import "ThemeManager.h"
#import "VoidLink-Swift.h"
@interface MenuSectionView ()
@property (nonatomic, strong) UIStackView *rootStackView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UIImageView *iconImageView;
@property (nonatomic, strong) UIButton *toggleButton;
@property (nonatomic, strong) UIButton *toggleArea;
@property (nonatomic, strong) NSLayoutConstraint *heightConstraint;
@property (nonatomic, strong) UIView *headerView;
@property (nonatomic, strong) UIView *separatorLine;
@property (assign) CGFloat titleOffset;
@end
@implementation MenuSectionView
static BOOL overridePersistedFoldState = YES;
+ (BOOL)overridePersistedFoldState {
return overridePersistedFoldState;
}
+ (void)setOverridePersistedFoldState:(BOOL)val {
overridePersistedFoldState = val;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
@@ -44,15 +52,17 @@
- (void)commonInit {
// 默认值
_leadingTrailingPadding = 0;
_leadingTrailingPadding = 5;
_separatorLinePadding = 40;
_sectionTitle = @"Section";
_expanded = YES;
_isExpanded = YES;
_expandable = true;
_backgroundColor = [UIColor clearColor];
_rootStackViewSpacing = [self isIPhone] ? 10 : 18;
_rootStackViewSpacing = [self isIPhone] ? 10 : 12;
_subStackViews = [NSMutableArray array];
_headerViewHeight = 37;
_headerViewVerticalSpacing = 25;
_titleOffset = 41.5;
// 设置视图
self.layer.cornerRadius = 10.0;
@@ -68,14 +78,15 @@
_iconImageView = [[UIImageView alloc] init];
_iconImageView.contentMode = UIViewContentModeScaleAspectFit;
_iconImageView.translatesAutoresizingMaskIntoConstraints = NO;
_iconImageView.tintColor = [ThemeManager textColor];
_iconImageView.tintColor = ThemeManager.textColor;
[_headerView addSubview:_iconImageView];
// 标题标签
_titleLabel = [[UILabel alloc] init];
_titleLabel.text = _sectionTitle;
_titleLabel.font = [UIFont systemFontOfSize:23 weight:UIFontWeightMedium];
_titleLabel.textColor = [ThemeManager textColor];
_titleLabel.accessibilityIdentifier = @"menuSectionTitleLabel";
_titleLabel.font = [UIFont systemFontOfSize:19.5 weight:UIFontWeightMedium];
_titleLabel.textColor = ThemeManager.textColor;
_titleLabel.textAlignment = NSTextAlignmentLeft;
_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
[_headerView addSubview:_titleLabel];
@@ -85,7 +96,7 @@
_toggleArea = [UIButton buttonWithType:UIButtonTypeSystem];
if (@available(iOS 13.0, *)) {
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:_headerViewHeight*0.31 weight:UIImageSymbolWeightBold];
[_toggleButton setImage:[UIImage systemImageNamed:@"chevron.left" withConfiguration:config] forState:UIControlStateNormal];
[_toggleButton setImage:[UIImage systemImageNamed:@"chevron.right" withConfiguration:config] forState:UIControlStateNormal];
} else {
// [_toggleButton setTitle:@"<" forState:UIControlStateNormal];
}
@@ -107,7 +118,7 @@
// 分隔线
_separatorLine = [[UIView alloc] init];
_separatorLine.backgroundColor = [ThemeManager separatorColor];
_separatorLine.backgroundColor = ThemeManager.separatorColor;
_separatorLine.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:_separatorLine];
@@ -119,6 +130,10 @@
// 布局
[self setupConstraints];
[self updateViewForFoldState];
self.lockedSectionHandler = ^{
NSLog(@"Null execution of lockedSectionHandler");
};
}
- (void)setupConstraints {
@@ -129,20 +144,11 @@
[_headerView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[_headerView.heightAnchor constraintEqualToConstant:_headerViewHeight]
]];
// 图标约束
[NSLayoutConstraint activateConstraints:@[
[_iconImageView.centerXAnchor constraintEqualToAnchor:_headerView.leadingAnchor constant:_leadingTrailingPadding+_headerViewHeight/2-3],
[_iconImageView.centerYAnchor constraintEqualToAnchor:_headerView.centerYAnchor],
// [_iconImageView.topAnchor constraintEqualToAnchor:_headerView.topAnchor],
[_iconImageView.widthAnchor constraintEqualToConstant:_headerViewHeight-6],
//[_iconImageView.heightAnchor constraintEqualToConstant:_headerViewHeight-6]
]];
// 标题约束
if(@available(iOS 13.0, *)){
[NSLayoutConstraint activateConstraints:@[
[_titleLabel.leadingAnchor constraintEqualToAnchor:_iconImageView.trailingAnchor constant:8],
[_titleLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:_titleOffset],
[_titleLabel.centerYAnchor constraintEqualToAnchor:_headerView.centerYAnchor],
[_titleLabel.trailingAnchor constraintEqualToAnchor:_toggleButton.leadingAnchor constant:-8]
]];
@@ -166,7 +172,7 @@
[_toggleArea.centerYAnchor constraintEqualToAnchor:_headerView.centerYAnchor],
[_toggleArea.trailingAnchor constraintEqualToAnchor:_toggleButton.leadingAnchor],
//[_toggleArea.widthAnchor constraintEqualToConstant:_headerViewHeight],
[_toggleArea.heightAnchor constraintEqualToConstant:_headerViewHeight]
[_toggleArea.heightAnchor constraintEqualToConstant:_headerViewHeight+25]
]];
[self layoutIfNeeded];
@@ -185,9 +191,9 @@
//[_separatorLine.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
//[_separatorLine.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[_separatorLine.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[_separatorLine.widthAnchor constraintEqualToConstant:1000], // //mark: settingMenuLayout
[_separatorLine.widthAnchor constraintEqualToAnchor:self.widthAnchor constant:-5], // //mark: settingMenuLayout
[_separatorLine.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
[_separatorLine.heightAnchor constraintEqualToConstant:2]
[_separatorLine.heightAnchor constraintEqualToConstant:GenericUtils.menuSectionSeparatorWidth]
]];
[NSLayoutConstraint activateConstraints:@[
@@ -198,7 +204,7 @@
#pragma mark - Public Methods
- (void)setSectionWithIcon:(UIImage *)icon andSize:(CGFloat)size{
- (void)setSectionWithIcon:(UIImage *)icon size:(CGFloat)size sizeConstraint:(CGFloat)constant{
if (@available(iOS 13.0, *)) {
UIImageSymbolConfiguration *config;
if(icon.isSymbolImage) config = [UIImageSymbolConfiguration configurationWithPointSize:size weight:UIImageSymbolWeightBold];
@@ -210,8 +216,44 @@
} else {
_iconImageView.image = icon;
}
_iconImageView.tintColor = [ThemeManager textColor];
_iconImageView.tintColor = ThemeManager.textColor;
_iconImageView.hidden = (icon == nil);
// 图标约束
[NSLayoutConstraint activateConstraints:@[
[_iconImageView.centerXAnchor constraintEqualToAnchor:_headerView.leadingAnchor constant:_leadingTrailingPadding+_headerViewHeight/2-3],
[_iconImageView.centerYAnchor constraintEqualToAnchor:_headerView.centerYAnchor constant:-0],
[_iconImageView.widthAnchor constraintEqualToConstant:_headerViewHeight+constant],
[_iconImageView.heightAnchor constraintEqualToConstant:_headerViewHeight+constant]
]];
[self updateLayout];
}
- (void)setSectionWithIcon:(UIImage *)icon size:(CGFloat)size weight:(UIImageSymbolWeight)weight sizeConstraint:(CGFloat)constant API_AVAILABLE(ios(13.0)){
if (@available(iOS 13.0, *)) {
UIImageSymbolConfiguration *config;
if(icon.isSymbolImage) config = [UIImageSymbolConfiguration configurationWithPointSize:size weight:weight];
else{
config = [UIImageSymbolConfiguration configurationWithPointSize:size];
icon = [icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
}
_iconImageView.image = [icon imageWithConfiguration:config];
} else {
_iconImageView.image = icon;
}
_iconImageView.tintColor = ThemeManager.textColor;
_iconImageView.hidden = (icon == nil);
// 图标约束
[NSLayoutConstraint activateConstraints:@[
[_iconImageView.centerXAnchor constraintEqualToAnchor:_headerView.leadingAnchor constant:_leadingTrailingPadding+_headerViewHeight/2-3],
[_iconImageView.centerYAnchor constraintEqualToAnchor:_headerView.centerYAnchor],
// [_iconImageView.topAnchor constraintEqualToAnchor:_headerView.topAnchor],
[_iconImageView.widthAnchor constraintEqualToConstant:_headerViewHeight+constant],
[_iconImageView.heightAnchor constraintEqualToConstant:_headerViewHeight+constant]
]];
[self updateLayout];
}
@@ -226,9 +268,10 @@
}
- (void)setExpanded:(BOOL)isExpanded {
if (_expanded != isExpanded) {
_expanded = isExpanded;}
[self updateViewForFoldState];
self.isExpanded = !_expandable ? false : isExpanded;
[self updateViewForFoldState];
[[NSUserDefaults standardUserDefaults] setBool:self.isExpanded forKey:self.identifier];
[[NSUserDefaults standardUserDefaults] synchronize];
}
- (void)setBackgroundColor:(UIColor *)backgroundColor {
@@ -264,6 +307,11 @@
[self.leadingAnchor constraintEqualToAnchor:parentStack.leadingAnchor constant:0],
[self.trailingAnchor constraintEqualToAnchor:parentStack.trailingAnchor constant:0],
]];
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
BOOL persistedFoldState = [defaults objectForKey:self.identifier] ? [defaults boolForKey:self.identifier] : YES;
[self setExpanded: MenuSectionView.overridePersistedFoldState ? _expandable : persistedFoldState];
}
- (void)removeSubStackView:(UIStackView *)stackView {
@@ -308,8 +356,11 @@
}
- (void)toggleFold {
self.expanded = !self.expanded;
self.isExpanded = !_expandable ? false : !self.isExpanded;
if(!_expandable && self.lockedSectionHandler) self.lockedSectionHandler();
[self updateViewForFoldState];
[[NSUserDefaults standardUserDefaults] setBool:self.isExpanded forKey:self.identifier];
[[NSUserDefaults standardUserDefaults] synchronize];
}
- (SettingsMenuMode)getSettingsMenuMode {
@@ -320,7 +371,9 @@
- (void)updateViewForFoldState {
[self setupConstraints];
// [self setupConstraints];
// don't run this repeatedly
NSInteger visibleCount = 0;
for (UIView *subview in _rootStackView.arrangedSubviews) {
if (!subview.isHidden) {
@@ -328,7 +381,7 @@
}
}
self.hidden = visibleCount == 0;
if (_expanded) {
if (_isExpanded) {
_rootStackView.hidden = NO;
_separatorLine.hidden = NO;
CGSize fittingSize = [self.rootStackView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
@@ -340,7 +393,7 @@
self.heightConstraint.constant = headerHeight + _headerViewVerticalSpacing + rootStackViewHeight + _separatorLinePadding + 1;
[UIView animateWithDuration:0 animations:^{
self.toggleButton.transform = CGAffineTransformMakeRotation(M_PI+M_PI_2);
self.toggleButton.transform = CGAffineTransformMakeRotation(M_PI/2);
[NSLayoutConstraint activateConstraints:@[
[self.rootStackView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-self.separatorLinePadding],
// [self.rootStackView.topAnchor constraintEqualToAnchor:self.headerView.bottomAnchor constant:0],
+7 -4
View File
@@ -12,6 +12,7 @@
#import "ConnectionCallbacks.h"
#import "Frame.h"
#import "Plot.h"
#import "TemporarySettings.h"
@interface MetalVideoRenderer : NSObject
@@ -22,11 +23,13 @@
@property (nonatomic) id<CAMetalDrawable> _Nullable nextDrawable;
@property (atomic) BOOL isStopping;
@property (nonatomic) BOOL hdrEnabled;
@property (nonatomic, readonly, nonnull) dispatch_semaphore_t inFlightSemaphore;
- (nonnull instancetype)initWithMetalDevice:(nonnull id<MTLDevice>)device drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat framerate:(float)framerate hdrEnabled:(BOOL)hdrEnabled;
- (void)renderFrame:(nonnull Frame *)frame withDrawable:(nonnull id<CAMetalDrawable>)drawable API_AVAILABLE(ios(17.0));
- (void)renderFrame:(nonnull Frame *)frame withDrawable:(nonnull id<CAMetalDrawable>)drawable targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0));
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer;
- (instancetype _Nonnull )initWithMetalDevice:(id<MTLDevice>_Nonnull)device drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat settings:(TemporarySettings* _Nonnull )currentSettings;
- (void)renderFrame:(nonnull Frame *)frame toLayer:(nonnull CAMetalLayer *)layer;
- (BOOL)waitToRenderTo:(nonnull CAMetalLayer *)layer API_AVAILABLE(ios(13.0));
- (void)drawableResize:(CGSize)drawableSize;
- (void)shutdown;
+178 -95
View File
@@ -130,25 +130,36 @@ CFStringRef __currentColorSpace;
size_t _lastDrawableHeight;
id<MTLBuffer> _CscParamsBuffer;
id<MTLBuffer> _VideoVertexBuffer;
CFStringRef _nonFullHdrColorSpace;
MTLPixelFormat _nonFullHdrPixelFormat;
// https://developer.apple.com/documentation/metal/synchronizing-cpu-and-gpu-work?language=objc
dispatch_semaphore_t _inFlightSemaphore;
}
- (instancetype)initWithMetalDevice:(id<MTLDevice>)device drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat framerate:(float)framerate hdrEnabled:(BOOL)hdrEnabled {
@synthesize inFlightSemaphore = _inFlightSemaphore;
- (instancetype)initWithMetalDevice:(id<MTLDevice>)device drawablePixelFormat:(MTLPixelFormat)drawablePixelFormat settings:(TemporarySettings* )currentSettings {
self = [super init];
if (self) {
_sq = dispatch_queue_create("com.moonlight.MetalVideoRenderer",
dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0));
_averageGPUTime = (1.0f / framerate) / 2;
_averageGPUTime = (1.0f / currentSettings.framerate.floatValue) / 2;
_device = device;
_colorPixelFormat = MTLPixelFormatBGR10A2Unorm;
_framerate = framerate;
_hdrEnabled = hdrEnabled;
_colorPixelFormat = drawablePixelFormat;
_framerate = currentSettings.framerate.floatValue;
_hdrEnabled = currentSettings.enableHdr;
_commandQueue = [_device newCommandQueue];
_currentEDRHeadroom = 1.0f;
_lastColorSpace = -1;
_lastFullRange = NO;
// Initialize default CSC parameters buffer with BT.601 limited range
struct ParamBuffer defaultParamBuffer;
defaultParamBuffer.cscParams = k_CscParams_Bt601Lim;
MTLResourceOptions bufferOptions = MTLResourceStorageModeShared;
_CscParamsBuffer = [_device newBufferWithBytes:(void *)&defaultParamBuffer length:sizeof(defaultParamBuffer) options:bufferOptions];
_lastPresented = 0.0f;
_inFlightSemaphore = dispatch_semaphore_create(MaxFramesInFlight);
_isStopping = NO;
@@ -164,6 +175,10 @@ CFStringRef __currentColorSpace;
_renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0);
_renderPassDescriptor.colorAttachments[0].storeAction = MTLStoreActionStore;
if (@available(iOS 14.0, *)) _nonFullHdrColorSpace = kCGColorSpaceITUR_2100_PQ;
else _nonFullHdrColorSpace = kCGColorSpaceITUR_2020;
_nonFullHdrPixelFormat = MTLPixelFormatRGBA16Float;
// Initialize texture array to NULL
for (int i = 0; i < MAX_VIDEO_PLANES; i++) {
_cvMetalTextures[i] = NULL;
@@ -393,16 +408,12 @@ CFStringRef __currentColorSpace;
CFStringRef frame_trc = CFDictionaryGetValue(ext, kCVImageBufferTransferFunctionKey);
if (CFEqual(frame_trc, kCVImageBufferTransferFunction_SMPTE_ST_2084_PQ)) {
isHDR = YES;
if (@available(iOS 14.0, *)) {
newColorSpace = CGColorSpaceCreateWithName(kCGColorSpaceITUR_2100_PQ);
} else {
newColorSpace = CGColorSpaceCreateWithName(kCGColorSpaceITUR_2020);
}
newPixelFormat = MTLPixelFormatBGR10A2Unorm;
newColorSpace = CGColorSpaceCreateWithName(_nonFullHdrColorSpace);
newPixelFormat = _nonFullHdrPixelFormat;
} else {
// SDR 2020, I'm not sure it's possible to stream this though
newColorSpace = CGColorSpaceCreateWithName(kCGColorSpaceITUR_2020);
newPixelFormat = MTLPixelFormatBGR10A2Unorm;
newColorSpace = CGColorSpaceCreateWithName(_nonFullHdrColorSpace);
newPixelFormat = _nonFullHdrPixelFormat;
}
if (isHDR) {
paramBuffer.cscParams = (fullRange ? k_CscParams_Bt2020Full_10bit : k_CscParams_Bt2020Lim_10bit);
@@ -433,11 +444,11 @@ CFStringRef __currentColorSpace;
Log(LOG_I,
@"Frame pixel format %@ - changing MetalLayer's pixel format to %@",
layer.pixelFormat == MTLPixelFormatBGRA8Unorm ? @"MTLPixelFormatBGRA8Unorm"
: layer.pixelFormat == MTLPixelFormatBGR10A2Unorm ? @"MTLPixelFormatBGR10A2Unorm"
: layer.pixelFormat == MTLPixelFormatBGRA10_XR ? @"MTLPixelFormatBGRA10_XR"
: [NSString stringWithFormat:@"Unknown: %lu", layer.pixelFormat],
newPixelFormat == MTLPixelFormatBGRA8Unorm ? @"MTLPixelFormatBGRA8Unorm"
: newPixelFormat == MTLPixelFormatBGR10A2Unorm ? @"MTLPixelFormatBGR10A2Unorm"
: [NSString stringWithFormat:@"Unknown: %lu", (unsigned long)layer.pixelFormat]);
: newPixelFormat == MTLPixelFormatBGRA10_XR ? @"MTLPixelFormatBGRA10_XR"
: [NSString stringWithFormat:@"Unknown: %lu", (unsigned long)newPixelFormat]);
}
#if TARGET_OS_TV
@@ -488,6 +499,10 @@ CFStringRef __currentColorSpace;
_lastColorSpace = colorspace;
_lastFullRange = fullRange;
}
// NSLog(@"layer.pixelFormat %lu", (unsigned long)layer.pixelFormat);
return YES;
}
@@ -557,29 +572,21 @@ CFStringRef __currentColorSpace;
return YES;
}
- (void)renderFrame:(Frame *)frame withDrawable:(id<CAMetalDrawable>)drawable targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0)) {
- (void)renderFrame:(Frame *)frame toLayer:(CAMetalLayer *)layer {
@autoreleasepool {
if (self.isStopping) {
Log(LOG_I, @"[MetalVideoRenderer] isStopping");
return;
}
// Calculate accurate frametime using CAMetalDisplayLink timing
if (self.lastPresented > 0.0f) {
CFTimeInterval frametime = targetPresentationTimestamp - self.lastPresented;
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:(frametime * 1000.0)];
}
self.lastPresented = targetPresentationTimestamp;
// Handle changes to the frame's colorspace from last time we rendered
BOOL layerDidChange = NO;
if (![self updateColorSpaceForFrame:frame toLayer:(CAMetalLayer *)drawable.layer layerDidChange:&layerDidChange]) {
if (![self updateColorSpaceForFrame:frame toLayer:layer layerDidChange:&layerDidChange]) {
return;
}
// Handle changes to the video size or drawable size
if (![self updateVideoRegionSizeForFrame:frame toLayer:(CAMetalLayer *)drawable.layer]) {
if (![self updateVideoRegionSizeForFrame:frame toLayer:layer]) {
return;
}
@@ -591,6 +598,15 @@ CFStringRef __currentColorSpace;
}
size_t planes = CVPixelBufferGetPlaneCount(frame.pixelBuffer);
// For packed formats like BGRA, plane count is 0 but we treat it as 1 plane
OSType pixelFormatType = CVPixelBufferGetPixelFormatType(frame.pixelBuffer);
BOOL isPackedFormat = (pixelFormatType == kCVPixelFormatType_32BGRA ||
pixelFormatType == kCVPixelFormatType_32ARGB);
if (isPackedFormat) {
planes = 1; // Treat packed formats as single plane
}
assert(planes <= MAX_VIDEO_PLANES);
if (layerDidChange && frame.frameNumber > 1) {
@@ -604,6 +620,13 @@ CFStringRef __currentColorSpace;
}
}
// Get the next drawable early to get its pixel format
id<CAMetalDrawable> drawable = [layer nextDrawable];
if (!drawable) {
Log(LOG_E, @"Failed to get nextDrawable");
return;
}
// Get the framebuffer pixel format for pipeline creation
MTLPixelFormat framebufferPixelFormat = drawable.texture.pixelFormat;
@@ -622,16 +645,26 @@ CFStringRef __currentColorSpace;
// linear shaders
id<MTLFunction> yuvToLinear = [defaultLibrary newFunctionWithName:@"yuvToLinear"];
// Determine if this is 10-bit based on the framebuffer's pixel format
BOOL is10Bit = (framebufferPixelFormat == MTLPixelFormatBGR10A2Unorm);
// Determine if this is 10-bit based on the input CVPixelBuffer format, not the output framebuffer format
// pixelFormatType is already declared above
BOOL is10BitInput = (pixelFormatType == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange ||
pixelFormatType == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange ||
pixelFormatType == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange ||
pixelFormatType == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange);
NSString *fragmentShaderName;
if (planes == 2) {
fragmentShaderName = is10Bit ? @"ps_draw_biplanar_10bit" : @"ps_draw_biplanar_8bit";
if (isPackedFormat) {
// BGRA/ARGB packed formats don't need color space conversion
fragmentShaderName = @"ps_draw_bgra";
} else if (planes == 2) {
fragmentShaderName = is10BitInput ? @"ps_draw_biplanar_10bit" : @"ps_draw_biplanar_8bit";
} else {
fragmentShaderName = is10Bit ? @"ps_draw_triplanar_10bit" : @"ps_draw_triplanar_8bit";
fragmentShaderName = is10BitInput ? @"ps_draw_triplanar_10bit" : @"ps_draw_triplanar_8bit";
}
Log(LOG_I, @"DEBUG: CVPixelBuffer format: 0x%X, planes: %zu, is10BitInput: %d, shader: %@, framebuffer format: %lu",
pixelFormatType, planes, is10BitInput, fragmentShaderName, (unsigned long)framebufferPixelFormat);
pipelineDesc.colorAttachments[0].pixelFormat = framebufferPixelFormat;
pipelineDesc.vertexBuffers[0].mutability = MTLMutabilityImmutable;
@@ -659,41 +692,78 @@ CFStringRef __currentColorSpace;
_videoPipelinePixelFormat[planes] = framebufferPixelFormat;
}
for (size_t i = 0; i < planes; i++) {
MTLPixelFormat fmt;
switch (CVPixelBufferGetPixelFormatType(frame.pixelBuffer)) {
case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
case kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange:
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange:
case kCVPixelFormatType_444YpCbCr8BiPlanarFullRange:
fmt = (i == 0) ? MTLPixelFormatR8Unorm : MTLPixelFormatRG8Unorm;
break;
case kCVPixelFormatType_420YpCbCr10BiPlanarFullRange:
case kCVPixelFormatType_444YpCbCr10BiPlanarFullRange:
case kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange:
case kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange:
fmt = (i == 0) ? MTLPixelFormatR16Unorm : MTLPixelFormatRG16Unorm;
break;
default:
Log(LOG_E, @"Unknown pixel format: %@", CVPixelBufferGetPixelFormatType(frame.pixelBuffer));
return;
if (isPackedFormat) {
// Handle packed BGRA format - iOS/macOS uses BGRA internally
// Check if the pixel buffer has IOSurface backing
CFTypeRef ioSurface = CVPixelBufferGetIOSurface(frame.pixelBuffer);
if (!ioSurface) {
Log(LOG_E, @"CVPixelBuffer does not have IOSurface backing - cannot create Metal texture");
return;
}
CVReturn err = CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
_textureCache,
frame.pixelBuffer,
NULL,
fmt,
CVPixelBufferGetWidthOfPlane(frame.pixelBuffer, i),
CVPixelBufferGetHeightOfPlane(frame.pixelBuffer, i),
i,
&_cvMetalTextures[i]);
MTLPixelFormatBGRA8Unorm,
CVPixelBufferGetWidth(frame.pixelBuffer),
CVPixelBufferGetHeight(frame.pixelBuffer),
0, // planeIndex must be 0 for non-planar
&_cvMetalTextures[0]);
if (err != kCVReturnSuccess) {
Log(LOG_E, @"CVMetalTextureCacheCreateTextureFromImage() failed: %d", err);
Log(LOG_E, @"CVMetalTextureCacheCreateTextureFromImage() failed for BGRA: %d", err);
Log(LOG_E, @"PixelBuffer info - format: 0x%X, width: %zu, height: %zu, IOSurface: %p",
pixelFormatType,
CVPixelBufferGetWidth(frame.pixelBuffer),
CVPixelBufferGetHeight(frame.pixelBuffer),
ioSurface);
return;
} else {
id<MTLTexture> texture = CVMetalTextureGetTexture(_cvMetalTextures[0]);
Log(LOG_I, @"DEBUG: Created BGRA texture: format=%lu, width=%zu, height=%zu",
(unsigned long)MTLPixelFormatBGRA8Unorm,
CVPixelBufferGetWidth(frame.pixelBuffer),
CVPixelBufferGetHeight(frame.pixelBuffer));
}
} else {
// Handle planar YUV formats
size_t actualPlanes = CVPixelBufferGetPlaneCount(frame.pixelBuffer);
for (size_t i = 0; i < actualPlanes; i++) {
MTLPixelFormat fmt;
switch (CVPixelBufferGetPixelFormatType(frame.pixelBuffer)) {
case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange:
case kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange:
case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange:
case kCVPixelFormatType_444YpCbCr8BiPlanarFullRange:
fmt = (i == 0) ? MTLPixelFormatR8Unorm : MTLPixelFormatRG8Unorm;
break;
case kCVPixelFormatType_420YpCbCr10BiPlanarFullRange:
case kCVPixelFormatType_444YpCbCr10BiPlanarFullRange:
case kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange:
case kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange:
fmt = (i == 0) ? MTLPixelFormatR16Unorm : MTLPixelFormatRG16Unorm;
break;
default:
Log(LOG_E, @"Unknown pixel format: %@", CVPixelBufferGetPixelFormatType(frame.pixelBuffer));
return;
}
CVReturn err = CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
_textureCache,
frame.pixelBuffer,
NULL,
fmt,
CVPixelBufferGetWidthOfPlane(frame.pixelBuffer, i),
CVPixelBufferGetHeightOfPlane(frame.pixelBuffer, i),
i,
&_cvMetalTextures[i]);
if (err != kCVReturnSuccess) {
Log(LOG_E, @"CVMetalTextureCacheCreateTextureFromImage() failed: %d", err);
return;
}
}
}
@@ -703,14 +773,26 @@ CFStringRef __currentColorSpace;
id<MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:_renderPassDescriptor];
[renderEncoder setRenderPipelineState:_videoPipelineState[planes]];
for (size_t i = 0; i < planes; i++) {
[renderEncoder setFragmentTexture:CVMetalTextureGetTexture(_cvMetalTextures[i]) atIndex:i];
if (isPackedFormat) {
// For packed formats, we only have one texture
[renderEncoder setFragmentTexture:CVMetalTextureGetTexture(_cvMetalTextures[0]) atIndex:0];
} else {
// For planar formats, set multiple textures
size_t actualPlanes = CVPixelBufferGetPlaneCount(frame.pixelBuffer);
for (size_t i = 0; i < actualPlanes; i++) {
[renderEncoder setFragmentTexture:CVMetalTextureGetTexture(_cvMetalTextures[i]) atIndex:i];
}
}
[renderEncoder setVertexBuffer:_VideoVertexBuffer offset:0 atIndex:0];
[renderEncoder setFragmentBuffer:_CscParamsBuffer offset:0 atIndex:0];
// Only set CSC params buffer for YUV formats that need color space conversion
if (!isPackedFormat) {
[renderEncoder setFragmentBuffer:_CscParamsBuffer offset:0 atIndex:0];
}
#if !TARGET_OS_TV
if (((CAMetalLayer *)drawable.layer).pixelFormat == MTLPixelFormatRGBA16Float) {
if (layer.pixelFormat == MTLPixelFormatRGBA16Float) {
[self pollCurrentEDRHeadroom];
[renderEncoder setFragmentBytes:&_currentEDRHeadroom length:sizeof(float) atIndex:1];
}
@@ -718,33 +800,29 @@ CFStringRef __currentColorSpace;
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4];
[renderEncoder endEncoding];
__block MetalVideoRenderer *strongSelf = self;
#if !TARGET_OS_SIMULATOR
[drawable addPresentedHandler:^(id<MTLDrawable> d) {
if (strongSelf.lastPresented > 0.0f) {
CFTimeInterval frametime = d.presentedTime - strongSelf.lastPresented;
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:(frametime * 1000.0)];
}
strongSelf.lastPresented = d.presentedTime;
}];
#endif
// signal semaphore, compute GPU time average, and clear textures
__block dispatch_semaphore_t block_semaphore = _inFlightSemaphore;
__block size_t texturesToClean = isPackedFormat ? 1 : CVPixelBufferGetPlaneCount(frame.pixelBuffer);
[commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> cb) {
dispatch_semaphore_signal(block_semaphore);
if (cb.error) {
Log(LOG_E, @"Command buffer error: %@", cb.error);
// Request IDR frame to recover from rendering error
LiRequestIdrFrame();
// Clear the command queue to reset GPU state
if (cb.status == MTLCommandBufferStatusError) {
NSError *error = cb.error;
if (error.code == 2 || error.code == 4) { // GPU timeout or submissions ignored
Log(LOG_W, @"GPU timeout detected, flushing command queue");
// Force texture cache flush
CVMetalTextureCacheFlush(self->_textureCache, 0);
}
}
} else {
const CFTimeInterval GPUTime = cb.GPUEndTime - cb.GPUStartTime;
const double alpha = 0.25f;
self->_averageGPUTime = (GPUTime * alpha) + (self->_averageGPUTime * (1.0 - alpha));
}
const CFTimeInterval GPUTime = cb.GPUEndTime - cb.GPUStartTime;
const double alpha = 0.25f;
self->_averageGPUTime = (GPUTime * alpha) + (self->_averageGPUTime * (1.0 - alpha));
// Free textures after completion of rendering
for (size_t i = 0; i < planes; i++) {
for (size_t i = 0; i < texturesToClean; i++) {
if (self->_cvMetalTextures[i]) {
CFRelease(self->_cvMetalTextures[i]);
self->_cvMetalTextures[i] = NULL;
@@ -754,27 +832,32 @@ CFStringRef __currentColorSpace;
CVMetalTextureCacheFlush(self->_textureCache, 0);
}];
// CAMetalDisplayLink handles timing automatically, so we don't use afterMinimumDuration
#if TARGET_OS_SIMULATOR
[commandBuffer presentDrawable:drawable];
#else
// present for a minimum duration for best frame pacing
[commandBuffer presentDrawable:drawable afterMinimumDuration:1.0f / _framerate];
#endif
[commandBuffer commit];
[commandBuffer waitUntilCompleted];
}
}
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer {
- (BOOL)waitToRenderTo:(nonnull CAMetalLayer *)layer {
// Wait to ensure only `MaxFramesInFlight` number of frames are getting processed
// by any stage in the Metal pipeline (CPU, GPU, Metal, Drivers, etc.).
if (!self.isStopping) {
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1f * NSEC_PER_SEC)); // 100ms
long result = dispatch_semaphore_wait(_inFlightSemaphore, timeout);
if (result != 0) {
Log(LOG_W, @"MetalVideoRenderer semaphore timeout - requesting IDR frame");
LiRequestIdrFrame(); // Request recovery
// Force flush the texture cache when we timeout
CVMetalTextureCacheFlush(_textureCache, 0);
}
if (self.isStopping) {
return NO;
}
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1f * NSEC_PER_SEC)); // 100ms
if (dispatch_semaphore_wait(_inFlightSemaphore, timeout) != 0) {
Log(LOG_W, @"Timed out waiting for in-flight frame buffer.");
return NO;
}
return YES;
}
- (void)shutdown {
+2 -3
View File
@@ -15,14 +15,13 @@
@protocol MetalViewDelegate <NSObject>
- (void)drawableResize:(CGSize)size;
- (void)renderWithDrawable:(nonnull id<CAMetalDrawable>)drawable toLayer:(nonnull CAMetalLayer *)layer API_AVAILABLE(ios(17.0));
- (void)renderWithDrawable:(nonnull id<CAMetalDrawable>)drawable toLayer:(nonnull CAMetalLayer *)layer targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0));
- (void)renderTo:(nonnull CAMetalLayer *)layer;
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer;
@end
// The Metal game view base class.
@interface MetalView : UIView <CALayerDelegate, CAMetalDisplayLinkDelegate>
@interface MetalView : UIView <CALayerDelegate>
@property (nonatomic, nonnull, readonly) CAMetalLayer *metalLayer;
@property (nonatomic, nullable) id<MetalViewDelegate> delegate;
+8 -78
View File
@@ -11,12 +11,10 @@
#import "MetalView.h"
#import "MetalConfig.h"
#import <QuartzCore/CAMetalDisplayLink.h>
@implementation MetalView {
// The secondary thread containing the render loop.
NSThread *_renderThread;
CAMetalDisplayLink *_metalDisplayLink API_AVAILABLE(ios(17.0));
}
#pragma mark - Initialization and Setup.
@@ -42,43 +40,15 @@
self.layer.delegate = self;
}
- (void)setFramerate:(float)framerate {
_framerate = framerate;
if (@available(iOS 17.0, *)) {
if (_metalDisplayLink) {
_metalDisplayLink.preferredFrameRateRange = CAFrameRateRangeMake(framerate, framerate, framerate);
}
}
}
- (void)shutdown {
// First cancel the thread to stop the run loop
if (_renderThread) {
Log(LOG_I, @"[MetalView] sending renderThread a cancel message");
[_renderThread cancel];
// Invalidate metal display link to stop callbacks
if (@available(iOS 17.0, *)) {
if (_metalDisplayLink) {
Log(LOG_I, @"[MetalView] invalidating metal display link");
[_metalDisplayLink invalidate];
_metalDisplayLink = nil;
}
}
// Now wait for thread to finish
Log(LOG_I, @"[MetalView] waiting on renderThread to finish");
NSDate *timeout = [NSDate dateWithTimeIntervalSinceNow:2.0]; // 2 second timeout
while (!_renderThread.isFinished && [timeout timeIntervalSinceNow] > 0) {
usleep(1000);
}
if (_renderThread.isFinished) {
Log(LOG_I, @"[MetalView] renderThread has finished");
} else {
Log(LOG_E, @"[MetalView] renderThread failed to finish in time");
while (!_renderThread.isFinished) {
usleep(100);
}
Log(LOG_I, @"[MetalView] renderThread has finished");
_renderThread = nil;
}
}
@@ -91,26 +61,6 @@
[self movedToWindow];
}
- (void)metalDisplayLink:(CAMetalDisplayLink *)link needsUpdate:(CAMetalDisplayLinkUpdate *)update API_AVAILABLE(ios(17.0)) {
// Skip rendering if we're shutting down
if ([NSThread currentThread].isCancelled) {
return;
}
@autoreleasepool {
[self.delegate waitToRenderTo:self.metalLayer];
id<CAMetalDrawable> drawable = update.drawable;
if (drawable) {
// Pass timing information to the renderer
if ([self.delegate respondsToSelector:@selector(renderWithDrawable:toLayer:targetPresentationTimestamp:)]) {
[self.delegate renderWithDrawable:drawable toLayer:self.metalLayer targetPresentationTimestamp:update.targetPresentationTimestamp];
} else {
[self.delegate renderWithDrawable:drawable toLayer:self.metalLayer];
}
}
}
}
- (void)movedToWindow {
if (!self.window) {
Log(LOG_I, @"[MetalView] movedToWindow(nil): shutting down...");
@@ -118,32 +68,12 @@
return;
}
if (@available(iOS 17.0, *)) {
_metalDisplayLink = [[CAMetalDisplayLink alloc] initWithMetalLayer:_metalLayer];
_metalDisplayLink.delegate = self;
_metalDisplayLink.preferredFrameRateRange = CAFrameRateRangeMake(_framerate, _framerate, _framerate);
Log(LOG_I, @"[MetalView] Using CAMetalDisplayLink for optimal Metal rendering");
}
// Start the display link on a background thread
// Render on a new thread
_renderThread = [[NSThread alloc] initWithBlock:^{
// Add metal display link to this thread's run loop
if (@available(iOS 17.0, *)) {
[self->_metalDisplayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
}
// Keep the run loop alive, checking for cancellation regularly
while (![NSThread currentThread].isCancelled && self->_metalDisplayLink) {
while (![NSThread currentThread].isCancelled) {
@autoreleasepool {
// Run the run loop for a short time to allow checking cancellation
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
}
}
// Remove from run loop if still valid
if (@available(iOS 17.0, *)) {
if (self->_metalDisplayLink) {
[self->_metalDisplayLink removeFromRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
[self.delegate waitToRenderTo:self.metalLayer];
[self.delegate renderTo:self.metalLayer];
}
}
Log(LOG_I, @"[MetalView] renderThread is exiting");
@@ -151,7 +81,7 @@
_renderThread.name = @"MetalVideoRenderer";
_renderThread.qualityOfService = NSQualityOfServiceUserInteractive;
[_renderThread start];
Log(LOG_I, @"[MetalView] started renderThread with CAMetalDisplayLink at %f fps", _framerate);
Log(LOG_I, @"[MetalView] started renderThread %@", _renderThread);
// Perform any actions that need to know the size and scale of the drawable. When UIKit calls
// didMoveToWindow after the view initialization, this is the first opportunity to notify
+3 -4
View File
@@ -12,15 +12,14 @@
#import "ImGuiRenderer.h"
#import "MetalVideoRenderer.h"
#import "MetalView.h"
#import "TemporarySettings.h"
@interface MetalViewController : UIViewController <MetalViewDelegate>
@property (nonatomic) CGRect bounds;
- (nonnull instancetype)initWithFrame:(CGRect)bounds
framerate:(float)framerate
enableHdr:(BOOL)enableHdr
metricsHandler:(MetricsHandler _Nonnull)metricsHandler;
- (nonnull instancetype)initWithFrame:(CGRect)bounds framerate:(float)framerate settings:(TemporarySettings* _Nonnull )settings metricsHandler:(MetricsHandler _Nonnull)metricsHandler;
- (void)pauseRendering;
- (void)resumeRendering;
+68 -26
View File
@@ -14,19 +14,21 @@
@implementation MetalViewController {
FrameQueue *_frameQueue;
float _framerate;
BOOL _enableHdr;
TemporarySettings* _currentSettings;
MetalView *_metalView;
MetalVideoRenderer *_renderer;
MetricsHandler _metricsHandler;
CADisplayLink *_displayLink;
BOOL _frameSlotAcquired;
}
- (nonnull instancetype)initWithFrame:(CGRect)bounds framerate:(float)framerate enableHdr:(BOOL)enableHdr metricsHandler:(MetricsHandler)metricsHandler {
- (nonnull instancetype)initWithFrame:(CGRect)bounds framerate:(float)framerate settings:(TemporarySettings* )settings metricsHandler:(MetricsHandler)metricsHandler {
self = [super init];
if (self) {
_bounds = bounds;
_frameQueue = [FrameQueue sharedInstance];
_framerate = framerate;
_enableHdr = enableHdr;
_currentSettings = settings;
_metricsHandler = metricsHandler;
}
return self;
@@ -58,11 +60,28 @@
}
view.metalLayer.device = device;
// Determine supported pixel format before initializing renderer
// Use TARGET_OS_SIMULATOR to detect simulator environment
MTLPixelFormat pixelFormat;
#if TARGET_OS_SIMULATOR
// iOS Simulator doesn't support BGRA10_XR
pixelFormat = MTLPixelFormatBGRA8Unorm;
Log(LOG_W, @"Running on iOS Simulator, using BGRA8Unorm pixel format");
#else
// On real devices, check if we should enable HDR
if (_currentSettings.enableHdr) {
pixelFormat = MTLPixelFormatBGRA10_XR;
Log(LOG_I, @"HDR enabled, using BGRA10_XR pixel format");
} else {
pixelFormat = MTLPixelFormatBGRA8Unorm;
Log(LOG_I, @"HDR disabled, using BGRA8Unorm pixel format");
}
#endif
// Initialize the renderer.
MetalVideoRenderer *renderer = [[MetalVideoRenderer alloc] initWithMetalDevice:device
drawablePixelFormat:MTLPixelFormatBGR10A2Unorm
framerate:self->_framerate
hdrEnabled:self->_enableHdr];
drawablePixelFormat:pixelFormat
settings:_currentSettings];
if (!renderer) {
Log(LOG_E, @"The renderer couldn't be initialized.");
return;
@@ -73,6 +92,20 @@
// Initialize the renderer-dependent view properties.
view.metalLayer.pixelFormat = renderer.colorPixelFormat;
view.metalLayer.maximumDrawableCount = 3;
// We need a no-op displaylink timer or iOS can decide to run at 60fps
// The overhead from this should be minimal.
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkHandler:)];
if (@available(iOS 15.0, tvOS 15.0, *)) {
_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(_framerate, _framerate, _framerate);
} else {
_displayLink.preferredFramesPerSecond = _framerate;
}
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)displayLinkHandler:(CADisplayLink *)link {
// Rendering does not use DisplayLink, this exists to fool iOS into keeping us running at the desired framerate
}
- (void)waitToRenderTo:(nonnull CAMetalLayer *)layer {
@@ -81,36 +114,36 @@
return;
}
// Renderer obtains a nextDrawable, waiting if necessary
if (@available(iOS 13.0, *)) {
[_renderer waitToRenderTo:layer];
_frameSlotAcquired = [_renderer waitToRenderTo:layer];
}
// If we don't have a frame yet, wait on that too
[_frameQueue waitForEnqueue];
if (_frameSlotAcquired) {
[_frameQueue waitForEnqueue];
}
}
- (void)renderWithDrawable:(nonnull id<CAMetalDrawable>)drawable toLayer:(nonnull CAMetalLayer *)layer API_AVAILABLE(ios(17.0)) {
/// Draw frame (used by manual loop)
- (void)renderTo:(nonnull CAMetalLayer *)layer {
if (!_frameSlotAcquired) {
return;
}
CFTimeInterval timeout = (1.0f / _framerate) - _renderer.averageGPUTime;
Frame *frame = [_frameQueue dequeueWithTimeout:timeout];
Frame *frame = [_frameQueue dequeueWithTimeoutSync:timeout];
if (!_renderer.isStopping) {
// Only render if not paused
if (frame) {
[_renderer renderFrame:frame withDrawable:drawable];
}
}
}
- (void)renderWithDrawable:(nonnull id<CAMetalDrawable>)drawable toLayer:(nonnull CAMetalLayer *)layer targetPresentationTimestamp:(CFTimeInterval)targetPresentationTimestamp API_AVAILABLE(ios(17.0)) {
if (!_renderer.isStopping) {
CFTimeInterval timeout = (1.0f / _framerate) - _renderer.averageGPUTime;
Frame *frame = [_frameQueue dequeueWithTimeout:timeout];
if (frame) {
[_renderer renderFrame:frame withDrawable:drawable targetPresentationTimestamp:targetPresentationTimestamp];
//if (@available(iOS 13.0, *)) {
[_renderer renderFrame:frame toLayer:layer];
//}
} else {
dispatch_semaphore_signal([_renderer inFlightSemaphore]);
}
} else {
// When paused, we still dequeue frames to prevent accumulation
// but don't render them. Also sleep a bit to reduce CPU usage
dispatch_semaphore_signal([_renderer inFlightSemaphore]);
usleep(100000);
}
}
@@ -120,6 +153,9 @@
}
- (void)pauseRendering {
if (_displayLink) {
_displayLink.paused = YES;
}
if (_renderer) {
_renderer.isStopping = YES;
}
@@ -130,6 +166,9 @@
if (_renderer) {
_renderer.isStopping = NO;
}
if (_displayLink) {
_displayLink.paused = NO;
}
Log(LOG_I, @"[MetalViewController] Rendering resumed");
}
@@ -138,13 +177,16 @@
Log(LOG_I, @"[MetalViewController] viewDidDisappear");
// Shutdown the renderer first
if (_displayLink) {
[_displayLink invalidate];
_displayLink = nil;
}
if (_renderer) {
[_renderer shutdown];
_renderer = nil;
}
// Then shutdown the view's display link
if (_metalView) {
_metalView.delegate = nil;
[_metalView shutdown];
+8
View File
@@ -127,3 +127,11 @@ fragment float4 ps_draw_triplanar_10bit(Vertex v [[ stage_in ]],
rgb.b = dot(yuv_corrected, cscParams.matrix[2]);
return float4(rgb, 1.0f);
}
// Shader for packed BGRA format (no color space conversion needed)
fragment float4 ps_draw_bgra(Vertex v [[ stage_in ]],
texture2d<float> bgraTexture [[ texture(0) ]])
{
// BGRA format is already in RGB color space, just sample and return
return bgraTexture.sample(s, v.texCoords);
}
+6 -1
View File
@@ -30,6 +30,7 @@
NSString* _uniqueId;
NSData* _cert;
BOOL shouldDiscover;
DataManager* dataMan;
}
- (id)initWithHosts:(NSArray *)hosts andCallback:(id<DiscoveryCallback>)callback {
@@ -52,6 +53,7 @@
[CryptoManager generateKeyPairUsingSSL];
_uniqueId = [IdManager getUniqueId];
_cert = [CryptoManager readCertFromFile];
DataManager* dataMan = [[DataManager alloc] init];
return self;
}
@@ -139,6 +141,8 @@
if ([serverInfoResponse isStatusOk]) {
host = [[TemporaryHost alloc] init];
host.activeAddress = host.address = hostAddress;
if(!host.activeAddressPool) host.activeAddressPool = [[NSMutableSet alloc] init];
[host.activeAddressPool addObject:host.activeAddress];
host.state = StateOnline;
[serverInfoResponse populateHost:host];
@@ -284,8 +288,9 @@
existingHost.externalAddress = host.externalAddress;
}
existingHost.activeAddress = host.activeAddress;
[existingHost.activeAddressPool addObject:host.activeAddress];
existingHost.state = host.state;
return NO;
return NO; // returning a "NO" here means Updating addresses of existing host
}
else {
@synchronized (_hostQueue) {
+15 -1
View File
@@ -41,11 +41,22 @@ static const float POLL_RATE = 2.0f; // Poll every 2 seconds
}
- (NSArray*) getHostAddressList {
NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:3];
NSMutableArray *array = [[NSMutableArray alloc] init];
if (_host.localAddress != nil) {
[array addObject:_host.localAddress];
}
NSArray *poolSnapshot = [_host.activeAddressPool copy];
for (NSString *address in poolSnapshot) {
if ([address containsString:@":"]) break;
[array addObject:address];
}
for (NSString *address in poolSnapshot) {
if (![address containsString:@":"]) break;
[array addObject:address];
}
if (_host.address != nil) {
[array addObject:_host.address];
}
@@ -61,6 +72,7 @@ static const float POLL_RATE = 2.0f; // Poll every 2 seconds
// to preserve insertion order of addresses.
for (int i = 0; i < [array count]; i++) {
NSString *addr1 = [array objectAtIndex:i];
// NSLog(@"iterates discovery addrs %@, host: %@", addr1, _host.name);
for (int j = 1; j < [array count]; j++) {
if (i == j) {
@@ -104,6 +116,8 @@ static const float POLL_RATE = 2.0f; // Poll every 2 seconds
receivedResponse = [self checkResponse:serverInfoResp];
if (receivedResponse) {
_host.activeAddress = address;
if(!_host.activeAddressPool) _host.activeAddressPool = [[NSMutableSet alloc] init];
[_host.activeAddressPool addObject:address];
[serverInfoResp populateHost:_host];
// Update the database using the response
+1
View File
@@ -29,6 +29,7 @@
- (NSURLRequest*) newLaunchOrResumeRequest:(NSString*)verb config:(StreamConfiguration*)config;
- (NSURLRequest*) newQuitAppRequest;
- (NSURLRequest*) newAppAssetRequestWithAppId:(NSString*)appId;
- (NSURLRequest*) newBirateRequest:(NSInteger)bitrateKbps forClient:(NSString* )clientName;
- (void) executeRequestSynchronously:(HttpRequest*)request;
@end
+14
View File
@@ -231,6 +231,7 @@
}
NSString* urlString = [NSString stringWithFormat:@"%@/applist?uniqueid=%@", _baseHTTPSURL, _uniqueId];
NSLog(@"urlString: %@", urlString);
return [self createRequestFromString:urlString timeout:NORMAL_TIMEOUT_SEC];
}
@@ -292,9 +293,22 @@
}
NSString* urlString = [NSString stringWithFormat:@"%@/cancel?uniqueid=%@", _baseHTTPSURL, _uniqueId];
NSLog(@"urlString print: %@", urlString);
return [self createRequestFromString:urlString timeout:LONG_TIMEOUT_SEC];
}
- (NSURLRequest*)newBirateRequest:(NSInteger)bitrateKbps forClient:(NSString* )clientName {
if (![self ensureHttpsUrlPopulated:NO]) {
return nil;
}
NSString* urlString = [NSString stringWithFormat:@"%@/bitrate?clientname=%@&bitrate=%@&uniqueid=%@", _baseHTTPSURL, clientName, [NSString stringWithFormat:@"%ld", (long)bitrateKbps], _uniqueId];
NSLog(@"bitrate urlString print: %@", urlString);
return [self createRequestFromString:urlString timeout:LONG_TIMEOUT_SEC];
}
- (NSURLRequest*) newAppAssetRequestWithAppId:(NSString *)appId {
if (![self ensureHttpsUrlPopulated:NO]) {
return nil;
+1
View File
@@ -228,6 +228,7 @@ static NSString* NV_SERVICE_TYPE = @"_nvstream._tcp";
Log(LOG_I, @"IPv6 address chosen: %@ -> %@", [service hostName], host.ipv6Address);
host.activeAddress = host.localAddress;
// [host.activeAddressPool addObject:host.localAddress];
host.name = service.hostName;
[self.callback updateHost:host];
});
+2
View File
@@ -46,6 +46,7 @@
// If we reached this host through this port, store our port there
if (host.activeAddress && [lanAddr isEqualToString:[Utils addressPortStringToAddress:host.activeAddress]]) {
localPort = [Utils addressPortStringToPort:host.activeAddress];
[host.activeAddressPool addObject:host.activeAddress];
}
else if (host.localAddress) {
// If there's an existing local address, use the port from that
@@ -57,6 +58,7 @@
}
host.localAddress = [Utils addressAndPortToAddressPortString:lanAddr port:localPort];
[host.activeAddressPool addObject:lanAddr];
}
// This is a Sunshine extension for WAN port remapping
BIN
View File
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" fill="none" viewBox="0 0 120 120">
<g clip-path="url(#a)">
<g filter="url(#b)">
<path fill="#fff" fill-opacity="1"
d="M67.25 10.437c.099.075.215.123.338.142a50 50 0 0 1 27.233 13.54.74.74 0 0 1 .004 1.056l-.776.775a.76.76 0 0 1-1.065.004 47.405 47.405 0 0 0-65.97 0 .76.76 0 0 1-1.064-.004l-.776-.775a.74.74 0 0 1 .004-1.057A50 50 0 0 1 52.41 10.58a.76.76 0 0 0 .339-.142l6.8-5.1a.75.75 0 0 1 .9 0z"/>
</g>
<g filter="url(#c)">
<path fill="#fff" fill-opacity="1"
d="M61.955 6.804c2.487 2.295 5.657 3.663 8.96 4.402a50 50 0 0 1 23.51 12.533c.52.493.52 1.316.013 1.823a1.326 1.326 0 0 1-1.849.013 47.403 47.403 0 0 0-65.179 0 1.326 1.326 0 0 1-1.848-.013 1.27 1.27 0 0 1 .012-1.823 50 50 0 0 1 23.512-12.533c3.302-.739 6.47-2.106 8.958-4.401L60 5z"
/>
</g>
</g>
<defs>
<!-- defs stay the same -->
<filter id="b" width="82.089" height="32.983" x="18.955" y="-.813" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur result="effect1_foregroundBlur_211_1847" stdDeviation="3"/>
</filter>
<filter id="c" width="69.634" height="22.444" x="25.183" y="5" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_211_1847"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow_211_1847" result="shape"/>
</filter>
<clipPath id="a">
<path fill="#fff" d="M0 0h120v120H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg width="860" height="860" viewBox="0 0 860 860" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_30)">
<path opacity="0.15" d="M430 45.3164C642.136 45.3164 814.471 217.345 814.471 430C814.471 642.656 642.136 814.684 430 814.684C217.864 814.684 45.5294 642.656 45.5294 430C45.5294 217.345 217.864 45.3164 430 45.3164Z" stroke="white" stroke-width="90"/>
<path opacity="0.15" d="M430 122.855L508.859 213.488H351.141L430 122.855Z" fill="#D9D9D9"/>
<path opacity="0.15" d="M430 737.144L351.141 646.512H508.859L430 737.144Z" fill="#D9D9D9"/>
<path opacity="0.15" d="M738.588 430.001L647.529 508.491V351.512L738.588 430.001Z" fill="#D9D9D9"/>
<path opacity="0.15" d="M121.412 430.001L212.471 351.512V508.491L121.412 430.001Z" fill="#D9D9D9"/>
</g>
<defs>
<clipPath id="clip0_3_30">
<rect width="860" height="860" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 879 B

+14
View File
@@ -0,0 +1,14 @@
<svg width="860" height="860" viewBox="0 0 860 860" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_30)">
<path opacity="0.75" d="M430 45.3164C642.136 45.3164 814.471 217.345 814.471 430C814.471 642.656 642.136 814.684 430 814.684C217.864 814.684 45.5294 642.656 45.5294 430C45.5294 217.345 217.864 45.3164 430 45.3164Z" stroke="white" stroke-width="90"/>
<path opacity="0.75" d="M430 122.855L508.859 213.488H351.141L430 122.855Z" fill="#D9D9D9"/>
<path opacity="0.75" d="M430 737.144L351.141 646.512H508.859L430 737.144Z" fill="#D9D9D9"/>
<path opacity="0.75" d="M738.588 430.001L647.529 508.491V351.512L738.588 430.001Z" fill="#D9D9D9"/>
<path opacity="0.75" d="M121.412 430.001L212.471 351.512V508.491L121.412 430.001Z" fill="#D9D9D9"/>
</g>
<defs>
<clipPath id="clip0_3_30">
<rect width="860" height="860" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 879 B

+14
View File
@@ -0,0 +1,14 @@
<svg width="860" height="860" viewBox="0 0 860 860" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_30)">
<path opacity="0.25" d="M430 45.3164C642.136 45.3164 814.471 217.345 814.471 430C814.471 642.656 642.136 814.684 430 814.684C217.864 814.684 45.5294 642.656 45.5294 430C45.5294 217.345 217.864 45.3164 430 45.3164Z" stroke="white" stroke-width="90"/>
<path opacity="0.25" d="M430 122.855L508.859 213.488H351.141L430 122.855Z" fill="#D9D9D9"/>
<path opacity="0.25" d="M430 737.144L351.141 646.512H508.859L430 737.144Z" fill="#D9D9D9"/>
<path opacity="0.25" d="M738.588 430.001L647.529 508.491V351.512L738.588 430.001Z" fill="#D9D9D9"/>
<path opacity="0.25" d="M121.412 430.001L212.471 351.512V508.491L121.412 430.001Z" fill="#D9D9D9"/>
</g>
<defs>
<clipPath id="clip0_3_30">
<rect width="860" height="860" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 879 B

+34
View File
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" fill="none" viewBox="0 0 120 120">
<g clip-path="url(#a)">
<g filter="url(#b)">
<path fill="#fff" fill-opacity="1"
d="M67.25 10.437c.099.075.215.123.338.142a50 50 0 0 1 27.233 13.54.74.74 0 0 1 .004 1.056l-.776.775a.76.76 0 0 1-1.065.004 47.405 47.405 0 0 0-65.97 0 .76.76 0 0 1-1.064-.004l-.776-.775a.74.74 0 0 1 .004-1.057A50 50 0 0 1 52.41 10.58a.76.76 0 0 0 .339-.142l6.8-5.1a.75.75 0 0 1 .9 0z"/>
</g>
<g filter="url(#c)">
<path fill="#fff" fill-opacity="1"
d="M61.955 6.804c2.487 2.295 5.657 3.663 8.96 4.402a50 50 0 0 1 23.51 12.533c.52.493.52 1.316.013 1.823a1.326 1.326 0 0 1-1.849.013 47.403 47.403 0 0 0-65.179 0 1.326 1.326 0 0 1-1.848-.013 1.27 1.27 0 0 1 .012-1.823 50 50 0 0 1 23.512-12.533c3.302-.739 6.47-2.106 8.958-4.401L60 5z"
/>
</g>
</g>
<defs>
<!-- defs stay the same -->
<filter id="b" width="82.089" height="32.983" x="18.955" y="-.813" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur result="effect1_foregroundBlur_211_1847" stdDeviation="3"/>
</filter>
<filter id="c" width="69.634" height="22.444" x="25.183" y="5" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow_211_1847"/>
<feBlend in="SourceGraphic" in2="effect1_dropShadow_211_1847" result="shape"/>
</filter>
<clipPath id="a">
<path fill="#fff" d="M0 0h120v120H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+8
View File
@@ -5,6 +5,10 @@
// Created by Diego Waxemberg on 1/19/14.
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
// Modified by True砖家 since 2025.9
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
#import "ConnectionCallbacks.h"
#import "VideoDecoderRenderer.h"
@@ -15,6 +19,8 @@
#define CONN_TEST_SERVER "www.baidu.com"
@interface Connection : NSOperation <NSStreamDelegate>
@property (class, nonatomic, assign) bool muteInBackground;
@property (class, nonatomic, assign) bool useSystemAudioEngine;
-(id) initWithConfig:(StreamConfiguration*)config renderer:(VideoDecoderRenderer*)myRenderer connectionCallbacks:(id<ConnectionCallbacks>)callbacks;
-(void) terminate;
@@ -23,4 +29,6 @@
-(BOOL) getVideoStats:(video_stats_t*)stats;
-(NSString*) getActiveCodecName;
+ (void)setVolume:(float)newVolume;
@end
+180 -16
View File
@@ -5,6 +5,9 @@
// Created by Diego Waxemberg on 1/19/14.
// Copyright (c) 2015 Moonlight Stream. All rights reserved.
//
// Modified by True砖家 since 2025.9
// Copyright © 2025 True砖家 on Bilibili. All rights reserved.
//
#import "Connection.h"
#import "Plot.h"
@@ -18,6 +21,7 @@
#include "Limelight.h"
#include "opus_multistream.h"
#include "VoidLink-Swift.h"
@implementation Connection {
SERVER_INFORMATION _serverInfo;
@@ -43,15 +47,26 @@ static NSLock* videoStatsLock;
static SDL_AudioDeviceID audioDevice;
static OPUS_MULTISTREAM_CONFIGURATION audioConfig;
static void* audioBuffer;
static float volume = 1.0;
static int audioFrameSize;
static bool useSystemAudioEngine;
static bool audioSessionInterrupted;
static AVAudioEngine *audioEngine;
static AVAudioPlayerNode *audioPlayerNode;
static AVAudioPCMBuffer *pcmBuffer;
static AVAudioFormat *audioFormat;
static bool muteInBackground;
static bool fullColorRange;
static VideoDecoderRenderer* renderer;
static BandwidthTracker *bwTracker;
int DrDecoderSetup(int videoFormat, int width, int height, int redrawRate, void* context, int drFlags)
{
[renderer setupWithVideoFormat:videoFormat width:width height:height frameRate:redrawRate];
[renderer setupWithVideoFormat:videoFormat width:width height:height frameRate:redrawRate fullRange:fullColorRange];
lastFrameNumber = 0;
activeVideoFormat = videoFormat;
Log(LOG_I, @"Active video format: 0x%x", activeVideoFormat);
@@ -273,11 +288,27 @@ int ArInit(int audioConfiguration, POPUS_MULTISTREAM_CONFIGURATION opusConfig, v
// Start playback
SDL_PauseAudioDevice(audioDevice, 0);
// Disable lowering volume of other audio streams (SDL sets AVAudioSessionCategoryOptionDuckOthers by default)
// [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:nil];
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:nil];
// return 0;
// System audio engine initialization
DataManager* dataMan = [[DataManager alloc] init];
TemporarySettings* tempSettings = [dataMan getSettings];
bool useBluetoothD2P = tempSettings.useBuiltinMic || !tempSettings.redirectMic;
AVAudioSessionCategoryOptions bluetoothAudioOption = useBluetoothD2P ? AVAudioSessionCategoryOptionAllowBluetoothA2DP : AVAudioSessionCategoryOptionAllowBluetooth;
AVAudioSessionCategoryOptions volumeMixOption = tempSettings.duckOtherApps ? AVAudioSessionCategoryOptionDuckOthers : AVAudioSessionCategoryOptionMixWithOthers;
AVAudioSession *session = [AVAudioSession sharedInstance];
[session setCategory:tempSettings.redirectMic ? AVAudioSessionCategoryPlayAndRecord : AVAudioSessionCategoryPlayback
mode:AVAudioSessionModeDefault
options:volumeMixOption|bluetoothAudioOption
error:nil];
if(tempSettings.redirectMic) if(@available(iOS 13.0, *)) [session setAllowHapticsAndSystemSoundsDuringRecording:YES error:nil];
[session setActive:YES error:nil];
audioSessionInterrupted = false;
AudioEngineInit(audioConfig.sampleRate, audioConfig.channelCount);
return 0;
}
@@ -301,8 +332,78 @@ void ArCleanup(void)
SDL_QuitSubSystem(SDL_INIT_AUDIO);
}
+ (void)setVolume:(float)linearVolume{
if (linearVolume <= 0.0f) linearVolume = 0.0f;
if (linearVolume >= 1.0f) linearVolume = 1.0f;
CGFloat exponent = 2.5;
volume = powf(linearVolume, exponent);
}
+ (void)setMuteInBackground:(bool)mute {
muteInBackground = mute;
}
+ (void)setUseSystemAudioEngine:(bool)useSysAudioEngine{
useSystemAudioEngine = useSysAudioEngine;
}
void AudioEngineInit(int sampleRate, int channelCount) {
audioEngine = [[AVAudioEngine alloc] init];
audioPlayerNode = [[AVAudioPlayerNode alloc] init];
[audioEngine attachNode:audioPlayerNode];
AVAudioChannelLayout *layout;
switch (channelCount) {
case 2:
default:
audioFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
sampleRate:sampleRate
channels:channelCount
interleaved:NO];
break;
case 6:
layout =
[[AVAudioChannelLayout alloc] initWithLayoutTag:kAudioChannelLayoutTag_MPEG_5_1_A];
audioFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
sampleRate:sampleRate
interleaved:NO
channelLayout:layout];
break;
case 8:
layout =
[[AVAudioChannelLayout alloc] initWithLayoutTag:kAudioChannelLayoutTag_MPEG_7_1_A];
audioFormat = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
sampleRate:sampleRate
interleaved:NO
channelLayout:layout];
break;
}
if(!audioFormat) return;
AVAudioFormat *mixerFormat = [audioEngine.mainMixerNode outputFormatForBus:0];
AVAudioFormat *outputFormat = [audioEngine.outputNode inputFormatForBus:0];
NSLog(@"Mixer: %dch, Output: %dch",
(int)mixerFormat.channelCount,
(int)outputFormat.channelCount);
[audioEngine connect:audioPlayerNode to:audioEngine.mainMixerNode format:audioFormat];
NSError *err = nil;
if (![audioEngine startAndReturnError:&err]) {
NSLog(@"AudioEngine start error: %@", err);
}
[audioPlayerNode play];
}
void ArDecodeAndPlaySample(char* sampleData, int sampleLength)
{
if(appDidEnterBackgroundWithoutPip && muteInBackground) return;
int decodeLen;
// Don't queue if there's already more than 30 ms of audio data waiting
@@ -317,18 +418,49 @@ void ArDecodeAndPlaySample(char* sampleData, int sampleLength)
(float*)audioBuffer,
audioConfig.samplesPerFrame,
0);
if (decodeLen > 0) {
// Provide backpressure on the queue to ensure too many frames don't build up
// in SDL's audio queue.
while (SDL_GetQueuedAudioSize(audioDevice) / audioFrameSize > 10) {
[NSThread sleepForTimeInterval:0.001f];
float* fbuf = (float*)audioBuffer;
if(useSystemAudioEngine){
// 创建 AVAudioPCMBuffer
AVAudioFrameCount frameCount = decodeLen;
AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc] initWithPCMFormat:audioFormat frameCapacity:frameCount];
buffer.frameLength = frameCount;
// 拷贝数据到 buffer
for (int ch = 0; ch < audioConfig.channelCount; ch++) {
float *dst = buffer.floatChannelData[ch];
for (int i = 0; i < decodeLen; i++) {
dst[i] = fbuf[i * audioConfig.channelCount + ch] * volume; // 非交错数据
}
}
// 播放
if(!audioSessionInterrupted) [audioPlayerNode scheduleBuffer:buffer completionHandler:nil];
}
if (SDL_QueueAudio(audioDevice,
audioBuffer,
sizeof(float) * decodeLen * audioConfig.channelCount) < 0) {
Log(LOG_E, @"Failed to queue audio sample: %s\n", SDL_GetError());
else{
if(volume != 1.0){
int totalSamples = decodeLen * audioConfig.channelCount;
for (int i = 0; i < totalSamples; i++) {
fbuf[i] *= volume;
}
}
while (SDL_GetQueuedAudioSize(audioDevice) / audioFrameSize > 10) {
[NSThread sleepForTimeInterval:0.001f];
}
if (SDL_QueueAudio(audioDevice,
audioBuffer,
sizeof(float) * decodeLen * audioConfig.channelCount) < 0) {
Log(LOG_E, @"Failed to queue audio sample: %s\n", SDL_GetError());
}
}
}
}
@@ -403,7 +535,10 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t
// won't be able to acquire it if LiStartConnection is in
// progress.
LiInterruptConnection();
[audioPlayerNode stop];
[audioEngine stop];
[[NSNotificationCenter defaultCenter] removeObserver:self];
// We dispatch this async to get out because this can be invoked
// on a thread inside common and we don't want to deadlock. It also avoids
// blocking on the caller's thread waiting to acquire initLock.
@@ -414,6 +549,27 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t
});
}
- (void)handleAudioSessionInterruption:(NSNotification *)notification {
NSDictionary *info = notification.userInfo;
AVAudioSessionInterruptionType type =
[info[AVAudioSessionInterruptionTypeKey] unsignedIntegerValue];
switch (type) {
case AVAudioSessionInterruptionTypeBegan:
audioSessionInterrupted = true;
[audioPlayerNode stop];
[audioEngine stop];
break;
case AVAudioSessionInterruptionTypeEnded:
AudioEngineInit(audioConfig.sampleRate, audioConfig.channelCount);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 0.5*NSEC_PER_SEC), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
audioSessionInterrupted = false;
});
default:
break;
}
}
-(id) initWithConfig:(StreamConfiguration*)config renderer:(VideoDecoderRenderer*)myRenderer connectionCallbacks:(id<ConnectionCallbacks>)callbacks
{
self = [super init];
@@ -461,14 +617,16 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t
_callbacks = callbacks;
LiInitializeStreamConfiguration(&_streamConfig);
_streamConfig.colorRange = 1; // Full range
_streamConfig.colorRange = config.fullColorRange ? 1 : 0;
fullColorRange = config.fullColorRange;
_streamConfig.width = config.width;
_streamConfig.height = config.height;
_streamConfig.fps = config.frameRate;
_streamConfig.bitrate = config.bitRate;
_streamConfig.supportedVideoFormats = config.supportedVideoFormats;
_streamConfig.audioConfiguration = config.audioConfiguration;
_streamConfig.redirectMic = config.redirectMic && [MicHandler permissionGranted];
[Connection setVolume:config.localVolume];
// Since we require iOS 12 or above, we're guaranteed to be running
// on a 64-bit device with ARMv8 crypto instructions, so we don't
// need to check for that here.
@@ -493,9 +651,10 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t
LiInitializeVideoCallbacks(&_drCallbacks);
_drCallbacks.setup = DrDecoderSetup;
_drCallbacks.cleanup = DrCleanup;
// Use pull renderer for legacy frame pacing, direct submit for modern frame pacing
// Use pull renderer for legacy and off frame pacing, direct submit for queue-based frame pacing
DataManager* dataMan = [[DataManager alloc] init];
if ([[dataMan getSettings].framePacingMode integerValue] == FramePacingModeLegacy) {
FramePacingMode framePacingMode = [[dataMan getSettings].framePacingMode integerValue];
if (framePacingMode == FramePacingModeLegacy || framePacingMode == FramePacingModeOff) {
_drCallbacks.capabilities = CAPABILITY_PULL_RENDERER |
CAPABILITY_REFERENCE_FRAME_INVALIDATION_HEVC |
CAPABILITY_REFERENCE_FRAME_INVALIDATION_AV1;
@@ -528,7 +687,12 @@ void ClSetControllerLED(uint16_t controllerNumber, uint8_t r, uint8_t g, uint8_t
_clCallbacks.rumbleTriggers = ClRumbleTriggers;
_clCallbacks.setMotionEventState = ClSetMotionEventState;
_clCallbacks.setControllerLED = ClSetControllerLED;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleAudioSessionInterruption:)
name:AVAudioSessionInterruptionNotification
object:nil];
return self;
}
+4
View File
@@ -21,6 +21,7 @@
@property int serverCodecModeSupport;
@property BOOL enableYUV444;
@property BOOL enablePIP;
@property BOOL fullColorRange;
@property int width;
@property int height;
@property int frameRate;
@@ -31,7 +32,9 @@
@property int gamepadMask;
@property BOOL optimizeGameSettings;
@property BOOL playAudioOnPC;
@property BOOL redirectMic;
@property BOOL swapABXYButtons;
@property BOOL buttonVisualFeedback;
@property BOOL asyncNativeTouchPriority;
@property int gyroMode;
@property int emulatedControllerType;
@@ -40,5 +43,6 @@
@property BOOL multiController;
@property NSData* serverCert;
@property int localMousePointerMode;
@property CGFloat localVolume;
@end
+1 -1
View File
@@ -9,5 +9,5 @@
#import "StreamConfiguration.h"
@implementation StreamConfiguration
@synthesize host, httpsPort, appID, width, height, frameRate, bitRate, riKeyId, riKey, gamepadMask, appName, optimizeGameSettings, playAudioOnPC, swapABXYButtons, gyroMode, emulatedControllerType, audioConfiguration, supportedVideoFormats, multiController, serverCert, rtspSessionUrl, serverCodecModeSupport, enableYUV444, enablePIP;
@synthesize host, httpsPort, appID, width, height, frameRate, bitRate, riKeyId, riKey, gamepadMask, appName, optimizeGameSettings, playAudioOnPC, swapABXYButtons, buttonVisualFeedback, gyroMode, emulatedControllerType, audioConfiguration, supportedVideoFormats, multiController, serverCert, rtspSessionUrl, serverCodecModeSupport, enableYUV444, enablePIP, fullColorRange, localVolume;
@end
+1
View File
@@ -19,6 +19,7 @@
- (id) initWithConfig:(StreamConfiguration*)config renderView:(UIView*)view connectionCallbacks:(id<ConnectionCallbacks>)callback;
- (void) stopStream;
- (void) setNeedRequeuing:(bool)needRequeuing;
- (NSString*) getStatsOverlayText: (uint16_t) overlayLevel;
+26 -10
View File
@@ -114,9 +114,16 @@
});
}
- (void) setNeedRequeuing:(bool)needRequeuing{
if (_videoRenderer.queueSize==0) return;
[_videoRenderer.frameQueue clear];
_videoRenderer.needRequeuing = needRequeuing;
}
- (void) stopStream
{
[_connection terminate];
_callbacks = nil;
}
- (BOOL) launchApp:(HttpManager*)hMan receiveSessionUrl:(NSString**)sessionUrl {
@@ -169,7 +176,7 @@
uint32_t rtt, variance;
NSString* latencyString;
if (LiGetEstimatedRttInfo(&rtt, &variance)) {
latencyString = [LocalizationHelper localizedStringForKey:@"%u ms (variance: %u ms)", rtt, variance];
latencyString = [LocalizationHelper localizedStringForKey:@"%3u ms (var: %3u ms)", rtt, variance];
}
else {
latencyString = @"N/A";
@@ -189,13 +196,13 @@
float interval = stats.endTime - stats.startTime;
// Check if we're using legacy pacing mode
// Get frame pacing mode
DataManager* dataMan = [[DataManager alloc] init];
BOOL isLegacyPacing = [[dataMan getSettings].framePacingMode integerValue] == FramePacingModeLegacy;
FramePacingMode framePacingMode = [[dataMan getSettings].framePacingMode integerValue];
// Calculate FPS differently based on pacing mode
float fps;
if (isLegacyPacing) {
if (framePacingMode == FramePacingModeLegacy || framePacingMode == FramePacingModeOff) {
fps = stats.totalFrames / interval;
} else {
float scalePlotMetrics = stats.frameDropMetrics.nsamples > 0 ? ((float)stats.frameDropMetrics.nsamples / stats.totalFrames) : 1.0f;
@@ -205,18 +212,25 @@
double avgVideoMbps = [_connection getBwTracker].averageMbps;
double peakVideoMbps = [_connection getBwTracker].peakMbps;
if(overlayLevel == 1) return [LocalizationHelper localizedStringForKey:@"FPS: %5.2f Network dropped frames: %.2f%% Network latency: %@",
NSString* colorRange = _config.fullColorRange ? @"Full" : @"Limited";
if(overlayLevel == 1) return [LocalizationHelper localizedStringForKey:@"simplifiedOsdText",
fps,
stats.networkDroppedFrames / interval,
avgVideoMbps,
latencyString];
else {
if (isLegacyPacing) {
if (framePacingMode == FramePacingModeLegacy || framePacingMode == FramePacingModeOff) {
NSString* rendererWithPacing = stats.renderingBackendString;
if ([stats.renderingBackendString isEqualToString:@"AVSampleBuffer"]) {
rendererWithPacing = @"AVSampleBuffer (Legacy Pacing)";
if (framePacingMode == FramePacingModeOff) {
rendererWithPacing = @"AVSampleBuffer (No Pacing)";
} else {
rendererWithPacing = @"AVSampleBuffer (Legacy Pacing)";
}
}
return [LocalizationHelper localizedStringForKey:@"Video stream: %dx%d %.2f FPS (Codec: %@)\n"
return [LocalizationHelper localizedStringForKey:@"Video stream: %dx%d %.2f FPS (Codec: %@, %@)\n"
"Bitrate: %.1f Mbps, Peak: %.1f\n"
"%@"
"Renderer: %@\n"
@@ -226,6 +240,7 @@
_config.height,
fps,
[_connection getActiveCodecName],
colorRange,
avgVideoMbps, peakVideoMbps,
hostProcessingString,
rendererWithPacing,
@@ -237,7 +252,7 @@
rendererWithPacing = @"AVSampleBuffer (Queue Pacing)";
}
return [LocalizationHelper localizedStringForKey:@"Video stream: %dx%d %.2f FPS (Codec: %@)\n"
return [LocalizationHelper localizedStringForKey:@"Video stream: %dx%d %.2f FPS (Codec: %@, %@)\n"
"Bitrate: %.1f Mbps, Peak: %.1f, Frames buffered: %.1f\n"
"%@"
"Renderer: %@\n"
@@ -248,6 +263,7 @@
_config.height,
fps,
[_connection getActiveCodecName],
colorRange,
avgVideoMbps, peakVideoMbps, stats.frameQueueMetrics.avg,
hostProcessingString,
rendererWithPacing,
+4 -1
View File
@@ -18,12 +18,15 @@
@property (atomic, readonly) PlotMetrics decodeMetrics;
@property (atomic, readonly) PlotMetrics frameQueueMetrics;
@property (atomic, assign) bool needRequeuing;
@property (nonatomic, strong) FrameQueue* frameQueue;
@property (atomic, readonly) int32_t queueSize;
@property (nonatomic, strong, readonly) AVSampleBufferDisplayLayer *displayLayer;
- (id)initWithView:(UIView*)view callbacks:(id<ConnectionCallbacks>)callbacks streamAspectRatio:(float)aspectRatio;
- (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate;
- (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate fullRange:(BOOL)fullRange;
- (void)renderFrame:(Frame *)frame atTime:(CMTime)targetTime;
- (void)cleanup;
- (void)setHdrMode:(BOOL)enabled;
+127 -85
View File
@@ -5,6 +5,10 @@
// Created by Cameron Gutman on 10/18/14.
// Copyright (c) 2014 Moonlight Stream. All rights reserved.
//
// Modified by True砖家 since 2026.2
// Copyright © 2026 True砖家 @ Bilibili. All rights reserved.
//
@import AVFoundation;
@import VideoToolbox;
@@ -36,12 +40,13 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
@implementation VideoDecoderRenderer {
dispatch_queue_t _sq, _vtq;
StreamView* _view;
id<ConnectionCallbacks> _callbacks;
__weak id<ConnectionCallbacks> _callbacks;
float _streamAspectRatio;
AVSampleBufferDisplayLayer* _displayLayer;
int _videoFormat;
int _frameRate;
BOOL _fullRange;
NSMutableArray *_parameterSetBuffers;
NSData *_masteringDisplayColorVolume;
@@ -51,11 +56,15 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
VTDecompressionSessionRef _decompressionSession;
CADisplayLink *_displayLink;
FrameQueue *_frameQueue;
NSInteger _maxRefreshRate;
RenderingBackend _renderingBackend;
BOOL _useLegacyPacing;
FramePacingMode _framePacingMode;
bool _enableTimebase;
bool _asyncFrameDequeue;
// CMTime playTime;
// NSTimeInterval previousLinkTime;
}
- (void)reinitializeDisplayLayer
@@ -114,7 +123,9 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
{
NSLog(@"initializing video decoder %f", CACurrentMediaTime());
self = [super init];
appDidEnterBackgroundWithoutPip = false;
_sq = dispatch_queue_create("com.moonlight.VideoDecoderRenderer",
dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0));
@@ -122,21 +133,28 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
_vtq = dispatch_queue_create("com.moonlight.VideoDecoderRenderer.VTDecoder",
dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INTERACTIVE, 0));
_view = view;
_view = (StreamView*) view;
_callbacks = callbacks;
_streamAspectRatio = aspectRatio;
_maxRefreshRate = [[UIScreen mainScreen] maximumFramesPerSecond];
_parameterSetBuffers = [[NSMutableArray alloc] init];
DataManager* dataMan = [[DataManager alloc] init];
TemporarySettings* tempSettings = [dataMan getSettings];
_useLegacyPacing = [[dataMan getSettings].framePacingMode integerValue] == FramePacingModeLegacy;
_framePacingMode = tempSettings.framePacingMode.integerValue;
_asyncFrameDequeue = tempSettings.asyncFrameDequeue;
NSLog(@"_asyncFrameDequeue %d", _asyncFrameDequeue);
_enableTimebase = false;
_queueSize = tempSettings.frameQueueSize.intValue;
_needRequeuing = _queueSize>0;
_frameQueue = [FrameQueue sharedInstance];
[_frameQueue start];
[_frameQueue setHighWaterMark:(int)[[dataMan getSettings].frameQueueSize integerValue]];
[_frameQueue setHighWaterMark:MAX(1, _queueSize)];
[self reinitializeDisplayLayer];
// NSTimeInterval interval = 1.0/tempSettings.framerate.intValue;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(reinitializeDisplayLayer)
@@ -148,10 +166,11 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
# pragma mark DisplayLink vsync callback
- (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate
- (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate fullRange:(BOOL)fullRange
{
self->_videoFormat = videoFormat;
self->_frameRate = frameRate;
self->_fullRange = fullRange;
// reset plot data in case we've already used it for a previous renderer
[[ImGuiPlots sharedInstance] clearData];
@@ -162,8 +181,8 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
// Choose the appropriate selector based on frame pacing mode
SEL displayLinkSelector;
if (_useLegacyPacing) {
// Legacy frame pacing: use simple displayLinkCallback
if (_framePacingMode == FramePacingModeLegacy || _framePacingMode == FramePacingModeOff) {
// Legacy frame pacing or Off mode: use simple displayLinkCallback
displayLinkSelector = @selector(displayLinkCallback:);
} else {
// PACING_MODE_VSYNC:
@@ -220,10 +239,9 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
#else
NSNumber *pixelFormat = nil;
if (self->_videoFormat & VIDEO_FORMAT_MASK_YUV444) {
pixelFormat = @(kCVPixelFormatType_444YpCbCr10BiPlanarFullRange);
}
else {
pixelFormat = @(kCVPixelFormatType_420YpCbCr10BiPlanarFullRange);
pixelFormat = self->_fullRange ? @(kCVPixelFormatType_444YpCbCr10BiPlanarFullRange) : @(kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange);
} else {
pixelFormat = self->_fullRange ? @(kCVPixelFormatType_420YpCbCr10BiPlanarFullRange) : @(kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange);
}
NSMutableDictionary *destinationPixelBufferAttributes = [@{
(id)kCVPixelBufferPixelFormatTypeKey : pixelFormat
@@ -242,7 +260,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
// Check for issues with the SampleBuffer, this should be much less likely since
// AVSB is not actually decoding the frames anymore
if (self->_displayLayer.status == AVQueuedSampleBufferRenderingStatusFailed) {
Log(LOG_E, @"Display layer rendering failed: %@", _displayLayer.error);
// Log(LOG_E, @"Display layer rendering failed: %@", _displayLayer.error);
// Recreate the display layer. We are already on the main thread,
// so this is safe to do right here.
@@ -258,66 +276,84 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
#pragma mark DisplayLink - Frame Pacing - Vsync with FrameQueue
/*
// This frame pacing method was inspired by the behavior of moonlight-qt's Pacer class, although it has evolved
// a few additional features. Incoming frames from Sunshine are asynchronously processed into a queue by the VideoRecv thread.
// DisplayLink calls us every vsync we we try to present the most recent frame. We try to maintain a user-configurable buffer
// of 1-5 frames. If the buffer is full, every other frame is dropped which just appears to the user as a lower framerate stream.
// of 1-5 frames. If the buffer is full, every other frame is dropped which just appears to the user as a lower framerate stream. */
- (void)renderModeAVSB:(CADisplayLink *)link {
// NSTimeInterval current = link.targetTimestamp;
// NSLog(@"link %f", link.duration);
// previousLinkTime = current;
CFTimeInterval start = link.timestamp;
CFTimeInterval deadline = link.targetTimestamp;
CFTimeInterval targetTime = link.targetTimestamp;
static CFTimeInterval lastTargetLocal = 0.0f;
CFTimeInterval dl0 = CACurrentMediaTime();
/*
static int lateCallbacks = 0;
if (dl0 > deadline) {
if (dl0 > nextFrameTime) {
// we already missed it, count how often this happens
lateCallbacks++;
return;
}
}*/
[self checkDisplayLayer];
static CFTimeInterval avgOverhead = 0.004f; // averaged each callback
CFTimeInterval waitFor = deadline - dl0 - avgOverhead;
CFTimeInterval waitFor = targetTime - dl0;
/*
if (waitFor < 0.001f) {
NSLog(@"waitFor %f", waitFor);
// waitFor = isIPhone ? waitFor : 0.0f;
waitFor = 0.0f;
}
// Get the next frame or wait if necessary. If no frame arrives the previous one will be redisplayed automatically.
Frame *frame = [_frameQueue dequeueWithTimeout:waitFor];
if (frame) {
CFTimeInterval dl1 = CACurrentMediaTime();
LogOnce(LOG_I, @"Frame pacing: using AVSampleBufferDisplayLayer target %f Hz with %d FPS stream", 1.0f / (deadline - start), self->_frameRate);
// The system works best with properly timed video frames, which we time to the end of the next vsync period,
// the earliest they can be displayed due to double-buffering.
CFTimeInterval targetLocal = deadline + link.duration;
[self renderFrame:frame atTime:CMTimeMakeWithSeconds(targetLocal, NSEC_PER_SEC)];
*/
if(_needRequeuing ? _frameQueue.count>MAX(_queueSize-1,0) : true){
// if(true){
_needRequeuing = false;
if(_asyncFrameDequeue){
[_frameQueue dequeueWithTimeout:waitFor completion:^(Frame *frame) {
if (frame) {
// LogOnce(LOG_I, @"Frame pacing: using AVSampleBufferDisplayLayer target %f Hz with %d FPS stream", 1.0f / (deadline - start), self->_frameRate);
[self renderFrame:frame atTime:CMTimeMakeWithSeconds(targetTime, NSEC_PER_SEC)];
}
}];
}
else{
Frame *frame = [_frameQueue dequeueWithTimeoutSync:waitFor];
if (frame) {
// CFTimeInterval dl1 = CACurrentMediaTime();
// LogOnce(LOG_I, @"Frame pacing: using AVSampleBufferDisplayLayer target %f Hz with %d FPS stream", 1.0f / (deadline - start), self->_frameRate);
// The system works best with properly timed video frames, which we time to the end of the next vsync period,
// the earliest they can be displayed due to double-buffering.
CFTimeInterval targetLocal = targetTime;
[self renderFrame:frame atTime:CMTimeMakeWithSeconds(targetLocal, NSEC_PER_SEC)];
#ifdef DISPLAYLINK_VERBOSE
Log(LOG_I, @"[%.3f] rendering frame %d, waitFor %.3f ms, overhead %.3f ms, lateCallbacks %d, queue size %d",
deadline, frame.frameNumber, waitFor * 1000.0, avgOverhead * 1000.0, lateCallbacks, [_frameQueue count]);
Log(LOG_I, @"[%.3f] rendering frame %d, waitFor %.3f ms, overhead %.3f ms, lateCallbacks %d, queue size %d",
deadline, frame.frameNumber, waitFor * 1000.0, avgOverhead * 1000.0, lateCallbacks, [_frameQueue count]);
#endif
// Update metrics
if (lastTargetLocal != 0) {
CFTimeInterval frametime = targetLocal - lastTargetLocal;
if (frametime > deadline - start + 0.0005f) {
// we missed a callback
// Log(LOG_W, @"*** slow frametime %.3f ms", frametime * 1000.0);
}
if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) {
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:frametime * 1000.0];
// Update metrics
if (lastTargetLocal != 0) {
CFTimeInterval frametime = targetLocal - lastTargetLocal;
if (frametime > targetTime - start + 0.0005f) {
// we missed a callback
// Log(LOG_W, @"*** slow frametime %.3f ms", frametime * 1000.0);
}
if ([[UIApplication sharedApplication] applicationState] != UIApplicationStateBackground) {
[[ImGuiPlots sharedInstance] observeFloat:PLOT_FRAMETIME value:frametime * 1000.0];
}
}
lastTargetLocal = targetLocal;
}
}
lastTargetLocal = targetLocal;
// weighted moving average of how much time displayLink needs after dequeuing a frame.
// This is used to avoid overshooting a vsync by waiting too long.
const double alpha = 0.1f;
avgOverhead = ((CACurrentMediaTime() - dl1) * alpha) + (avgOverhead * (1.0 - alpha));
}
}
@@ -332,17 +368,20 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
while (LiPollNextVideoFrame(&handle, &du)) {
LiCompleteVideoFrame(handle, DrSubmitDecodeUnit(du));
// Calculate the actual display refresh rate
double displayRefreshRate = 1 / (_displayLink.targetTimestamp - _displayLink.timestamp);
// Only pace frames if the display refresh rate is >= 90% of our stream frame rate.
// Battery saver, accessibility settings, or device thermals can cause the actual
// refresh rate of the display to drop below the physical maximum.
if (displayRefreshRate >= _frameRate * 0.9f) {
// Keep one pending frame to smooth out gaps due to
// network jitter at the cost of 1 frame of latency
if (LiGetPendingVideoFrames() == 1) {
break;
// Skip frame pacing logic if frame pacing is off
if (_framePacingMode != FramePacingModeOff) {
// Calculate the actual display refresh rate
double displayRefreshRate = 1 / (_displayLink.targetTimestamp - _displayLink.timestamp);
// Only pace frames if the display refresh rate is >= 90% of our stream frame rate.
// Battery saver, accessibility settings, or device thermals can cause the actual
// refresh rate of the display to drop below the physical maximum.
if (displayRefreshRate >= _frameRate * 0.9f) {
// Keep one pending frame to smooth out gaps due to
// network jitter at the cost of 1 frame of latency
if (LiGetPendingVideoFrames() == 1) {
break;
}
}
}
}
@@ -352,7 +391,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
- (void)renderFrame:(Frame *)frame atTime:(CMTime)targetTime {
CMSampleBufferSetOutputPresentationTimeStamp(frame.sampleBuffer, targetTime);
if ([self->_displayLayer controlTimebase] == NULL) {
if (_enableTimebase && [self->_displayLayer controlTimebase] == NULL) {
// On first frame, set timebase to the initial presentation time.
// This will let us present frames using the local clock (vsync pacing) or
// the pts timestamps from the host.
@@ -371,7 +410,8 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
Log(LOG_I, @"Setting timebase for stream to %d / %d", pts.value, pts.timescale);
}
[self->_displayLayer enqueueSampleBuffer:frame.sampleBuffer];
if(appDidEnterBackgroundWithoutPip) [self->_displayLayer flush];
else [self->_displayLayer enqueueSampleBuffer:frame.sampleBuffer];
#ifdef DISPLAYLINK_VERBOSE
// Some OS-level metrics I'm not sure what to do with
@@ -403,21 +443,22 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
[[NSNotificationCenter defaultCenter] removeObserver:self];
[_displayLink invalidate];
}
- (void)cleanup
{
[_frameQueue stop];
if (_renderingBackend == RENDER_AVSB) {
[_displayLink invalidate];
}
@synchronized(self) {
if (_decompressionSession != NULL) {
VTDecompressionSessionInvalidate(_decompressionSession);
CFRelease(_decompressionSession);
_decompressionSession = nil;
- (void)cleanup{
dispatch_async(dispatch_get_main_queue(), ^{
[self->_frameQueue stop];
if (self->_renderingBackend == RENDER_AVSB) {
[self->_displayLink invalidate];
}
}
@synchronized(self) {
if (self->_decompressionSession != NULL) {
VTDecompressionSessionInvalidate(self->_decompressionSession);
CFRelease(self->_decompressionSession);
self->_decompressionSession = nil;
}
}
});
}
#define NALU_START_PREFIX_SIZE 3
@@ -856,7 +897,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
CMSampleBufferRef sampleBuffer;
CMTime presentationTimeStamp;
if (_useLegacyPacing) {
if (_framePacingMode == FramePacingModeLegacy || _framePacingMode == FramePacingModeOff) {
presentationTimeStamp = CMTimeMake(du->presentationTimeUs / 1000, 1000);
} else {
presentationTimeStamp = CMTimeMake((int64_t)du->rtpTimestamp, 90000);
@@ -881,9 +922,10 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
return DR_NEED_IDR;
}
if (_useLegacyPacing) {
if (_framePacingMode == FramePacingModeLegacy || _framePacingMode == FramePacingModeOff) {
// Enqueue the next frame
[self->_displayLayer enqueueSampleBuffer:sampleBuffer];
if(appDidEnterBackgroundWithoutPip) [self->_displayLayer flush];
else [self->_displayLayer enqueueSampleBuffer:sampleBuffer];
if (du->frameType == FRAME_TYPE_IDR) {
// Ensure the layer is visible now
-26
View File
@@ -1,26 +0,0 @@
//
// ThemeManager.h
// VoidLink
//
// Created by True砖家 on 2025.5.25.
// Copyright © 2025 True砖家 @ Bilibili. All rights reserved.
//
#import <UIKit/UIKit.h>
@interface ThemeManager:NSObject
@property (class, nonatomic, assign) UIUserInterfaceStyle userInterfaceStyle;
+ (UIColor *)appPrimaryColor;
+ (UIColor *)appSecondaryColor;
+ (UIColor *)appPrimaryColorWithAlpha;
+ (UIColor *)widgetBackgroundColor;
+ (UIColor *)appBackgroundColor;
+ (UIColor *)separatorColor;
+ (UIColor *)textColor;
+ (UIColor *)textColorGray;
+ (UIColor *)lowProfileGray;
+ (UIColor *)textTintColorWithAlpha;
@end
-148
View File
@@ -1,148 +0,0 @@
//
// ThemeManager.h
// VoidLink
//
// Created by True砖家 on 2025.5.25.
// Copyright © 2025 True砖家 @ Bilibili. All rights reserved.
//
#import "ThemeManager.h"
@implementation ThemeManager
static UIUserInterfaceStyle _privateUserInterfaceStyle = UIUserInterfaceStyleUnspecified;
static UIUserInterfaceStyle _userInterfaceStyle;
+ (UIColor *)getUIStyle{
if (@available(iOS 13.0, *)) {
UITraitCollection *traitCollection = [UIScreen mainScreen].traitCollection;
if(_privateUserInterfaceStyle == UIUserInterfaceStyleUnspecified) {
_userInterfaceStyle = traitCollection.userInterfaceStyle;
}
else _userInterfaceStyle = _privateUserInterfaceStyle;
return [UIColor clearColor];
} else {
return [UIColor clearColor];
}
}
+ (UIUserInterfaceStyle)userInterfaceStyle {
[ThemeManager getUIStyle];
return _userInterfaceStyle;
}
+ (void)setUserInterfaceStyle:(UIUserInterfaceStyle)style {
_privateUserInterfaceStyle = style;
[ThemeManager getUIStyle];
}
+ (UIColor *)appBackgroundColor {
[ThemeManager getUIStyle];
switch (self.userInterfaceStyle) {
case UIUserInterfaceStyleLight:
return [UIColor colorWithRed:242.0/255.0 green:242.0/255.0 blue:247.0/255.0 alpha:1.0];
break;
case UIUserInterfaceStyleDark:
default:
return [UIColor colorWithRed:28.0/255.0 green:28.0/255.0 blue:30.0/255.0 alpha:1.0];
//return [UIColor blackColor];
break;
}
}
+ (UIColor *)widgetBackgroundColor {
[ThemeManager getUIStyle];
switch (self.userInterfaceStyle) {
case UIUserInterfaceStyleLight:
return [UIColor whiteColor];
break;
case UIUserInterfaceStyleDark:
default:
return [UIColor colorWithRed:44.0/255.0 green:44.0/255.0 blue:46.0/255.0 alpha:1.0];
break;
}
}
+ (UIColor *)separatorColor {
[ThemeManager getUIStyle];
switch (self.userInterfaceStyle) {
case UIUserInterfaceStyleLight:
return [UIColor colorWithWhite:0.1 alpha:0.28];
break;
case UIUserInterfaceStyleDark:
default:
return [UIColor colorWithWhite:0.28 alpha:1.0];
break;
}
}
+ (UIColor *)textColor {
[ThemeManager getUIStyle];
switch (self.userInterfaceStyle) {
case UIUserInterfaceStyleLight:
return [UIColor blackColor];
break;
case UIUserInterfaceStyleDark:
default:
return [UIColor whiteColor];
break;
}
}
+ (UIColor *)appPrimaryColor {
return [UIColor colorWithRed:0.0 green:0.48 blue:1.0 alpha:1.0]; // #0A84FF
}
+ (UIColor *)appSecondaryColor {
// return [UIColor colorWithRed:0.0 green:0.319 blue:0.64 alpha:1.0]; // 可换其他常用色
UIColor* originalColor = [ThemeManager appPrimaryColor];
UIColor *grayColor = [UIColor grayColor];
CGFloat mixRatio = 0.3; // 混合比例,0.0 到 1.0
CGFloat r1, g1, b1, a1;
CGFloat r2, g2, b2, a2;
[originalColor getRed:&r1 green:&g1 blue:&b1 alpha:&a1];
[grayColor getRed:&r2 green:&g2 blue:&b2 alpha:&a2];
CGFloat r = r1 * (1 - mixRatio) + r2 * mixRatio;
CGFloat g = g1 * (1 - mixRatio) + g2 * mixRatio;
CGFloat b = b1 * (1 - mixRatio) + b2 * mixRatio;
CGFloat a = a1 * (1 - mixRatio) + a2 * mixRatio;
UIColor *tonedColor = [UIColor colorWithRed:r green:g blue:b alpha:a];
return tonedColor; // 可换其他常用色
}
+ (UIColor *)appPrimaryColorWithAlpha {
[ThemeManager getUIStyle];
switch (self.userInterfaceStyle) {
case UIUserInterfaceStyleLight:
return [[ThemeManager appPrimaryColor] colorWithAlphaComponent:0.24]; // #0A84FF
break;
case UIUserInterfaceStyleDark:
default:
return [[ThemeManager appPrimaryColor] colorWithAlphaComponent:0.24];
break;
}
}
+ (UIColor *)textTintColorWithAlpha{
return [[ThemeManager appPrimaryColor] colorWithAlphaComponent:0.24];
}
+ (UIColor *)textColorGray{
return [UIColor colorWithRed:0.55 green:0.55 blue:0.6 alpha:0.95];
}
+ (UIColor *)lowProfileGray{
return [UIColor colorWithRed:0.65 green:0.65 blue:0.65 alpha:0.4];
}
@end
+328
View File
@@ -0,0 +1,328 @@
//
// ThemeManager.swift
// VoidLink
//
// Created by True砖家 on 2026/3/6.
// Copyright © 2026 True砖家 @ Bilibili. All rights reserved.
//
import UIKit
@objcMembers
class ThemeManager: NSObject {
static let ThemeDidChangeNotification = "ThemeDidChangeNotification"
private static var _privateUserInterfaceStyle: UIUserInterfaceStyle = .unspecified
private static var _userInterfaceStyle: UIUserInterfaceStyle = .unspecified
@objc class func setPublicUIStyle() -> UIColor {
if #available(iOS 13.0, *) {
let traitCollection = UIScreen.main.traitCollection
if _privateUserInterfaceStyle == .unspecified {
_userInterfaceStyle = traitCollection.userInterfaceStyle
} else {
_userInterfaceStyle = _privateUserInterfaceStyle
}
}
return UIColor.clear
}
@objc class func userInterfaceStyle() -> UIUserInterfaceStyle {
_ = setPublicUIStyle()
return _userInterfaceStyle
}
@objc class func setUserInterfaceStyle(_ style: UIUserInterfaceStyle) {
_privateUserInterfaceStyle = style
if _userInterfaceStyle == style {
return
}
_ = setPublicUIStyle()
NotificationCenter.default.post(
name: Notification.Name(ThemeDidChangeNotification),
object: nil
)
}
@objc static var menuBackgroundColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
return UIColor(
red: 242.0/255.0,
green: 242.0/255.0,
blue: 247.0/255.0,
alpha: 1
)
default:
if #available(iOS 13.0, *) {
let darkTraits = UITraitCollection(userInterfaceStyle: .dark)
return UIColor.secondarySystemBackground.resolvedColor(with: darkTraits)
} else {
return UIColor(
red: 28.0/255.0,
green: 28.0/255.0,
blue: 30.0/255.0,
alpha: 1
)
}
}
}
@objc static var hostViewBackgroundColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
return UIColor(
red: 242.0/255.0,
green: 242.0/255.0,
blue: 247.0/255.0,
alpha: 1
)
default:
if #available(iOS 13.0, *) {
let darkTraits = UITraitCollection(userInterfaceStyle: .dark)
return UIColor.systemGroupedBackground.resolvedColor(with: darkTraits)
} else {
return UIColor.black
}
}
}
@objc static var offlineHostIconBackgroundColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
return UIColor(
red: 242.0/255.0,
green: 242.0/255.0,
blue: 247.0/255.0,
alpha: 1
)
default:
return UIColor(
red: 17.0/255.0,
green: 17.0/255.0,
blue: 19.0/255.0,
alpha: 1
)
}
}
@objc static var widgetBackgroundColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
return UIColor.white
default:
if #available(iOS 13.0, *) {
// return UIColor.secondarySystemBackground
let darkTraits = UITraitCollection(userInterfaceStyle: .dark)
return UIColor.secondarySystemGroupedBackground.resolvedColor(with: darkTraits)
} else {
return UIColor(
red: 44.0/255.0,
green: 44.0/255.0,
blue: 46.0/255.0,
alpha: 1
)
}
}
}
@objc static var separatorColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
if #available(iOS 13.0, *) {
let lightTraits = UITraitCollection(userInterfaceStyle: .light)
return GenericUtils.liquidGlassEnabled ? legacySepratorColor : UIColor.separator.resolvedColor(with: lightTraits)
} else {
return UIColor(
red: 214.0/255.0,
green: 214.0/255.0,
blue: 218.0/255.0,
alpha: 1
)
}
default:
if #available(iOS 13.0, *) {
let darkTraits = UITraitCollection(userInterfaceStyle: .dark)
return GenericUtils.liquidGlassEnabled ? legacySepratorColor : UIColor.separator.resolvedColor(with: darkTraits)
} else {return UIColor(white: 0.28, alpha: 1)}
}
}
@objc static var legacySepratorColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
return UIColor(
red: 0.23529411764705882,
green: 0.23529411764705882,
blue: 0.2627450980392157,
alpha: 0.29
)
default:
return UIColor(
red: 0.32941176470588235,
green: 0.32941176470588235,
blue: 0.34509803921568627,
alpha: 0.6
)
}
}
@objc static var hostCardSeparatorColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
if #available(iOS 13.0, *) {
let lightTraits = UITraitCollection(userInterfaceStyle: .light)
return UIColor.separator.resolvedColor(with: lightTraits)
} else {
return UIColor(
red: 214.0/255.0,
green: 214.0/255.0,
blue: 218.0/255.0,
alpha: 1
)
}
default:
if #available(iOS 13.0, *) {
let darkTraits = UITraitCollection(userInterfaceStyle: .dark)
return UIColor.separator.resolvedColor(with: darkTraits)
} else {return UIColor(white: 0.28, alpha: 1)}
}
}
@objc static var textColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
return UIColor.black
default:
return UIColor.white
}
}
@objc static var sectionLabelTextColor: UIColor {
_ = setPublicUIStyle()
switch userInterfaceStyle() {
case .light:
if #available(iOS 13.0, *) {
let traits = UITraitCollection(userInterfaceStyle: .light)
return UIColor.label.resolvedColor(with: traits)
} else {
return UIColor.black
}
default:
if #available(iOS 13.0, *) {
let traits = UITraitCollection(userInterfaceStyle: .dark)
return UIColor.label.resolvedColor(with: traits)
} else {
return UIColor.white
}
}
}
@objc static var appPrimaryColor: UIColor {
return UIColor(
red: 0.0,
green: 0.48,
blue: 1.0,
alpha: 1.0
) // #0A84FF
}
@objc static var appSecondaryColor: UIColor {
let originalColor = appPrimaryColor
let grayColor = UIColor.gray
let mixRatio: CGFloat = 0.3
var r1: CGFloat = 0
var g1: CGFloat = 0
var b1: CGFloat = 0
var a1: CGFloat = 0
var r2: CGFloat = 0
var g2: CGFloat = 0
var b2: CGFloat = 0
var a2: CGFloat = 0
originalColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1)
grayColor.getRed(&r2, green: &g2, blue: &b2, alpha: &a2)
let r = r1 * (1 - mixRatio) + r2 * mixRatio
let g = g1 * (1 - mixRatio) + g2 * mixRatio
let b = b1 * (1 - mixRatio) + b2 * mixRatio
let a = a1 * (1 - mixRatio) + a2 * mixRatio
return UIColor(red: r, green: g, blue: b, alpha: a)
}
@objc static var appPrimaryColorWithAlpha: UIColor {
return appPrimaryColor.withAlphaComponent(0.24)
}
@objc static var textTintColorWithAlpha: UIColor {
return appPrimaryColor.withAlphaComponent(0.24)
}
@objc static var textColorGray: UIColor {
return UIColor(
red: 0.55,
green: 0.55,
blue: 0.6,
alpha: 0.95
)
}
@objc static var lowProfileGray: UIColor {
return UIColor(
red: 0.65,
green: 0.65,
blue: 0.65,
alpha: 0.4
)
}
@available(iOS 26.0, *)
@objc static var liquidGlassSwitchOffTint: UIColor {
switch userInterfaceStyle() {
case .light:
return UIColor.systemFill
default:
return UIColor.clear
}
}
@objc static var liquidGlassSliderMaxTrackTint: UIColor {
if #available(iOS 13.0, *) {
switch userInterfaceStyle() {
case .light:
return UIColor.tertiarySystemFill
default:
return UIColor.tertiarySystemFill
}
}
else {
return UIColor.black
}
}
}
+3 -3
View File
@@ -12,7 +12,7 @@
#import "UIAppView.h"
#import "AppAssetManager.h"
#import "ThemeManager.h"
#import "VoidLink-Swift.h"
static const float REFRESH_CYCLE = 1.0f;
@@ -178,7 +178,7 @@ static UIImage* noImage;
if (@available(iOS 13.0, *)) {
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:23];
UIImageView* playIcon = [[UIImageView alloc] initWithImage:[[UIImage systemImageNamed:@"play.circle.fill" withConfiguration:config] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]];
//playIcon.tintColor = [[ThemeManager widgetBackgroundColor] colorWithAlphaComponent:0.85];
//playIcon.tintColor = [ThemeManager.widgetBackgroundColor colorWithAlphaComponent:0.85];
playIcon.tintColor = [[UIColor blackColor] colorWithAlphaComponent:0.55];
_appOverlay = playIcon;
@@ -202,7 +202,7 @@ static UIImage* noImage;
UIImage* appIconImage = [[UIImage imageNamed:@"icon-pc-app"] imageWithConfiguration:config];
UIImageView* appIcon = [[UIImageView alloc] initWithImage:[appIconImage imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]];
//layIcon.tintColor = [[ThemeManager widgetBackgroundColor] colorWithAlphaComponent:0.85];
//layIcon.tintColor = [ThemeManager.widgetBackgroundColor colorWithAlphaComponent:0.85];
appIcon.tintColor = [[UIColor blackColor] colorWithAlphaComponent:0.5];
_appOverlay = appIcon;
+3 -1
View File
@@ -13,6 +13,8 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic) int highWaterMark;
@property (nonatomic, readonly) int maxCapacity;
@property (atomic) BOOL paused;
- (void)dequeueWithTimeout:(CFTimeInterval)timeout
completion:(void (^)(Frame *frame))completion;
+ (instancetype)sharedInstance;
@@ -23,7 +25,7 @@ NS_ASSUME_NONNULL_BEGIN
- (int)enqueue:(Frame *)frame;
- (int)enqueue:(Frame *)frame withSlackSize:(int)slack;
- (nullable Frame *)dequeue;
- (nullable Frame *)dequeueWithTimeout:(CFTimeInterval)timeout;
- (nullable Frame *)dequeueWithTimeoutSync:(CFTimeInterval)timeout;
- (CFTimeInterval)estimatedFramerate;
- (int)currentSoftCap;
- (void)waitForEnqueue;
+13 -1
View File
@@ -251,7 +251,19 @@
return frame;
}
- (Frame *)dequeueWithTimeout:(CFTimeInterval)timeout {
- (void)dequeueWithTimeout:(CFTimeInterval)timeout
completion:(void (^)(Frame *frame))completion {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
Frame *frame = [self dequeueWithTimeoutSync:timeout];
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) {
completion(frame);
}
});
});
}
- (Frame *)dequeueWithTimeoutSync:(CFTimeInterval)timeout {
CFTimeInterval start = CACurrentMediaTime();
CFTimeInterval deadline = start + timeout;
int round = 0;
+108
View File
@@ -0,0 +1,108 @@
//
// GenericUtils.swift
// VoidLink
//
// Created by True砖家 on 2026/2/26.
// Copyright © 2026 True砖家@Bilibili. All rights reserved.
//
import Foundation
@objc public class GenericUtils: NSObject {
@objc static func needUpdateDefaultSettings() -> Bool {
// let key = "needUpdateDefaultSettings20260226-1"
let key = "needUpdateDefaultSettings20260226-2"
guard !UserDefaults.standard.bool(forKey: key) else {
return false
}
UserDefaults.standard.set(true, forKey: key)
return true
}
@objc static func isEnableOswForNativeTouchSwitchFirstFlipping() -> Bool {
let key = "enableOswForNativeTouchSwitchFlipped"
guard !UserDefaults.standard.bool(forKey: key) else {
return false
}
UserDefaults.standard.set(true, forKey: key)
return true
}
@objc static func isFirstLaunchPressureCurveTool() -> Bool {
let key = "hasLaunchedPressureCurveTool"
let defaults = UserDefaults.standard
let launchedBefore = defaults.bool(forKey: key)
if !launchedBefore {
defaults.set(true, forKey: key)
return true
}
return false
}
@objc static func isIPhone() -> Bool {
return UIDevice.current.userInterfaceIdiom == .phone
}
@objc static func isIPad() -> Bool {
return UIDevice.current.userInterfaceIdiom == .pad
}
@objc static let liquidGlassEnabled: Bool = {
if #available(iOS 26.0, tvOS 26.0, *) {
let useLegacyUI = Bundle.main.object(forInfoDictionaryKey: "UIDesignRequiresCompatibility") as? Bool
return useLegacyUI != true
} else {
return false
}
}()
@objc static let menuSeparatorWidth: CGFloat = 0.7
@objc static let menuSectionSeparatorWidth: CGFloat = 0.7
@objc static var legacyToolbarHeight: CGFloat {
return 44
}
@objc static var hostViewNavigationBarHeight: CGFloat {
switch UIDevice.current.userInterfaceIdiom {
case .phone:
return liquidGlassEnabled ? 54 : 44
case .pad:
return liquidGlassEnabled ? 54 : 50
default:
return liquidGlassEnabled ? 54 : 50
}
}
@objc static var settingsMenuNavigationBarHeight: CGFloat {
// return isIPhone() ? 44 : hostViewNavigationBarHeight
switch UIDevice.current.userInterfaceIdiom {
case .phone:
return liquidGlassEnabled ? hostViewNavigationBarHeight+5 : hostViewNavigationBarHeight
case .pad:
return liquidGlassEnabled ? hostViewNavigationBarHeight+9 : hostViewNavigationBarHeight
default:
return liquidGlassEnabled ? hostViewNavigationBarHeight+9 : hostViewNavigationBarHeight
}
}
@objc static var dockedNavBarTopAnchorOffset: CGFloat {
return liquidGlassEnabled ? 10 : 0
}
@available(iOS 26.0, *)
@objc static func applyOffTintColor(_ view: UIView) {
let name = String(describing: type(of: view))
if name.contains("UISwitchModernVisualElement") {
view.backgroundColor = ThemeManager.liquidGlassSwitchOffTint
view.layer.cornerRadius = view.bounds.height/2
view.clipsToBounds = true
}
for sub in view.subviews {
applyOffTintColor(sub)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
//
// GraphicUtils.swift
// VoidLink
//
// Created by True砖家 on 2025/12/24.
// Copyright © 2025 True砖家@Bilibili. All rights reserved.
//
import Foundation
import SVGKit
@objc public class GraphicUtils: NSObject {
@objc public static func makeSVGLayer(
from file: String,
in container: CALayer,
at normalizedPosition: CGPoint = .zero,
targetSize: CGSize
) -> CALayer {
guard let url = Bundle.main.url(forResource: file, withExtension: "svg"),
let data = try? Data(contentsOf: url) else {
return CALayer()
}
guard let svg = SVGKImage(data: data) else {
fatalError("Failed to load SVG \(file)")
}
return _makeSVGLayer(
from: svg,
in: container,
at: normalizedPosition,
targetSize: targetSize
)
}
public static func makeCenteredSVGLayer(
from svg: SVGKImage,
in container: CALayer,
targetSize: CGSize
) -> CALayer {
return _makeSVGLayer(
from: svg,
in: container,
targetSize: targetSize
)
}
@objc public static func _makeSVGLayer(
from svg: SVGKImage,
in container: CALayer,
at normalizedPosition: CGPoint = .zero,
targetSize: CGSize,
getWrapperLayer: Bool = true
) -> CALayer {
let svgLayer = svg.caLayerTree!
svgLayer.transform = CATransform3DIdentity
var unionRect = CGRect.null
func accumulateBounds(_ layer: CALayer) {
unionRect = unionRect.union(layer.frame)
layer.sublayers?.forEach { accumulateBounds($0) }
}
accumulateBounds(svgLayer)
svgLayer.setAffineTransform(
CGAffineTransform(
translationX: -unionRect.origin.x,
y: -unionRect.origin.y
)
)
let realPosition = (normalizedPosition == .zero
? CGPoint(x: container.bounds.midX,y: container.bounds.midY)
: CGPoint(x: container.bounds.width*normalizedPosition.x,y: container.bounds.height*normalizedPosition.y))
let wrapper = CALayer()
wrapper.bounds = CGRect(origin: .zero, size: unionRect.size)
wrapper.anchorPoint = CGPoint(x: 0.5, y: 0.5)
wrapper.position = realPosition
let scale = min(
targetSize.width / unionRect.width,
targetSize.height / unionRect.height
)
wrapper.setAffineTransform(
CGAffineTransform(scaleX: scale, y: scale)
)
wrapper.addSublayer(svgLayer)
if !getWrapperLayer {
return wrapper
}
let wrappedLayer = CALayer()
wrappedLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
wrappedLayer.bounds = CGRect(x: 0, y: 0, width: container.bounds.size.width, height: container.bounds.size.height)
wrappedLayer.position = CGPoint(x: container.bounds.midX, y: container.bounds.midY)
wrappedLayer.insertSublayer(wrapper, at: 0)
return wrappedLayer
}
@objc public static func changeColor(layer: CALayer, color: UIColor) {
if let shape = layer as? CAShapeLayer {
shape.fillColor = color.cgColor
shape.strokeColor = color.cgColor
}
layer.sublayers?.forEach { changeColor(layer: $0, color: color) }
}
@objc public static func makeTouchTrackpoint(in view:UIView) -> CAShapeLayer {
var trackPoint = CAShapeLayer()
let path = UIBezierPath(
arcCenter: CGPoint(x: 0, y: 0),
radius: GenericUtils.isIPhone() ? 12 : 15,
startAngle: 0,
endAngle: CGFloat.pi * 2,
clockwise: true
)
trackPoint = CAShapeLayer()
trackPoint.path = path.cgPath
trackPoint.fillColor = UIColor.white.withAlphaComponent(0.3).cgColor
trackPoint.strokeColor = UIColor.clear.cgColor
trackPoint.lineWidth = 0
view.layer.addSublayer(trackPoint)
trackPoint.position = view.center
trackPoint.isHidden = true
return trackPoint
}
}
+474
View File
@@ -0,0 +1,474 @@
//
// IAPManager.swift
// VoidLink
//
// Created by True砖家 on 2026/1/9.
// Copyright © 2026 True砖家@Bilibili. All rights reserved.
//
import Foundation
import StoreKit
@objc public enum PurchaseStatus: Int {
case notPurchased
case purchased
case revoked
}
@objc public enum PurchaseInterruption: Int {
case unlockNow
case restore
case learnMore
case lowOSVersion
}
@objc public class PurchaseInfo: NSObject {
@objc public let valid: Bool
@objc public let status: PurchaseStatus
@objc public let expirationDate: NSDate?
@objc public init(status: PurchaseStatus, expirationDate: NSDate?) {
self.status = status
self.expirationDate = expirationDate
self.valid = (status == .purchased)
&& (expirationDate?.timeIntervalSinceNow ?? .infinity > 0)
}
}
@objc public enum AddOnProduct: UInt8, CaseIterable {
case PencilProPack
func productId() -> String {
let bundleId = Bundle.main.bundleIdentifier ?? ""
switch (self, bundleId) {
case (.PencilProPack, "com.voidlink.iOS"):
return "com.pencilpro.voidlink.iOS"
case (.PencilProPack, "com.voidlinkextreme.iOS"):
return "com.pencilpro.voidlinkextreme.iOS"
case (.PencilProPack, "com.voidlink.tf.debug10.iOS"):
return "com.pencilpro.voidlink.debug.iOS"
case (.PencilProPack, "com.voidlink.tf.iOS"):
return "com.pencilpro.voidlink.tf.iOS"
default:
return ""
}
}
func productName() -> String {
switch self {
case .PencilProPack:
return SwiftLocalizationHelper.localizedString(forKey: "Drawing Toolkit")
default:
return ""
}
}
func productURL() -> String {
switch self {
case .PencilProPack:
return SwiftLocalizationHelper.localizedString(forKey: "PencilProPackURL")
default:
return ""
}
}
func productDescription() -> String {
switch self {
case .PencilProPack:
return SwiftLocalizationHelper.localizedString(forKey: "PencilProPackDescription")
default:
return ""
}
}
func purchaseAbortedNotification() -> Notification.Name {
switch self {
case .PencilProPack:
return Notification.Name("PencilProPurchaseAbortedNotification")
default:
return Notification.Name("")
}
}
func purchaseSucceededNotification() -> Notification.Name {
switch self {
case .PencilProPack:
return Notification.Name("PencilProPurchaseSucceededNotification")
default:
return Notification.Name("")
}
}
static func from(productId: String) -> AddOnProduct? {
switch productId {
case "com.pencilpro.voidlink.iOS",
"com.pencilpro.voidlinkextreme.iOS",
"com.pencilpro.voidlink.debug.iOS",
"com.pencilpro.voidlink.tf.iOS":
return .PencilProPack
default:
return nil
}
}
}
@objc public protocol IAPManagerDelegate: NSObjectProtocol {
@objc func iapManagerDidFetchProducts(_ products: [String])
@objc func iapManagerDidFailWithError(_ error: Error)
@objc func iapManagerDidPurchase(_ product: AddOnProduct)
@objc func iapManagerDidRestore(_ product: AddOnProduct)
}
@objc public class IAPManager: NSObject {
@objc public static let shared = IAPManager()
@objc public weak var delegate: IAPManagerDelegate?
@objc public private(set) var fetchedProductIds: [String] = []
private override init() {
super.init()
if #available(iOS 15.0, *, *) {
Task {
await listenForTransactions()
}
}
}
// MARK: - Fetch Products (StoreKit 2)
@objc public func fetchProducts() {
if #available(iOS 15.0, *, *) {
Task {
await fetchProductsStoreKit2()
}
} else {
fetchProductsLegacy()
}
}
@available(iOS 15.0, *, *)
private func fetchProductsStoreKit2() async {
let ids = Set(AddOnProduct.allCases.map { $0.productId() })
do {
let sk2Products = try await Product.products(for: ids)
let idsFetched = sk2Products.map { $0.id }
await MainActor.run {
self.delegate?.iapManagerDidFetchProducts(idsFetched)
self.fetchedProductIds = idsFetched
}
} catch {
await MainActor.run {
self.delegate?.iapManagerDidFailWithError(error)
}
}
}
@objc public var skProducts: [SKProduct] = []
private var productsRequest: SKProductsRequest?
private func fetchProductsLegacy() {
let productIds = Set(AddOnProduct.allCases.map { $0.productId() })
guard productsRequest == nil else { return }
guard productIds.count>0 else { return }
fetchedProductIds = Array(productIds)
productsRequest = SKProductsRequest(
productIdentifiers: productIds
)
productsRequest?.delegate = self
productsRequest?.start()
}
// MARK: - Purchase (StoreKit 2)
@objc public func purchase(_ product: AddOnProduct) {
if #available(iOS 15.0, *, *) {
Task {
await purchaseStoreKit2(product)
}
} else {
purchaseLegacy(product)
}
}
@available(iOS 15.0, *, *)
private func purchaseStoreKit2(_ product: AddOnProduct) async {
let pid = product.productId()
do {
let products = try await Product.products(for: [pid])
guard let sk2p = products.first else {
throw NSError(
domain: "IAPManager",
code: -2,
userInfo: [NSLocalizedDescriptionKey: "Product not found"]
)
}
let result = try await sk2p.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
await MainActor.run {
self.delegate?.iapManagerDidPurchase(product)
}
await transaction.finish()
IAPManager.handlePurchaseSuccess(product)
NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil)
case .unverified(_, let err):
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
await MainActor.run {
self.delegate?.iapManagerDidFailWithError(err)
}
}
case .userCancelled:
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
break
default:
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
break
}
} catch {
await MainActor.run {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue])
self.delegate?.iapManagerDidFailWithError(error)
}
}
}
@objc static func handlePurchaseSuccess(_ product: AddOnProduct) {
switch product {
case .PencilProPack:
let dataMan = DataManager()
let settings = dataMan.retrieveSettings()
settings?.onscreenControls = 1
dataMan.saveData()
let profileMan = OSCProfilesManager.sharedManager(.zero)
profileMan.updateDefaultTemplates()
let profiles = profileMan.getAllProfiles()
guard profiles.count > 1 else { break }
guard let targetProfile = profiles[1] as? OSCProfile else { break }
if targetProfile.name == "Pencil Pro" {
profileMan.setProfileToSelected(1)
}
default:
break
}
}
private func purchaseLegacy(_ product: AddOnProduct) {
let productId = product.productId()
print("products \(skProducts.count)")
guard let skProduct = skProducts.first(
where: {
print("$0.productIdentifier \($0.productIdentifier), productId \(productId)")
return $0.productIdentifier == productId
}
) else { return }
let payment = SKPayment(product: skProduct)
SKPaymentQueue.default().add(payment)
}
// MARK: - Restore (StoreKit 2)
@objc public func restorePurchasesStoreKit2() {
if #available(iOS 15.0, *, *) {
Task {
await restoreStoreKit2()
}
}
else{
restorePurchasesLegacy()
}
}
@available(iOS 15.0, *, *)
private func restoreStoreKit2() async {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
let adp = AddOnProduct.from(productId: transaction.productID) {
await MainActor.run {
self.delegate?.iapManagerDidRestore(adp)
}
}
}
}
private func restorePurchasesLegacy() {
SKPaymentQueue.default().restoreCompletedTransactions()
}
// MARK: - Listen update
@available(iOS 15.0, *, *)
private func listenForTransactions() async {
for await verificationResult in Transaction.updates {
if case .verified(let transaction) = verificationResult,
let adp = AddOnProduct.from(productId: transaction.productID) {
await MainActor.run {
// 这里把支付成功回调出来
self.delegate?.iapManagerDidPurchase(adp)
}
await transaction.finish()
}
}
}
// MARK: Utils
@objc static public func checkPurchaseInfo(
_ product: AddOnProduct,
completion: @escaping (PurchaseInfo) -> Void
) {
let productID = product.productId()
if #available(iOS 15.0, *) {
Task {
var status: PurchaseStatus = .notPurchased
var expiration: Date? = nil
for await verificationResult in Transaction.currentEntitlements {
if case .verified(let transaction) = verificationResult,
transaction.productID == productID {
// 有退款 / 撤销
if transaction.revocationDate != nil {
status = .revoked
} else {
status = .purchased
expiration = transaction.expirationDate
}
break
}
}
let info = PurchaseInfo(status: status, expirationDate: expiration as NSDate?)
await MainActor.run {
completion(info)
}
}
}
else {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.lowOSVersion, userInfo:["interruption": PurchaseInterruption.lowOSVersion.rawValue])
}
}
@available(iOS 15.0, *)
static public func checkPurchaseInfo(
_ product: AddOnProduct
) async -> PurchaseInfo {
await withCheckedContinuation { continuation in
checkPurchaseInfo(product) { info in
continuation.resume(returning: info)
}
}
}
@objc static public func inAppPurchaseAction(viewController: UIViewController, product: AddOnProduct){
let alert = UIAlertController(title: product.productName(),
message: SwiftLocalizationHelper.localizedString(forKey: "No purchase found", product.productName()),
preferredStyle: .alert)
let unlockAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Purchase Now"), style: .default) { _ in
IAPManager.shared.purchase(product)
}
let restoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Restore Purchase"), style: .default) { _ in
if #available(iOS 13.0, *) {
Task{
do {
if #available(iOS 15.0, *) {
try await AppStore.sync()
} else {
// Fallback on earlier versions
}
} catch {
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.restore, userInfo:["interruption": PurchaseInterruption.restore.rawValue])
print("Restore failed: \(error)")
}
}
} else {
// Fallback on earlier versions
}
}
let learnMoreAction = UIAlertAction(title: SwiftLocalizationHelper.localizedString(forKey: "Learn More"), style: .default) { _ in
NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.learnMore, userInfo:["interruption": PurchaseInterruption.learnMore.rawValue])
if let url = URL(string: product.productURL()) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
return
}
alert.addAction(unlockAction)
alert.addAction(restoreAction)
alert.addAction(learnMoreAction)
viewController.present(alert, animated: true, completion: {
})
}
@objc static public func inAppPurchaseURLMessage(viewController: UIViewController, product: AddOnProduct){
return
}
}
extension IAPManager: SKProductsRequestDelegate {
public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
skProducts = response.products
let productIds = Array(skProducts.map { $0.productIdentifier })
delegate?.iapManagerDidFetchProducts(productIds)
}
public func request(_ request: SKRequest, didFailWithError error: Error) {
delegate?.iapManagerDidFailWithError(error)
}
}
extension IAPManager: SKPaymentTransactionObserver {
public func paymentQueue(_ queue: SKPaymentQueue,
updatedTransactions transactions: [SKPaymentTransaction]) {
print("paymentQueue \(CACurrentMediaTime())")
for transaction in transactions {
let productId = transaction.payment.productIdentifier
guard let product = AddOnProduct.from(productId: productId) else {
SKPaymentQueue.default().finishTransaction(transaction)
continue
}
print("transaction.transactionState \(transaction.transactionState)")
switch transaction.transactionState {
case .purchased:
delegate?.iapManagerDidPurchase(product)
SKPaymentQueue.default().finishTransaction(transaction)
case .restored:
delegate?.iapManagerDidRestore(product)
SKPaymentQueue.default().finishTransaction(transaction)
case .failed:
if let error = transaction.error {
delegate?.iapManagerDidFailWithError(error)
}
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More