From 6740a7130ba3c8c07320870fb0e0b6333862cc04 Mon Sep 17 00:00:00 2001 From: Long Le Date: Tue, 27 Jun 2023 14:13:05 -0700 Subject: [PATCH] includes on screen controls --- .../ArrowBackward.imageset/Contents.json | 25 + ...arrow_uturn_backward_square-120x120@3x.png | Bin 0 -> 3777 bytes .../arrow_uturn_backward_square-80x80@2x.png | Bin 0 -> 2017 bytes .../Contents.json | 25 + ...row_uturn_backward_square_fill-120x120.png | Bin 0 -> 3195 bytes ...ow_uturn_backward_square_fill-80x80@2x.png | Bin 0 -> 1707 bytes .../ChevronCompactDown.imageset/Contents.json | 25 + .../chevron_compact_down@2x.png | Bin 0 -> 848 bytes .../chevron_compact_down@3x.png | Bin 0 -> 1216 bytes .../ChevronCompactUp.imageset/Contents.json | 25 + .../chevron_compact_up@2x.png | Bin 0 -> 833 bytes .../chevron_compact_up@3x.png | Bin 0 -> 1200 bytes Limelight/Images.xcassets/Contents.json | 6 +- .../SquareAndArrowDown.imageset/Contents.json | 25 + .../square_and_arrow_down-120x120@3x.png | Bin 0 -> 2785 bytes .../square_and_arrow_down-80x80@2x.png | Bin 0 -> 1495 bytes .../SquaresArrowUp.imageset/Contents.json | 25 + ...uare_and_arrow_up_on_square-120x120@3x.png | Bin 0 -> 2834 bytes ...square_and_arrow_up_on_square-80x80@2x.png | Bin 0 -> 1616 bytes .../Trash.imageset/Contents.json | 25 + .../trash_square-120x120@3x.png | Bin 0 -> 4387 bytes .../Trash.imageset/trash_square-80x80@2x.png | Bin 0 -> 2382 bytes .../TrashFilled.imageset/Contents.json | 22 + .../trash_square_fill-120x120@3x.png | Bin 0 -> 3974 bytes .../trash_square_fill-80x80@2x.png | Bin 0 -> 2201 bytes .../LayoutOnScreenControls.h | 35 ++ .../LayoutOnScreenControls.m | 239 +++++++++ .../Custom OnScreen Controls/OSCProfile.h | 32 ++ .../Custom OnScreen Controls/OSCProfile.m | 43 ++ .../OSCProfilesManager.h | 63 +++ .../OSCProfilesManager.m | 215 ++++++++ .../OnScreenButtonState.h | 32 ++ .../OnScreenButtonState.m | 43 ++ Limelight/Input/OnScreenControls.h | 43 +- Limelight/Input/OnScreenControls.m | 204 ++++++-- .../Cells/ProfileTableViewCell.h | 19 + .../Cells/ProfileTableViewCell.m | 21 + .../Cells/ProfileTableViewCell.xib | 42 ++ .../LayoutOnScreenControlsViewController.h | 36 ++ .../LayoutOnScreenControlsViewController.m | 358 +++++++++++++ .../OSCProfilesTableViewController.h | 24 + .../OSCProfilesTableViewController.m | 158 ++++++ .../ToolBarContainerView.h | 18 + .../ToolBarContainerView.m | 26 + .../ViewControllers/SettingsViewController.h | 2 + .../ViewControllers/SettingsViewController.m | 77 ++- iPad.storyboard => Limelight/iPad.storyboard | 228 +++++++- .../iPhone.storyboard | 489 +++++++++++++----- 48 files changed, 2474 insertions(+), 176 deletions(-) create mode 100644 Limelight/Images.xcassets/ArrowBackward.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/ArrowBackward.imageset/arrow_uturn_backward_square-120x120@3x.png create mode 100644 Limelight/Images.xcassets/ArrowBackward.imageset/arrow_uturn_backward_square-80x80@2x.png create mode 100644 Limelight/Images.xcassets/ArrowBackwardFilled.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/ArrowBackwardFilled.imageset/arrow_uturn_backward_square_fill-120x120.png create mode 100644 Limelight/Images.xcassets/ArrowBackwardFilled.imageset/arrow_uturn_backward_square_fill-80x80@2x.png create mode 100644 Limelight/Images.xcassets/ChevronCompactDown.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/ChevronCompactDown.imageset/chevron_compact_down@2x.png create mode 100644 Limelight/Images.xcassets/ChevronCompactDown.imageset/chevron_compact_down@3x.png create mode 100644 Limelight/Images.xcassets/ChevronCompactUp.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/ChevronCompactUp.imageset/chevron_compact_up@2x.png create mode 100644 Limelight/Images.xcassets/ChevronCompactUp.imageset/chevron_compact_up@3x.png create mode 100644 Limelight/Images.xcassets/SquareAndArrowDown.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/SquareAndArrowDown.imageset/square_and_arrow_down-120x120@3x.png create mode 100644 Limelight/Images.xcassets/SquareAndArrowDown.imageset/square_and_arrow_down-80x80@2x.png create mode 100644 Limelight/Images.xcassets/SquaresArrowUp.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/SquaresArrowUp.imageset/square_and_arrow_up_on_square-120x120@3x.png create mode 100644 Limelight/Images.xcassets/SquaresArrowUp.imageset/square_and_arrow_up_on_square-80x80@2x.png create mode 100644 Limelight/Images.xcassets/Trash.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/Trash.imageset/trash_square-120x120@3x.png create mode 100644 Limelight/Images.xcassets/Trash.imageset/trash_square-80x80@2x.png create mode 100644 Limelight/Images.xcassets/TrashFilled.imageset/Contents.json create mode 100644 Limelight/Images.xcassets/TrashFilled.imageset/trash_square_fill-120x120@3x.png create mode 100644 Limelight/Images.xcassets/TrashFilled.imageset/trash_square_fill-80x80@2x.png create mode 100644 Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.h create mode 100644 Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.m create mode 100644 Limelight/Input/Custom OnScreen Controls/OSCProfile.h create mode 100644 Limelight/Input/Custom OnScreen Controls/OSCProfile.m create mode 100644 Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.h create mode 100644 Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.m create mode 100644 Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.h create mode 100644 Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.m create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.h create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.m create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.xib create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.h create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.m create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.h create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.m create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.h create mode 100644 Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.m rename iPad.storyboard => Limelight/iPad.storyboard (67%) rename iPhone.storyboard => Limelight/iPhone.storyboard (64%) diff --git a/Limelight/Images.xcassets/ArrowBackward.imageset/Contents.json b/Limelight/Images.xcassets/ArrowBackward.imageset/Contents.json new file mode 100644 index 00000000..2f5e927f --- /dev/null +++ b/Limelight/Images.xcassets/ArrowBackward.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "arrow_uturn_backward_square-80x80@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "arrow_uturn_backward_square-120x120@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Limelight/Images.xcassets/ArrowBackward.imageset/arrow_uturn_backward_square-120x120@3x.png b/Limelight/Images.xcassets/ArrowBackward.imageset/arrow_uturn_backward_square-120x120@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..00ce88659f305b64f29885748bde7aff073e6ad1 GIT binary patch literal 3777 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 literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/ArrowBackward.imageset/arrow_uturn_backward_square-80x80@2x.png b/Limelight/Images.xcassets/ArrowBackward.imageset/arrow_uturn_backward_square-80x80@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..71ea3c22098df6b1eb694c4da76c7d5cb04e3e67 GIT binary patch literal 2017 zcmeAS@N?(olHy`uVBq!ia0y~yUk44ofy`glX=O&z&_j4 z#WAEJ?(N*tH!`jgZSxEE91ux$UUK&5Kkh%gHJo!; zZRTBx7i##-?9PzQ9K7AKbCr$WjC_XYjQUsgR|y_C+PJ!*a@D3!bFM2+(7Mjt5IwJo zVLAIdX0bDs7gm*ix89&`uubm3%7)VoS66Mj#kcl(dP1=NtXXVT_hN4yN?7%N0#~@l z5v8|5xiPPf9(Zw!`8k8MIpbsg2mcoAiu-zT_1+~5x{j6{*mS_{V2*cm*Y?i^@}HK~ zvdyr(6y^Il$3`@u^ta=Y)b}3*ugvJ#^3dvlt7dN=tKYf>$4rA+HcImKcos?>yXkTE zme!@)<_FRaTtDl!`o;Hm=cF5+D!-Q2Z}`q|+C|ES`%PS6!6v=z;s<9Uk{N3(pZ>At z-C_6ILp0Z1`$LjcL;lh?ADJ7DYjOE#pT5-VU6OtEKzVT0EzydFQzvTOGTY_N&XD%| zC^zQ=o&#D3gbqAE@R@brTW-z;>n4;<+wG7X`#!CkAi@4QJj26NLRvYpT{nF1! zx>C-+iPK$+r{OY_I>W6n&!j8m`{Nc)G0Ib9FlkS6Dc?J#Q%caPp5eg}j*3JXVH15Z zBcoj#jXzvuIKU|w@Y#=Ln;+w`H`5<(S>#q-wXJpQJ>D=5KcCNbY~L8|G!{I&y6lQD z!(Ib7ChMgQ>KWA{S4vj?x2oqq#`|84+syk!CeYP1oj558?)DK^q z#u&i%au(aE1Pg&1+M9Bh`rQ%ad1>@CWEazdTV*mEa$P>NN3?KhPz88;d{ut-*s6q0W=hQyt71Ik#8+V$n zH{a+voAGub!`y~KJI5(f6IFr^%W=(Guy)1rNAvF;T3ZnGCi?%akI$H&o%-=-^}*CX z8zrMS@0YVW95-dnP`af%SLHqvJLCE%8C4#~CrfN_WH9Ni(qTSZ5V<zq7=(|p)>PZ45Q9mqto1s_G`18 zH@B|-EA^(^Rf_9ap6RswY7CcVwyY+er1{ura8RGoAW1DQDs_cVC1a~=~a!MQT1h~zU95y9l&5^ z`PRAe=Cr9-k2I`i)tT2?=_%E4??Pnn&qItF4)Z#V1hhV~>*m#!n8sSJLxD zxH-Pw-Rz;rSn`NX=*xWGnt#($Rdu&rTr1*m{*asD-vzw0T)DUtrk&h=IjyxIP=!^( z=hZN{4$?>DcA%eZXLFl&YRwe&02Vl(SlELbj-p3$1h9mM+f-qRMgWD>)yDN!aVOxJl%>ipML%ev#)uyAwO(hvrzf(ve+OSKIh zc)BBx&o-T{Uf~wU#~&3D$-wIR*z9C+M(M?`Z+(}`U2{8IoN02wX}0k#$5js_&)YDs zI;ObUZT{poecp!MPejfc9r~N-QMhf^Asy3(D*mFJP4jX+yG1RMYxgB;=wGYPGzoCn z#{A9K^UU?;G#2-!rCU{tSmyJsX}6gi+!wCJkj)_ZZTkO98hQSY+wDsyE{OADDBk0J z>s=N@!fXLPhBqn=@0Z-EGMYIrtN4SKjo$VPELU9??fej>ax^1Z`0gi)Gu-BIukx1BKY`Uu*OxJ7)Lr|YuG%?u%D!Kk>x&vUF=l+6!16UBrTWC9`-~QL zn~WAqxRuY>xP7X4*7_wH{To!OuPE9H9=O@eDzCjW=yxA4%f~s-Lc8)({C{*YCHEOh z27k6xy?J7Kcjvl^ii$It#WuWfnz}N=;IzJ)SQzgM#+e&8m*_65{FYvl?!tTE?cv)d zvv@R$4cG*gr^sWZp$ZiH!o r^ndG~?ToAM_m<37^Dw#nkNxlcvuTT-mo8^uU|{fc^>bP0l+XkKkF%v- literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/ArrowBackwardFilled.imageset/Contents.json b/Limelight/Images.xcassets/ArrowBackwardFilled.imageset/Contents.json new file mode 100644 index 00000000..b926c753 --- /dev/null +++ b/Limelight/Images.xcassets/ArrowBackwardFilled.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "arrow_uturn_backward_square_fill-80x80@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "arrow_uturn_backward_square_fill-120x120.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Limelight/Images.xcassets/ArrowBackwardFilled.imageset/arrow_uturn_backward_square_fill-120x120.png b/Limelight/Images.xcassets/ArrowBackwardFilled.imageset/arrow_uturn_backward_square_fill-120x120.png new file mode 100644 index 0000000000000000000000000000000000000000..e5e5a9e4af880a6346556ca8d08d5bf552621651 GIT binary patch literal 3195 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 literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/ArrowBackwardFilled.imageset/arrow_uturn_backward_square_fill-80x80@2x.png b/Limelight/Images.xcassets/ArrowBackwardFilled.imageset/arrow_uturn_backward_square_fill-80x80@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..412403dbf0947c3bac5f41c64a63df416852d9f9 GIT binary patch literal 1707 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 literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/ChevronCompactDown.imageset/chevron_compact_down@3x.png b/Limelight/Images.xcassets/ChevronCompactDown.imageset/chevron_compact_down@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..faf03285dc3ab7da5e3fd562b661a77664947868 GIT binary patch literal 1216 zcmeAS@N?(olHy`uVBq!ia0y~yV8~!#U{K&-V_;zTCvLldfq{XsILO_JVcj{ImkbOH zE~ycoX}-P;S_}*f91M)@nG7r-B@7GdNM zQ+oULx3ycp{n}?dS>1M$CChhbb)ClgL;<}+kw4h}H3xl~*;pBJnN|7$>kQud#?Lhj z``SDw|83rK*!@CQipWmSUxgN2=5Ct7YRk_b2-&pV!F)}#dV-0i$FX*;6|8=TnO3;H z3w*(}V#WD~@*1kOY||$HReRI8^QCM1({C#llt0{?c=D=y-O?AF7Cg*X_nRqt*`3wc z-rV(4;&DfPxT9ROTw&zXXsv?qTDEyhg+s%P3i>z1K6p9BGbBLX=X}WN57YmvRB`n= z{Qt0~P2`!@LWlLmYiq_!!^F8 zBEla|STnaT*givT(bZo(x^LM;H!pe-7CPa3!TK8;kDP9q&ZRFNXEe)k&hqI8m2LCb3`7ngR7^O1vj&C3tAC`EnTo-e)8XqT5A?xJXH5UzuC)@z1~?-$l1~JxM`Dl zvv_ya4H-}SO*Z+Ti;{f!(pIjxRi3P+@#eCU(%lXGVr`PV64xqxOx3#I6z>(8(&Ksh zZ|H~Ur|&Lp$`|=Lu}`M8>sZP}*>&5S4sEpcc2T}D_o`T$3s3*a89JNldzUOIm?P_Q zC-mRtR=I{ns)lO*7be{6YMba58ert8%d%x<$hB1}hRf6Qj-~kht;{j)UAJJvW$n%t z8luyVrcT~%C|PFozQ9;hLvpwKym?J`AC&HudjHFR%H^-03~Ts(mCvqNP*C(-;N$WM z;SXFRm~KT|sqSy~e&8=!8!+|zjH0Dej04U&STaB5IWn{Hcc9h!wDX^LwSh_xPgg&e IbxsLQ0QK!AnE(I) literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/ChevronCompactUp.imageset/Contents.json b/Limelight/Images.xcassets/ChevronCompactUp.imageset/Contents.json new file mode 100644 index 00000000..2e6fba06 --- /dev/null +++ b/Limelight/Images.xcassets/ChevronCompactUp.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "chevron_compact_up@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "chevron_compact_up@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Limelight/Images.xcassets/ChevronCompactUp.imageset/chevron_compact_up@2x.png b/Limelight/Images.xcassets/ChevronCompactUp.imageset/chevron_compact_up@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..1ecebbe46c920b206de4ee66bc0636f1280224b2 GIT binary patch literal 833 zcmeAS@N?(olHy`uVBq!ia0y~yU~pq#U=ZP8V_;w~$ndUUU|?V@4sv&5Sa(k5B?AM4 zOKOB?ny)W|76StV2Loe!CIbsd2?GOz6axq_FJNR~28%E{N(I!v0DEShK`>Nuq>?5F4-M)P$sJH*TX zIjnY>V_xXt?!$++s^%@=5KWQX7$>ydyT8l&$@Pf z>gu0M?lWa4GS;*{FA&c=l&<0WRW&77AzfOgA+2MzWAM7ek{4FYH^}PJUe&=ACiq3+ zO>3s6@Sc=CY;#-xO({0!)xR}EGGr^iNs8+-uK#nsG+(xR!8m=^^VSs{$vrojW^EK! za~7=fzHsmU9mt{@=_~nF z?D_S!F0bS|nRP~K(yy?IJ1e|?I6y5>A< zCr|%X=XDkB`c0=V{?I#VDd5!1vXkYRLAa9Y&%)E4B^))o&-jRJ{kDLy&87d(WWl!G zD%+bX&7D@LX6emWDz0ydI4Ws8RkHfP?(J%2WxKfZCVf9(UC#TaXmW;Jv(-ZGH+{}0 z?p#aIy=Jne^3QDv-w%3Q8=tiV*gKwUbiJ`6b?VkUhWu+~GdL7Yl0HjFUUq6*d_hD> z*f3{L&H%*On`BD1OfcPber`j#)MK|QDK&?j|+>#Qw$u8PY| zo*BrjG<+#|Jyt8vui5li-__IJ8a~Zd31&x5`tFELu6aARC2YkM-V3Z3CLEtC{_kUQ zywU9g+m2lSZ_%+t^Y{YyWTo6k2adnB-tqoUlj4v0S5NeRR<6+GIS^XGSh}B~%d~2W T8mnC=DEWH2`njxgN@xNAnQvP1 literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/ChevronCompactUp.imageset/chevron_compact_up@3x.png b/Limelight/Images.xcassets/ChevronCompactUp.imageset/chevron_compact_up@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..fc287c473f78ea615e7b6a513a87c2a51c24ca85 GIT binary patch literal 1200 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 literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/SquareAndArrowDown.imageset/square_and_arrow_down-80x80@2x.png b/Limelight/Images.xcassets/SquareAndArrowDown.imageset/square_and_arrow_down-80x80@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..aa21d1b8c6f17faf900a0a3a05b880ad82be36c1 GIT binary patch literal 1495 zcmeAS@N?(olHy`uVBq!ia0y~yUk44ofy`glX=O&z&g>> z#WAEJ?(LjNj`U!eSKohmFXp)A_kZVX|No!+dH>wcRp-C`zVqJp^!q>O<~r|t zS@MA8f%yZ!2TNskTd5!SV-#nYZvN|=c!A)Dq`bdpMIW3#V8ebb`oK)q1-%EX4}|$$ z>u-3Rd$j%Z)YVMf2V8hX8dwvQ8jn13Z6uzSX_Rev+jzF;=rtz$txw)AJ|JuBcOyX9 zo;jZ3zSM8&GuN)m?@%w0-5|X|cY|k&d&xaTkA+v*A5m<{Me88c$yaqz7rH zH*5+_esWMuHHy)W{f(7omBTxR)K`7(EH%VzQ^={*8^$gC%+s0)onUum~;%b z=+EAoWpTzJ`~BU}`K%9g4-__b-DB2cxmIc_@^jC59hsX)e!bbv&>yPJ8UIte@%U}6 zZ45PgqaN@4nxR{sGe1uH6c1}atYGu^#$MaMueMxd3Yh;)Y~CsU_nY&K4g^nAWL&lP zZ$rCH?Qbaylh8)_ihJkQA7E@q3R^a@jZLG`LnH5(poM|x0w=AQrRx~Z&0yQP>fA!c zQ+99N?#ND^W-R1o5NY?Y-*&0!pW=TV45via7H_?myDYxpFSo<(*?J7QbDDN?9bozp z&*bc{l*;21690{1JM%iGGPY^V+sh^HxHm>M=`cQdbM@BOvP^*){c;`8OENoDn#}tkp>+ORDkF0;Lzb>~`vE(@*!uA3E%$o`His^_ z^M8FsnenFAdJo(jj+spsec*P$cIxNLProo1Oc0Fwmb~EDvdv7#?r`0CX!CicqugX+ z>7PsX+}8TOJ9_K47g?VtZe*Au+ugXCnJt$8&g}}dqwk(PDgAzbmP*Zaxg}{^MVa-h zS+D8nvFUL->=CP2Q1#5(se;#HdsQ8Cf!6{#%Zm7pfVjD)w+%0Bb=}@Ot%~PL`tqfH zHV6C}t1T;8TnpCa&5Skm*>jWm-WiPz+unva%wL=TW|CK_zMtQ@v`cH&Zl3PVEPN@G zasIri&(COV;0(OTYd8JY_XB2qPiKX0=W`I>6B-uOSYyl{PNdxaAFb0&?b_F~?wg&iCK@4KxFlWo2AgB`oyCp*za~gsR==~w zb)m?2$p($Grz`rJE1gaMpEkP8wPx+bpNE@uuf6VZYP6sKUGYj_ng~Pc;zbAK*tVR{ zRXcm=*OmRVnfA<_=Tc+C5*WSv*R@?vXGP+aIxc%H4ou+nu(SKS)j55$zkhes;(Hev zWg2eQ&(dHJ?i437cji~0OH>nj^qSi!{6*`!>|WK@cU?XzC+?J=b&N~2v|nC&Ht~h{ zX4MbtmR~&gL&|$f-HSzM1%p5F@bmh=eQ9*|?z}ejqKj%JSH1H}9c7$9)uwJ*{dxOh zF;+chKdyjD%Om{!?_Mf#zEZ@#TABIpi*G-t6=xp(XmdNQgEb+eCga*_52jwlNj2{j zSp6o(?>e+K^LX^U6Q$u>7jj%acdBT5Ov?X~BdabVDNPHb6Mw<&;$T2_Ojvt literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/SquaresArrowUp.imageset/Contents.json b/Limelight/Images.xcassets/SquaresArrowUp.imageset/Contents.json new file mode 100644 index 00000000..0ad5a001 --- /dev/null +++ b/Limelight/Images.xcassets/SquaresArrowUp.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "square_and_arrow_up_on_square-80x80@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "square_and_arrow_up_on_square-120x120@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Limelight/Images.xcassets/SquaresArrowUp.imageset/square_and_arrow_up_on_square-120x120@3x.png b/Limelight/Images.xcassets/SquaresArrowUp.imageset/square_and_arrow_up_on_square-120x120@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..de7aa00c916100f84be69988e457b9676dabccae GIT binary patch literal 2834 zcmeAS@N?(olHy`uVBq!ia0y~yV5k6L4mJh`2Fnz)OAHJQjKx9jP7LeL$-HD>;5y^! z;uum9_jYdOncCwL4fCT`Wq4?+E^=ATu}C!gir-pQ4Oj0es}`&(%X)3^8mfCS>edR= zZCN@CFNU$KT)TDJjV>|Y)hmR4>-nGgyYpw&^EsB!&)BA)`+IhF&Ch>3iqq~@&NnVj zt9(9JcE`e^T@LME-xKdHi$YY-aj!O=G*!+P$5zy#LC3`vd6=;cNT4 zmfV!DkS}=KX4A)FH}_d ztXYy_B{@Z#^~2SM>&)M?8?#rJhTJjvQMB^X&h-WUjCr>vJYU^Vp1vV@W8@Tuisc*D z?a6PRx{pJK?~mn@kJ7acDtn8cZ9be|&8W_7o^j%L#e*eDoV+19SqH8&__2P=S<$}R z`CRheUAD3jbCOn>+Md8UovBlXoc_t^SZ?;UI(Q9Y!Tbh*CFeBTUWpP z?yFcOl{d%EmPh6Z?K>IXZ}a%Xop*nB^e(!c7+Ws;wJzqy`QE30ogV}|2-g;>5xAlL zX+iZ7`G}9V=Q2dUI>$S;(%{47(vY%MHp_Dku4j{zT=~N_@$b(=C*I35eS61o;|V`Y zU+#-_^SNpgwIp6^lzSamr?vC;=Foo}HcUS%om+o1nRzb{EIYdT+hl&0eOvjQ{ii;k z(h%EuYu6%`nB`v6w@ajOB)ra%sP#UIw*|Gn}?ie|ajfv^W@2M#7K_!{d_ zplKKC%n-efk9j-04Er0QWueb@7RD(AGr#*ijgR%t_B%Ii!x_)>MVhy2KgjzwEA|yv zMcIj6nlX&!o3FnK3O}&f`I_&;Qid|dgZ9gnVn3H}jQ_vVOwy_=Z2hvc?GZm;Ef4Rg zUl%W~AIo?|t7Pql$B+5?e{ZnXb9|S?624w;@`lyMA|KWraBrTs@)zfI``XcF6 z+cnJIb$p@dEtUhf4!nz!-+XeZ_JilG!c4D&YJwSVyH=~cdi~lcBx_$RgLOxd@twQV zOHVFixMn9ex$@C2o~g6)#Sir5$-0)9ZG?ScPW zPoBSfV&2YjDPk!5dt-%JvX^H=?2n&COFr>!d1w4+`D?DMCk!=y&zf&(>U()MT>Wl6 zu{8Jj;qSc`@0VXGE|?|L5Svl|DZzJFYJgP2@|JmzyZMCk&#Q@!t zS={H6kKbzAeQV>k2kXS5=kD9~z@}`C?vB;6+1&54HsL?k*5*Hd)iaJ~RLJ;f;E`vfpv9_nrI0j$cA0?E+UK>m5(fdB)9s;}!XXDd)3SU;nAh~rG&G)V_Gi5m)AD

od3S^?LIwf9j%0);UQ_znSc0j9wI2+}kTV zIeN?LQ1(boGcV80dkgg+8+h7&%ST^h-I2+dx|ossLUr?8 zeyj45OZgsc<;(OKQtFts9-Lv*)!ogt!$fK3*Od*%(p$E?(Ry&HBjwt1rgJ$LE^S)f z;H^9zMRR(3Q=6SVWcYTSx^K`o48zpW2#+QrtdhKby zSa4|iSDo!0Vr$;9Ge7^f=ExoS&kZx!kA`)A50@x9c;bES5{U!p4DMf69y~LDWAwF$ zaq53UlN^lo-@mA?e7d5+mgDKptD#BXYf3D>TrJ!9?u(4x2OXOWg=eoTxvdc;tNr-- z$;`=)F72mO8bW_5JP>u>djG)lzxUqp&zZk6Z!+VyulLw2>BJm+=@QGC-3XI1M6IsPpqD^_AAqaHv;yTc-!w7Sw48mCCPj2=R)1z#}xlP z@{@g>Xw|X1^oZAe<~uiLc+GjY^W8h%)%Uc1y=BZkv$9l!t6vlfmfN|4R_p4kx`md&a@qtL)wK&kue7-Bd z?S*$&+|o+@ep2eNpReI0fBx(T$u9z(BzJF#S!nEiz)Ivr#0IZA#y8t#Ex*6){W-18 zJa<~dceXuut<_FW51ev=h5gloMSarZhBH+YrdyRh=w?0mFR(yb>XEA9g?Ec>_VItv zJFxU#=py6!+5gvGdU~$y+8HC29ccyobBo$7)$gP(@IJGw)8@;eEnk1}_MNnD@{h>= z>&?98P=3zNpkpT65)S{G&l+P_%x*6!@t~&sfkVQ5v8-JWUis8#YTxa%w$=Rk)MIh+ z?duHvEg?G>vC8D^vYq|pOhgj*Hm_ax%zjGeuYd4%Cm(P11y3^#(GBZX-kbAiU(wY6 z(GN@>tk7fA6x~o-aH@o9fqmK3|CN0fzHdqvEJ!)vbeGpcbVuF7$E}8OHv6~!TD)K9 z>+aoaLS8mpz4|%eJ4gC_mc6!}+iSVMEix#65%b-WAuc5A_=ELJ?@tSV`9?76$GL|6 xXG3jvJa;S<*VkjK6X2`ceX-!-1+Ty2vvRIXKc#&yoPmLX!PC{xWt~$(69Au%dUXH* literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/SquaresArrowUp.imageset/square_and_arrow_up_on_square-80x80@2x.png b/Limelight/Images.xcassets/SquaresArrowUp.imageset/square_and_arrow_up_on_square-80x80@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..85e092db23ef7e9d69b05b38d6066b63bd70128e GIT binary patch literal 1616 zcmeAS@N?(olHy`uVBq!ia0y~yUk44ofy`glX=O&z{ch2 z;uum9_jYdh30+fx@P!9d$Is0&*7ccWHd< zQb=-tliTFe z$o&w#!T6)jrubLpyzA?uOV*1vsy7^+Gw~byy*|0x1B}NQG|pEuW?b6K{^4N*&!P}s z-h$Ex7x}vXGx5#QooAtPptSKfQ{6k|26g6J{g+BK8nl`Hzs`8L@IbNcFWzgZ91XJ> zw@VpUvE8`#^N)W*;PcIo8-BAZ3$4&FKd`n@nrF5cgFDl1>-%q6Z1QXp{MhsO=iF8} zTg-GvV8`hhNAnIGKj3>{+PUwqnSH1Kt~^lM9KabciTi=wfun7*$C>y;=ildElg@C( z{k2KVw&~2%S23ZA*L|ND zCrr1oIOKkoF(B+k&{T1*XRf?yKbbwL~)VfZyT;rhI?>eA1Q zD*`56TUMLO!@uVCFW;?+O9LW{UNXH{$Mj3Wz|7(1nt&NjNhg?-qW_A_)A4Lq&P30P`$$Zg;~Qb+v1yM`}*-8h7;039(oO%-iACdIDSI9adWUlocvA`Nm|y4kB7va*91#2R{8?o>ZGe?Z$UTh;jG z?(dE4jnkcHoKxlJdAKO_8mnEt3~PdMm~P&pDOQ_>)OTJrja_R!YGU85EGl01N+|4%pS#=? z;kD(uu9LP(d8leKzA8ER@$$3AP44~&OdIrVzJ)m*JjdCvFu>`Sc!T1Wo^7lNj1ry- zo2oxTlgFb*)&!Nt6Yq{Pb06?vns~RHU94dui_&g!{yA?M*1owi<)rJx8mry=pFa8b z;ytrf_21bOUms(xZr!}*&w;s(=WTl=cAwO8JIa6m&w&&Mf2NSCC3^9UxBGpMJMT~Z zefB`4TCB=V?u6vNAVXyGglVYvP=3y7J!)tRx*?W}Uh;UHu+g4a=X0i6QUQ zZi!v^nUcC--PHwWzWbky(PHsH{*uz@V2Ro4J;3w{E^L69>yxX+s|TYi-6U%AcML!=XR_( z6SpFT*}~8zdg89c*PEtoGMi!buyT`eb8y3Vr}U>6|9xL2(Ryy<5t|#En7_<8UGH0B z@Z4*8v(b_`e#w*v+gKA+J}`aw&-WoAd83@=^CfqepJHD7tI_KcrzY#HDSgKiRy}_z zcIv{a*F3B9-(45f)>>4)+16IgC`@?j+u)!3c4=@dOgO@Gk~e^Bt#SEn#^!12AODz6 z(ax3Ka#1pK?o>I}1=2UB2v+%D);>3Vp0MvVH!@9AXZ22|NB)_u?u? zL0Q?YQr;|nmR(`F95T`e@cKsH0x9xgSztHwW~H&FJ_#Qr29AIX3;j!^xXf7 zHU&(+2RU3fh0kIY32paYx_Hgyx!g1DRSt9}sZL*A(xr6#*Ls6pOhP*=U)!x|pEYmO jdgTe~DWM4fg$?ls literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/Trash.imageset/Contents.json b/Limelight/Images.xcassets/Trash.imageset/Contents.json new file mode 100644 index 00000000..67e1d21a --- /dev/null +++ b/Limelight/Images.xcassets/Trash.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "trash_square-80x80@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "trash_square-120x120@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Limelight/Images.xcassets/Trash.imageset/trash_square-120x120@3x.png b/Limelight/Images.xcassets/Trash.imageset/trash_square-120x120@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6e26af8271430a366f5a3a7e1386bfbafe79256d GIT binary patch literal 4387 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 ZThigJ?_LG8FfcGMc)I$ztaD0e0s!D37l8l( literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/Trash.imageset/trash_square-80x80@2x.png b/Limelight/Images.xcassets/Trash.imageset/trash_square-80x80@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f91ed9b300d7d00c323d1f1469f607321e6fc8ab GIT binary patch literal 2382 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 JS?83{1OVU=Y}fz* literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/TrashFilled.imageset/Contents.json b/Limelight/Images.xcassets/TrashFilled.imageset/Contents.json new file mode 100644 index 00000000..d442915b --- /dev/null +++ b/Limelight/Images.xcassets/TrashFilled.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "trash_square_fill-80x80@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "trash_square_fill-120x120@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Limelight/Images.xcassets/TrashFilled.imageset/trash_square_fill-120x120@3x.png b/Limelight/Images.xcassets/TrashFilled.imageset/trash_square_fill-120x120@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..ab7ec37f7f481fae65f0b394b6e60d46bc178034 GIT binary patch literal 3974 zcmeAS@N?(olHy`uVBq!ia0y~yV5k6L4mJh`2Fnz)OAHJQjKx9jP7LeL$-HD>;CJwJ zaSW-5dpno?LyD{5G5a%?3z@XiSbJuinp131e58RfP_X#i3}ZF}?W~!BXBJHpWG!~+ zNoR0m6)8T{aqWHl=J3OJ&gAXio?rIs?cR02uE)MTH@oukeZN=#cBNaFPB!+in!IGQ z=dP9|cU58oF7-`1C%SL(nXx=C^hFKEJ=l$qZ}9=cWiyzIU4))+_T^%K6hF&vBf`Y!W% zjN{SV)kl`>)MtqIykxy>ZgI4U)|P+ap0gQ#*d=zYyKpQdHJSGf>w!1h-d*_Ny6oBO zjD&iIw5nsg7AJpYK1g8rw`IxP>+AD_`&DGw4#dYhzZN~yvCe2SXG!h3;-AVduav7g zZkAl_koa@b5@n{XPro^d-N&tg6qp zXCWlNaoY`BT}X? zBv&3jcgtyAH{*WS!?w3&WDgwo+%;RJ?388W6n^F@``4v7p2+^t^WUO9!f&rd+K2i5 zj&AH9)SK?wY~QGK>yhK6g6eM%^NZK~TDruX^%l1v+sX@U%VPgLPo8j*@y|C0AB8Q- zqRY;Q^EyN|d@AZ^D7(crd#!rQd-VqGjE0L}y*wq%gDN*Hap%2~mN_?_vtfJL{>ED? zW6!>DGmxIc%*9|IbIFIa<0kg zzSLvho4<0_RE8ey_hyrRSv=JKzCZITi=>R$n!K%XDXI+h4wr$Mt$jC35-NjmMHa|9lwPyWPMjQYCPo`H~ z>e!jMbRXlRVwKpP5Btxv9+=bn@dCpatCLHe70h_fhCO0RFxoLOyy=h7YZVFO53B8h zgvfKB`lcu@;IDfBb1i>}#&f2G7v{gctnS=!-1I(?an{FmCr)^say84ge8^tN?djh) z<*%}*KQG^epv~K-eL3_(<1?@4_jd_Js&86NxaL$wcXBsKx(b}ycslBdUt+n`^x7y3 z@8|Q5Gd2b?KKgZL#fRw|>^?bba{keqwBgtx9fy!sSBbwH{G-yImNFWgN@5W{mi5u( zVdKLZN$#6JxwKrYi0&H@znj@j59R9$GUA}50*D{+Gudq zt~#N?ezHvAj97!Xh-rdO{`ulOvQrJ`MDLYuu+$gQ&Dh!9pv`(|>ttn~&X9JQgBO}P z*yeM--e&rtXT!|hUboh(?y@M~bA;XasnhF?KJ`A68Kk)DLf0fZGyOSR@bknPzem&7 zrLy$&irN*-(a=50d*Jhn4J(`UL}i|Se10ePfM1PALt)(!nf39lA*aO-+_2HHFJi|ZFaFjouOi!6Ti*iO>QiXJvHt2 zbD17VldZj*)!uJ>@$}I5xJ3eY8G1yym+x0y$R#Z?hkIi}d)qAQD;)bAPCO94etg|6 zsgh$n4L`3~J?h;*``pnx3_YJdXhg1cS<_$0Vqea-wCwRrvtK<4d6l<4I^{X-owC0u zeM>+0?V9OsV4QC)=Cjg8Iie+%<+POe zoGl{e48~74ajV(4xdth-gzrvdkmO3}D2lk2G&9}D^Y+su;h5HI#haI&uRXPcafX9_ z*}<@Ind>_XU%&J*m@wr;Ztb#+rqdhN95p&GGfV6LoJn!FX9S+K-pM%QiTVyk@0-Oz ztVXeMM!y7p8(hvhCw-MY!N!q6g!`C;MVOdkR^xA$%H`7@u_b62?qC#;n=ZAb^?9du zUh>W)#jVcr4f&dyUs=u;doEKr^hkllRCVuqsT^~LVw+u+59}hROz=*MON#!&D6vm` z%JqnXjwNwxF6}PrOFP>)UC?`fo8Du#ghzEV#cuLOv;5b8(-pBUo}>OHv%&TQg@F+| zGS&CeDt8u4KI?vH|1tMfBE`%Gdmno=I^I{Z+$UnppzZqgAP^e>Wkm$ zneVeF@R&Qh?lZ0goj}#Jlv~-$PWzs0x44n$&Bs`v_gw6qhtDdpgK>VVIu{!8Mcg_w zwUE`oQe)@rdrB5+LAQ%v{T`vwe>3o0ZF}%0A|V6f z;}qp*JGbs&`tfm+UCI>>`?hw~RK@L^H+nARKXCqhMU%pv<#B5+y?&;Xcx)YS)EUtO zJ+rR-i+-iMl8w`{cj*`Am&^u+t}E3$0;a{hTXV-+h?(`}-mN>BB;IpBkM7>^Zxi2f zrbGqtu1o9*2N;(`?2>tWE-X(FJIjUZ`_Gh%Q)LUkQvL`o$TY9CPyRHfx z?)w+H_TtwkIS%3)rl$PUN{-B&d1Fz8*GJD&57P##4%qfp1?_%QFkn-@R z-70Bmli$o`m&H8$Bd4WjO(|skVDl>bR^>8(8R3s}_cb&)C)_lxSiWK3@$JhWK5d$j z&nhwd*E&;y*AdIL8#w>@6(>#kbyy>rw{4!)w;(Yw%qZTSZ@DFDJ$pxwG-TR zHA||qbk{MsNiyVgeoFuKCt=g&zln*@y>x{6YOY=z46FLwt~R=tlIZ{5A=>$J3QMJzARx`shPu4 z9*ZYRic2O3x=Jp4affxfv8(em<%S+J4fljys_o+EpEn)PX56tNuAE`=%r&qu zkLUdUcB{eu8M*O|S{ zM;Odf<&MZ6sOX4N=Z_YfA+xmnrqyq|_k0QO@)cN)W(mEy{W0qN#%8ru8%l4ySX2Lw z;h6{1+cx7>s%sN<7yP=??KvfwLHS1W#Adr4%rmB2MP2$gY2Sr|MOWA-D9%^hEZZg? zBtB{3^3=W;JL2^ik1cSBQi&;hVE~QR4eDKP4V)J+|lN!KSBWU42u#Jq5dk z6_?K7EZnAFeXu0_L(x;oxe@1296tC~e}>n|?xPyt)TEUy&aQg(R(1LJm+KqPGW0B# z+5KAO_43BW3=-*PJ0;a4?RjPR4r~x#v+~t-f7aJ`7dtb|m@zMC*|o3l{{8y?j)CFa z(Oc{4uW#>_d&hj>LduELFJAuHmb+qqp8iIzJf;MTV^8XDZOp36R<&RS)k22dCqmA z(;^pHZ)0JYGK1ZK`J~Npk9iH>cug)FmB&r^k-9o(uWEzj$%q{%M4FYGHh)stXL3NJ zb&f}#@H(jj>-UFNoV9r;aNp4}(PZ-UE}m5el4_aj8z(o*&)*`tqq=k2nXXnDwiOI} zy%nYjZtDDRu`g;}?ey#ad3RiYbi*LYGn{cM=PbkEl0Y4X^KxmrYWu{zG#<`q$#^4n z;J%{E?a#L9uk^mIvp4xBXY}@v>*2rymET-EkGIAeuIxE^{b>20sY;suS8x4iWDl*l VvZr#FKLY~;gQu&X%Q~loCIDdIa{&MV literal 0 HcmV?d00001 diff --git a/Limelight/Images.xcassets/TrashFilled.imageset/trash_square_fill-80x80@2x.png b/Limelight/Images.xcassets/TrashFilled.imageset/trash_square_fill-80x80@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..be3b2707832abd776d2be4abc9e8775b66904929 GIT binary patch literal 2201 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 YJk literal 0 HcmV?d00001 diff --git a/Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.h b/Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.h new file mode 100644 index 00000000..85f36da6 --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.h @@ -0,0 +1,35 @@ +// +// LayoutOnScreenControls.h +// Moonlight +// +// Created by Long Le on 9/26/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import +#import "OnScreenControls.h" +NS_ASSUME_NONNULL_BEGIN + +/** + This object is a subclass of 'OnScreenControls' and adds additional properties and functions which allow the user to drag and drop each of the 19 on screen controller buttons in order to change their positions on screen. The object is used in the 'LayoutOnScreenControlsViewController' thus allowing the user to drag, drop, hide, unhide, on screen controller buttons. + Note that this is in contrast to the game stream view which displays an 'OnScreenControls' object on screen that allows the app to register taps on each button as controller input. It does not (and naturally should not) allow the user to move the buttons around the screen. + */ +@interface LayoutOnScreenControls : OnScreenControls + +@property UIView* _view; +@property NSMutableArray *layoutChanges; +@property CALayer *layerBeingDragged; + +- (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport + streamConfig:(StreamConfiguration*)streamConfig oscLevel:(int)oscLevel; + +- (CALayer*) buttonLayerFromName:(NSString*)name; +- (BOOL) isLayer:(CALayer*)layer hoveringOverButton:(UIButton*)button; + +- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event; +- (void) touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event; +- (void) touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.m b/Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.m new file mode 100644 index 00000000..ffe66a18 --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/LayoutOnScreenControls.m @@ -0,0 +1,239 @@ +// +// LayoutOnScreenControls.m +// Moonlight +// +// Created by Long Le on 9/26/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import "LayoutOnScreenControls.h" +#import "OSCProfilesTableViewController.h" +#import "OnScreenButtonState.h" +#import "OSCProfilesManager.h" + + +@interface LayoutOnScreenControls () +@end + +@implementation LayoutOnScreenControls { + + UIButton *trashCanButton; + UIView *horizontalGuideline; + UIView *verticalGuideline; +} + +@synthesize layerBeingDragged; +@synthesize _view; +@synthesize layoutChanges; + +- (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport streamConfig:(StreamConfiguration*)streamConfig oscLevel:(int)oscLevel { + _view = view; + _view.multipleTouchEnabled = false; + + self = [super initWithView:view controllerSup:controllerSupport streamConfig:streamConfig]; + self._level = oscLevel; + + layoutChanges = [[NSMutableArray alloc] init]; // will contain OSC button layout changes the user has made for this profile + + [self drawButtons]; + [self drawGuidelines]; // add the blue guidelines that appear when button is being tapped and dragged + + return self; +} + +#pragma mark - Drawing + +/** + * 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 + [super drawButtons]; + + 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]; + + /* reposition each dPad button within their parent dPadBackground layer */ + self._downButton.frame = CGRectMake(self._dPadBackground.frame.size.width/3, self._dPadBackground.frame.size.height/2 + D_PAD_DIST, downButtonImage.size.width, downButtonImage.size.height); + self._rightButton.frame = CGRectMake(self._dPadBackground.frame.size.width/2 + D_PAD_DIST, self._dPadBackground.frame.size.height/3, rightButtonImage.size.width, rightButtonImage.size.height); + self._upButton.frame = CGRectMake(self._dPadBackground.frame.size.width/3, 0, upButtonImage.size.width, upButtonImage.size.height); + self._leftButton.frame = CGRectMake(0, self._dPadBackground.frame.size.height/3, leftButtonImage.size.width, leftButtonImage.size.height); +} + +/** + * draws a horizontal and vertical line that is made visible and positioned over whichever button the user is dragging around the screen + */ +- (void) drawGuidelines { + horizontalGuideline = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self._view.frame.size.width * 2, 2)]; + horizontalGuideline.backgroundColor = [UIColor blueColor]; + horizontalGuideline.hidden = YES; + [self._view addSubview: horizontalGuideline]; + + verticalGuideline = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 2, self._view.frame.size.height * 2)]; + verticalGuideline.backgroundColor = [UIColor blueColor]; + verticalGuideline.hidden = YES; + [self._view addSubview: verticalGuideline]; +} + + +#pragma mark - Queries + +/* used to determine whether user is dragging an OSC button (of type CALayer) over the trash can with the intent of hiding that button */ +- (BOOL) isLayer:(CALayer *)layer hoveringOverButton:(UIButton *)button { + CGRect buttonConvertedRect = [self._view convertRect:button.imageView.frame fromView:button.superview]; + + if (CGRectIntersectsRect(layer.frame, buttonConvertedRect)) { + return YES; + } + else { + return NO; + } +} + +/* returns reference to button layer object given the button's name*/ +- (CALayer*)buttonLayerFromName: (NSString*)name { + for (CALayer *buttonLayer in self.OSCButtonLayers) { + + if ([buttonLayer.name isEqualToString:name]) { + return buttonLayer; + } + } + return nil; +} + +#pragma mark - Touch + +- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + /* Reset variables related to on screen controller button drag and drop routine. These variables should be reset in 'touchesCancelled' or 'touchesEnded' but these may not be called in unaccounted-for edge cases such as when the user opens various OS-level control center related views by dragging down from the top of the screen, or dragging up from the bottom of the screen. Since Apple likes to add new control centers and new ways of opening them (i.e. Dynamic Island on iPhone 14 Pro) it's best to reset these variables here when the user is beginning a new on screen controller button drag routine */ + layerBeingDragged = nil; + horizontalGuideline.backgroundColor = [UIColor blueColor]; + verticalGuideline.backgroundColor = [UIColor blueColor]; + + for (UITouch* touch in touches) { // Process touch + + CGPoint touchLocation = [touch locationInView:_view]; + touchLocation = [[touch view] convertPoint:touchLocation toView:nil]; + CALayer *layer = [_view.layer hitTest:touchLocation]; + + /* Don't let user drag and move anything other than on screen controller buttons, which are CALayer types. The reason is that 'LayoutOnScreenControls' should only be responsible for managing and letting users move on screen controller buttons. Since this class's view is currently set to be set equal to the 'LayoutOnScreenControlsViewController' view it belongs to, we need to make sure touches on the VC's objects don't propagate down to 'LayoutOnScreenControls'. Weird stuff can happen to the UI buttons that belong to that VC (trash can button, undo button, save button, etc), such as them being dragged around the screen with the user's touches */ + for (UIView *subview in self._view.subviews) { + + if (CGRectContainsPoint(subview.frame, touchLocation)) { + if (![subview isKindOfClass:[CALayer class]]) { + return; + } + } + } + + if (layer == self._upButton || + layer == self._downButton || + layer == self._leftButton || + layer == self._rightButton) { // don't let user drag individual dPad buttons + layerBeingDragged = self._dPadBackground; + } + else if (layer == self._rightStick) { // only let user drag right stick's background, not the inner analog stick itself + layerBeingDragged = self._rightStickBackground; + } + else if (layer == self._leftStick) { // only let user drag left stick's background, not the inner analog stick itself + layerBeingDragged = self._leftStickBackground; + } + else { // let user drag whatever other valid button they're touching + layerBeingDragged = layer; + } + + /* 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 isHidden:layerBeingDragged.isHidden andPosition:layerBeingDragged.position]; + [layoutChanges addObject:onScreenButtonState]; + [[NSNotificationCenter defaultCenter] postNotificationName:@"OSCLayoutChanged" object:self]; // lets the view controller know whether to fade the undo button in or out depending on whether there are any further OSC layout changes the user is allowed to undo + + /* make guide lines visible and position them over the button the user is touching */ + horizontalGuideline.center = layerBeingDragged.position; + horizontalGuideline.hidden = NO; + verticalGuideline.center = layerBeingDragged.position; + verticalGuideline.hidden = NO; + } +} + +- (void) touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { + UITouch *touch = [touches anyObject]; + CGPoint touchLocation = [touch locationInView:_view]; + + layerBeingDragged.position = touchLocation; // move object to touch location + + /* have guidelines follow wherever the user is touching on the screen */ + horizontalGuideline.center = layerBeingDragged.position; + verticalGuideline.center = layerBeingDragged.position; + + /* + Telegraph to the user whether the horizontal and/or vertical guidelines line up with one or more of the buttons on screen by doing the following: + -Change horizontal guideline color to white if its y-position is almost equal to that of one of the buttons on screen. + -Change vertical guideline color to white if its x-position is almost equal to that of one of the buttons on screen. + */ + for (CALayer *button in self.OSCButtonLayers) { // horizontal guideline position check + + if ((layerBeingDragged != button) && !button.isHidden) { + if ((horizontalGuideline.center.y < button.position.y + 1) && + (horizontalGuideline.center.y > button.position.y - 1)) { + horizontalGuideline.backgroundColor = [UIColor whiteColor]; + break; + } + } + + 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) { // vertical guideline position check + + if ((layerBeingDragged != button) && !button.isHidden) { + if ((verticalGuideline.center.x < button.position.x + 1) && + (verticalGuideline.center.x > button.position.x - 1)) { + verticalGuideline.backgroundColor = [UIColor whiteColor]; + break; + } + } + + verticalGuideline.backgroundColor = [UIColor blueColor]; // change vertical guideline back to blue if it doesn't line up with one of the on screen buttons + } +} + +- (void) touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { + layerBeingDragged = nil; + + horizontalGuideline.hidden = YES; + verticalGuideline.hidden = YES; + horizontalGuideline.backgroundColor = [UIColor blueColor]; + verticalGuideline.backgroundColor = [UIColor blueColor]; +} + +- (void) touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + layerBeingDragged = nil; + + horizontalGuideline.hidden = YES; + verticalGuideline.hidden = YES; + horizontalGuideline.backgroundColor = [UIColor blueColor]; + verticalGuideline.backgroundColor = [UIColor blueColor]; +} + + + + +@end diff --git a/Limelight/Input/Custom OnScreen Controls/OSCProfile.h b/Limelight/Input/Custom OnScreen Controls/OSCProfile.h new file mode 100644 index 00000000..1161b12f --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/OSCProfile.h @@ -0,0 +1,32 @@ +// +// OSCProfile.h +// Moonlight +// +// Created by Long Le on 12/22/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import +#import "OnScreenButtonState.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + 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 + +@property NSString *name; +@property NSMutableArray *buttonStates; +@property BOOL isSelected; + +- (id) initWithName:(NSString*)name buttonStates:(NSMutableArray*)buttonStates isSelected:(BOOL)isSelected; + ++ (BOOL) supportsSecureCoding; +- (id) initWithCoder:(NSCoder*)decoder; +- (void) encodeWithCoder:(NSCoder*)encoder; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/Input/Custom OnScreen Controls/OSCProfile.m b/Limelight/Input/Custom OnScreen Controls/OSCProfile.m new file mode 100644 index 00000000..43612647 --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/OSCProfile.m @@ -0,0 +1,43 @@ +// +// OSCProfile.m +// Moonlight +// +// Created by Long Le on 12/22/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import "OSCProfile.h" + +@implementation OSCProfile + +- (id) initWithName:(NSString*)name buttonStates:(NSMutableArray*)buttonStates isSelected:(BOOL)isSelected { + if ((self = [self init])) { + self.name = name; + self.buttonStates = buttonStates; + self.isSelected = isSelected; + } + + return self; +} + ++ (BOOL) supportsSecureCoding { + return YES; +} + +- (void) encodeWithCoder:(NSCoder*)encoder { + [encoder encodeObject:self.name forKey:@"name"]; + [encoder encodeObject:self.buttonStates forKey:@"buttonStates"]; + [encoder encodeBool:self.isSelected forKey:@"isSelected"]; +} + +- (id) initWithCoder:(NSCoder*)decoder { + if (self = [super init]) { + self.name = [decoder decodeObjectForKey:@"name"]; + self.buttonStates = [decoder decodeObjectForKey:@"buttonStates"]; + self.isSelected = [decoder decodeBoolForKey:@"isSelected"]; + } + + return self; +} + +@end diff --git a/Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.h b/Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.h new file mode 100644 index 00000000..6a2c83bd --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.h @@ -0,0 +1,63 @@ +// +// OSCProfilesManager.h +// Moonlight +// +// Created by Long Le on 1/1/23. +// Copyright © 2023 Moonlight Game Streaming Project. All rights reserved. +// + +#import +#import "OSCProfile.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + This singleton object can be accessed from any class and provides methods to get and set on screen controller profile related data. + Note that the implementation file contains a number of 'Helper' methods. These helper methods are only used in this class's implementation file and help to reduce re-writing large blocks of code that are called multiple times throughout the file + */ +@interface OSCProfilesManager : NSObject + + ++ (OSCProfilesManager *) sharedManager; + +#pragma mark - Getters +/** + * Returns an array of decoded profile objects + */ +- (NSMutableArray *) getAllProfiles; + +/** + * Returns the OSC Profile that is currently selected to be displayed on screen during game streaming + */ +- (OSCProfile *) getSelectedProfile; + +/** + * Returns the index of the 'selected' profile within the array it's in + */ +- (NSInteger) getIndexOfSelectedProfile; + + + + +#pragma mark - Setters +/** + * Sets the profile object with the particular 'name' as the selected profile to be displayed on screen during game streaming + */ +- (void) setProfileToSelected:(NSString *)name; + +/** + * 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; + + +#pragma mark - Queries +/** + * Lets the caller of this method know whether a profile with a given name already exists in persistent storage + */ +- (BOOL) profileNameAlreadyExist:(NSString*)name; + + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.m b/Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.m new file mode 100644 index 00000000..f24f619d --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/OSCProfilesManager.m @@ -0,0 +1,215 @@ +// +// OSCProfilesManager.m +// Moonlight +// +// Created by Long Le on 1/1/23. +// Copyright © 2023 Moonlight Game Streaming Project. All rights reserved. +// + +#import "OSCProfilesManager.h" + +@implementation OSCProfilesManager + +#pragma mark - Initializer + ++ (OSCProfilesManager *) sharedManager { + static OSCProfilesManager *_sharedManager = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + _sharedManager = [[self alloc] init]; + }); + return _sharedManager; +} + + +#pragma mark - Class Helper Methods + +/** + * Returns the profile whose 'name' property has a value that is equal to the 'name' passed into the method + */ +- (OSCProfile *) OSCProfileWithName:(NSString*)name { + NSMutableArray *profiles = [self getAllProfiles]; + + for (OSCProfile *profile in profiles) { + + if ([profile.name isEqualToString:name]) { + return profile; + } + } + + return nil; +} + +/** + * Returns an array of encoded 'OSCProfile' objects from the array of decoded 'OSCProfile' objects passed into this method + */ +- (NSMutableArray *) encodedProfilesFromArray:(NSMutableArray *)profiles { + + NSMutableArray *profilesEncoded = [[NSMutableArray alloc] init]; + /* encode each profile and add them back into an array */ + for (OSCProfile *profile in profiles) { // + + NSData *profileEncoded = [NSKeyedArchiver archivedDataWithRootObject:profile requiringSecureCoding:YES error:nil]; + [profilesEncoded addObject:profileEncoded]; + } + + return profilesEncoded; +} + +/** + * Replaces one 'OSCProfile' object for another in the 'OSCProfile' objects array stored in persistent storage + */ +- (void) replaceProfile:(OSCProfile*)oldProfile withProfile:(OSCProfile*)newProfile { + NSMutableArray *profiles = [self getAllProfiles]; + + for (OSCProfile *profile in profiles) { // set all profiles' 'isSelected' property to NO since the new profile we're saving over the old profile with will be the 'selected' profile + + profile.isSelected = NO; + } + + /* Set the new profile as the selected one. The reasoning behind this is that this method is currently being used when the user saves over an existing profile with another profile that has the same name. The expected behavior is that the newly saved profile becomes the selected profile which will show on screen when they launch the game stream view */ + newProfile.isSelected = YES; + + /* Remove the old profile from the array and insert the new profile into its place */ + int index = 0; + for (int i = 0; i < profiles.count; i++) { + + if ([[profiles[i] name] isEqualToString: oldProfile.name]) { + index = i; + } + } + [profiles removeObjectAtIndex:index]; + [profiles insertObject:newProfile atIndex:index]; + + 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 in the array, which are already encoded. Save array to persistent storage */ + NSData *data = [NSKeyedArchiver archivedDataWithRootObject:profilesEncoded requiringSecureCoding:YES error:nil]; + [[NSUserDefaults standardUserDefaults] setObject:data forKey:@"OSCProfiles"]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + + +#pragma mark - Globally Accessible Methods + +#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 */ + NSMutableArray *profilesDecoded = [[NSMutableArray alloc] init]; + OSCProfile *profileDecoded; + for (NSData *profileEncoded in profilesEncoded) { + + profileDecoded = [NSKeyedUnarchiver unarchivedObjectOfClasses: classes fromData:profileEncoded error: nil]; + [profilesDecoded addObject: profileDecoded]; + } + return profilesDecoded; +} + +- (OSCProfile *) getSelectedProfile { + NSMutableArray *profiles = [self getAllProfiles]; + + for (OSCProfile *profile in profiles) { + + if (profile.isSelected) { + return profile; + } + } + return nil; +} + +- (NSInteger) getIndexOfSelectedProfile { + NSMutableArray *profiles = [self getAllProfiles]; + for (OSCProfile *profile in profiles) { + + if (profile.isSelected == YES) { + return [profiles indexOfObject:profile]; + } + } + 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 +} + + +#pragma mark - Setters + +- (void) setProfileToSelected:(NSString *)name { + 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; + } + } + + 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"]; + [[NSUserDefaults standardUserDefaults] synchronize]; +} + +- (void) saveProfileWithName:(NSString*)name andButtonLayers:(NSMutableArray *)buttonLayers { + /* 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 */ + NSMutableArray *buttonStatesEncoded = [[NSMutableArray alloc] init]; + for (CALayer *buttonLayer in buttonLayers) { + + OnScreenButtonState *buttonState = [[OnScreenButtonState alloc] initWithButtonName:buttonLayer.name isHidden:buttonLayer.isHidden andPosition:buttonLayer.position]; + NSData *buttonStateEncoded = [NSKeyedArchiver archivedDataWithRootObject:buttonState requiringSecureCoding:YES error:nil]; + [buttonStatesEncoded addObject: buttonStateEncoded]; + } + + 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]; + } + 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]; + } +} + +#pragma mark - Queries + +- (BOOL) profileNameAlreadyExist:(NSString*)name { + NSMutableArray *profiles = [self getAllProfiles]; + + /* Iterate through the decoded profiles and return 'YES' if one of the profiles' 'name' properties equals the 'name' passed into this method */ + for (OSCProfile *profile in profiles) { + + if ([profile.name isEqualToString:name]) { + return YES; + } + } + + return NO; +} + +@end diff --git a/Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.h b/Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.h new file mode 100644 index 00000000..a08402ba --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.h @@ -0,0 +1,32 @@ +// +// OnScreenButtonState.h +// Moonlight +// +// Created by Long Le on 10/20/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + This object is used to save positional and visibility information for any particular on screen virtual controller button. + We are able to associate this 'OnScreenButtonState' object and its corresponding CALayer onscreen controller button by setting their 'name' properties equal; in our particular case we give them descriptive names such as 'aButton', 'upButton', 'leftStick', etc. By keeping references to the 19 on screen controller buttons (CALayers) in an array, and creating 19 'OnScreenButtonState' objects with names corresponding to these 19 CALayers and keeping them in an array, we can iterate through both arrays to find OSC buttons (CALayers) with the same name as one of the 'OnScreenButtonStateObjects' and then set the CALayer on screen control button's 'position' and 'hidden' property according to the value of the 'OnScreenButtonState' objects 'position' and 'isHidden' properties. + Naturally we would like the user to be able to save their controller layout configurations so that they can load them between app launches, so we adopt encoding/decoding related protocols so we encode these 'OnScreenButtonState' objects and save them to NSUserDefaults + */ +@interface OnScreenButtonState : NSObject + +@property NSString *name; +@property CGPoint position; +@property BOOL isHidden; + +- (id) initWithButtonName:(NSString*)name isHidden:(BOOL)isHidden andPosition:(CGPoint)position; + ++ (BOOL) supportsSecureCoding; +- (void) encodeWithCoder:(NSCoder*)encoder; +- (id) initWithCoder:(NSCoder*)decoder; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.m b/Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.m new file mode 100644 index 00000000..a6dca53c --- /dev/null +++ b/Limelight/Input/Custom OnScreen Controls/OnScreenButtonState.m @@ -0,0 +1,43 @@ +// +// OnScreenButtonState.m +// Moonlight +// +// Created by Long Le on 10/20/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import "OnScreenButtonState.h" + +@implementation OnScreenButtonState + +- (id) initWithButtonName:(NSString*)name isHidden:(BOOL)isHidden andPosition:(CGPoint)position { + if ((self = [self init])) { + self.name = name; + self.isHidden = isHidden; + self.position = position; + } + + return self; +} + ++ (BOOL) supportsSecureCoding { + return YES; +} + +- (void) encodeWithCoder:(NSCoder*)encoder { + [encoder encodeObject:self.name forKey:@"name"]; + [encoder encodeBool:self.isHidden forKey:@"isHidden"]; + [encoder encodeCGPoint:self.position forKey:@"position"]; +} + +- (id) initWithCoder:(NSCoder*)decoder { + if (self = [super init]) { + self.name = [decoder decodeObjectForKey:@"name"]; + self.isHidden = [decoder decodeBoolForKey:@"isHidden"]; + self.position = [decoder decodeCGPointForKey:@"position"]; + } + + return self; +} + +@end diff --git a/Limelight/Input/OnScreenControls.h b/Limelight/Input/OnScreenControls.h index 29997de3..af33862e 100644 --- a/Limelight/Input/OnScreenControls.h +++ b/Limelight/Input/OnScreenControls.h @@ -7,10 +7,15 @@ // #import +#import "ControllerSupport.h" +#import "OSCProfile.h" @class ControllerSupport; @class StreamConfiguration; +static const float D_PAD_DIST = 10; +static const float BUTTON_DIST = 20; + @interface OnScreenControls : NSObject typedef NS_ENUM(NSInteger, OnScreenControlsLevel) { @@ -18,6 +23,7 @@ typedef NS_ENUM(NSInteger, OnScreenControlsLevel) { OnScreenControlsLevelAuto, OnScreenControlsLevelSimple, OnScreenControlsLevelFull, + OnScreenControlsCustom, // Internal levels selected by ControllerSupport OnScreenControlsLevelAutoGCGamepad, @@ -25,12 +31,47 @@ typedef NS_ENUM(NSInteger, OnScreenControlsLevel) { OnScreenControlsLevelAutoGCExtendedGamepadWithStickButtons }; +@property CALayer* _aButton; +@property CALayer* _bButton; +@property CALayer* _xButton; +@property CALayer* _yButton; +@property CALayer* _startButton; +@property CALayer* _selectButton; +@property CALayer* _r1Button; +@property CALayer* _r2Button; +@property CALayer* _r3Button; +@property CALayer* _l1Button; +@property CALayer* _l2Button; +@property CALayer* _l3Button; +@property CALayer* _upButton; +@property CALayer* _downButton; +@property CALayer* _leftButton; +@property CALayer* _rightButton; +@property CALayer* _leftStickBackground; +@property CALayer* _leftStick; +@property CALayer* _rightStickBackground; +@property CALayer* _rightStick; +@property CALayer *_dPadBackground; // parent layer that contains each individual dPad button so user can drag them around the screen together + +@property float D_PAD_CENTER_X; +@property float D_PAD_CENTER_Y; + +@property OnScreenControlsLevel _level; + +@property NSMutableArray *OSCButtonLayers; + - (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport streamConfig:(StreamConfiguration*)streamConfig; - (BOOL) handleTouchDownEvent:(NSSet*)touches; - (BOOL) handleTouchUpEvent:(NSSet*)touches; - (BOOL) handleTouchMovedEvent:(NSSet*)touches; - (void) setLevel:(OnScreenControlsLevel)level; -- (OnScreenControlsLevel) getLevel; - (void) show; +- (void) setupComplexControls; +- (void) drawButtons; +- (void) updateControls; +- (OnScreenControlsLevel) getLevel; +- (void) setDPadCenter; +- (void) setAnalogStickPositions; +- (void) positionOSCButtons; @end diff --git a/Limelight/Input/OnScreenControls.m b/Limelight/Input/OnScreenControls.m index 667de2d6..cdfcdefb 100644 --- a/Limelight/Input/OnScreenControls.m +++ b/Limelight/Input/OnScreenControls.m @@ -8,34 +8,17 @@ #import "OnScreenControls.h" #import "StreamView.h" -#import "ControllerSupport.h" #import "Controller.h" #include "Limelight.h" +#import "OnScreenButtonState.h" +#import "OSCProfilesManager.h" + #define UPDATE_BUTTON(x, y) (buttonFlags = \ (y) ? (buttonFlags | (x)) : (buttonFlags & ~(x))) @implementation OnScreenControls { - CALayer* _aButton; - CALayer* _bButton; - CALayer* _xButton; - CALayer* _yButton; - CALayer* _upButton; - CALayer* _downButton; - CALayer* _leftButton; - CALayer* _rightButton; - CALayer* _leftStickBackground; - CALayer* _leftStick; - CALayer* _rightStickBackground; - CALayer* _rightStick; - CALayer* _startButton; - CALayer* _selectButton; - CALayer* _r1Button; - CALayer* _r2Button; - CALayer* _r3Button; - CALayer* _l1Button; - CALayer* _l2Button; - CALayer* _l3Button; + UITouch* _aTouch; UITouch* _bTouch; @@ -62,25 +45,47 @@ BOOL _iPad; CGRect _controlArea; UIView* _view; - OnScreenControlsLevel _level; BOOL _visible; ControllerSupport *_controllerSupport; Controller *_controller; NSMutableArray* _deadTouches; BOOL _swapABXY; + OSCProfilesManager *profilesManager; } +@synthesize D_PAD_CENTER_X; +@synthesize D_PAD_CENTER_Y; +@synthesize _leftStickBackground; +@synthesize _leftStick; +@synthesize _rightStickBackground; +@synthesize _rightStick; +@synthesize _upButton; +@synthesize _downButton; +@synthesize _leftButton; +@synthesize _rightButton; +@synthesize _aButton; +@synthesize _bButton; +@synthesize _xButton; +@synthesize _yButton; +@synthesize _startButton; +@synthesize _selectButton; +@synthesize _r1Button; +@synthesize _r2Button; +@synthesize _r3Button; +@synthesize _l1Button; +@synthesize _l2Button; +@synthesize _l3Button; +@synthesize _level; +@synthesize OSCButtonLayers; +@synthesize _dPadBackground; + static const float EDGE_WIDTH = .05; //static const float BUTTON_SIZE = 50; -static const float BUTTON_DIST = 20; static float BUTTON_CENTER_X; static float BUTTON_CENTER_Y; -static const float D_PAD_DIST = 10; -static float D_PAD_CENTER_X; -static float D_PAD_CENTER_Y; static const float DEAD_ZONE_PADDING = 15; @@ -115,10 +120,19 @@ static float L3_Y; - (id) initWithView:(UIView*)view controllerSup:(ControllerSupport*)controllerSupport streamConfig:(StreamConfiguration*)streamConfig { self = [self init]; _view = view; - _controllerSupport = controllerSupport; + + profilesManager = [OSCProfilesManager sharedManager]; + + self.OSCButtonLayers = [[NSMutableArray alloc] init]; + + if (controllerSupport) { + _controllerSupport = controllerSupport; + } _controller = [controllerSupport getOscController]; _deadTouches = [[NSMutableArray alloc] init]; - _swapABXY = streamConfig.swapABXYButtons; + if (streamConfig) { + _swapABXY = streamConfig.swapABXYButtons; + } _iPad = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad); _controlArea = CGRectMake(0, 0, _view.frame.size.width, _view.frame.size.height); @@ -133,7 +147,7 @@ static float L3_Y; _controlArea.origin.x = _controlArea.size.width * EDGE_WIDTH; _controlArea.size.width -= _controlArea.origin.x * 2; } - + _aButton = [CALayer layer]; _bButton = [CALayer layer]; _xButton = [CALayer layer]; @@ -154,6 +168,49 @@ static float L3_Y; _rightStickBackground = [CALayer layer]; _leftStick = [CALayer layer]; _rightStick = [CALayer layer]; + + [self.OSCButtonLayers addObject:_aButton]; + [self.OSCButtonLayers addObject:_bButton]; + [self.OSCButtonLayers addObject:_xButton]; + [self.OSCButtonLayers addObject:_yButton]; + [self.OSCButtonLayers addObject:_startButton]; + [self.OSCButtonLayers addObject:_selectButton]; + [self.OSCButtonLayers addObject:_r1Button]; + [self.OSCButtonLayers addObject:_r2Button]; + [self.OSCButtonLayers addObject:_r3Button]; + [self.OSCButtonLayers addObject:_l1Button]; + [self.OSCButtonLayers addObject:_l2Button]; + [self.OSCButtonLayers addObject:_l3Button]; + [self.OSCButtonLayers addObject:_upButton]; + [self.OSCButtonLayers addObject:_downButton]; + [self.OSCButtonLayers addObject:_leftButton]; + [self.OSCButtonLayers addObject:_rightButton]; + [self.OSCButtonLayers addObject:_leftStickBackground]; + [self.OSCButtonLayers addObject:_rightStickBackground]; + [self.OSCButtonLayers addObject:_leftStick]; + [self.OSCButtonLayers addObject:_rightStick]; + + /* Name button layers to allow us to more easily associate them with 'OnScreenButtonState' objects by comparing their name properties */ + _leftStickBackground.name = @"leftStickBackground"; + _rightStickBackground.name = @"rightStickBackground"; + _leftStick.name = @"leftStick"; + _rightStick.name = @"rightStick"; + _aButton.name = @"aButton"; + _bButton.name = @"bButton"; + _xButton.name = @"xButton"; + _yButton.name = @"yButton"; + _startButton.name = @"startButton"; + _selectButton.name = @"selectButton"; + _r1Button.name = @"r1Button"; + _r2Button.name = @"r2Button"; + _r3Button.name = @"r3Button"; + _l1Button.name = @"l1Button"; + _l2Button.name = @"l2Button"; + _l3Button.name = @"l3Button"; + _upButton.name = @"upButton"; + _rightButton.name = @"rightButton"; + _downButton.name = @"downButton"; + _leftButton.name = @"leftButton"; return self; } @@ -179,7 +236,7 @@ static float L3_Y; } - (void) updateControls { - switch (_level) { + switch (self._level) { case OnScreenControlsLevelOff: [self hideButtons]; [self hideBumpers]; @@ -224,8 +281,8 @@ static float L3_Y; [self hideSticks]; break; case OnScreenControlsLevelSimple: + [self setupSimpleControls]; - [self hideTriggers]; [self hideL3R3]; [self hideBumpers]; @@ -234,14 +291,27 @@ static float L3_Y; [self drawButtons]; break; case OnScreenControlsLevelFull: - [self setupComplexControls]; + [self setupComplexControls]; [self drawButtons]; [self drawStartSelect]; [self drawBumpers]; [self drawTriggers]; [self drawSticks]; [self hideL3R3]; // Full controls don't need these they have the sticks + break; + case OnScreenControlsCustom: + + [self setupComplexControls]; // Default postion for D-Pad set here + [self setDPadCenter]; // Custom position for D-Pad set here + [self setAnalogStickPositions]; // Custom position for analog sticks set here + [self drawButtons]; + [self drawStartSelect]; + [self drawBumpers]; + [self drawTriggers]; + [self drawSticks]; + [self positionOSCButtons]; + break; default: Log(LOG_W, @"Unknown on-screen controls level: %d", (int)_level); @@ -408,27 +478,96 @@ static float L3_Y; UIImage* downButtonImage = [UIImage imageNamed:@"DownButton"]; _downButton.frame = CGRectMake(D_PAD_CENTER_X - downButtonImage.size.width / 2, D_PAD_CENTER_Y + D_PAD_DIST, downButtonImage.size.width, downButtonImage.size.height); _downButton.contents = (id) downButtonImage.CGImage; + _downButton.frame = CGRectMake(D_PAD_CENTER_X - downButtonImage.size.width / 2, D_PAD_CENTER_Y + D_PAD_DIST, downButtonImage.size.width, downButtonImage.size.height); [_view.layer addSublayer:_downButton]; // create Right button UIImage* rightButtonImage = [UIImage imageNamed:@"RightButton"]; _rightButton.frame = CGRectMake(D_PAD_CENTER_X + D_PAD_DIST, D_PAD_CENTER_Y - rightButtonImage.size.height / 2, rightButtonImage.size.width, rightButtonImage.size.height); _rightButton.contents = (id) rightButtonImage.CGImage; + _rightButton.frame = CGRectMake(D_PAD_CENTER_X + D_PAD_DIST, D_PAD_CENTER_Y - rightButtonImage.size.height / 2, rightButtonImage.size.width, rightButtonImage.size.height); [_view.layer addSublayer:_rightButton]; // create Up button UIImage* upButtonImage = [UIImage imageNamed:@"UpButton"]; _upButton.frame = CGRectMake(D_PAD_CENTER_X - upButtonImage.size.width / 2, D_PAD_CENTER_Y - D_PAD_DIST - upButtonImage.size.height, upButtonImage.size.width, upButtonImage.size.height); _upButton.contents = (id) upButtonImage.CGImage; + _upButton.frame = CGRectMake(D_PAD_CENTER_X - upButtonImage.size.width / 2, D_PAD_CENTER_Y - D_PAD_DIST - upButtonImage.size.height, upButtonImage.size.width, upButtonImage.size.height); [_view.layer addSublayer:_upButton]; // create Left button UIImage* leftButtonImage = [UIImage imageNamed:@"LeftButton"]; _leftButton.frame = CGRectMake(D_PAD_CENTER_X - D_PAD_DIST - leftButtonImage.size.width, D_PAD_CENTER_Y - leftButtonImage.size.height / 2, leftButtonImage.size.width, leftButtonImage.size.height); _leftButton.contents = (id) leftButtonImage.CGImage; + _leftButton.frame = CGRectMake(D_PAD_CENTER_X - D_PAD_DIST - leftButtonImage.size.width, D_PAD_CENTER_Y - leftButtonImage.size.height / 2, leftButtonImage.size.width, leftButtonImage.size.height); [_view.layer addSublayer:_leftButton]; } +/** + * Sets D-Pad position for class const var + */ +- (void) setDPadCenter { + OSCProfile *oscProfile = [profilesManager getSelectedProfile]; //returns the currently selected OSCProfile + + for (NSData *buttonStateEncoded in oscProfile.buttonStates) { + OnScreenButtonState *buttonState = [NSKeyedUnarchiver unarchivedObjectOfClass:[OnScreenButtonState class] fromData:buttonStateEncoded error:nil]; + + if ([buttonState.name isEqualToString:@"dPad"]) { + D_PAD_CENTER_X = buttonState.position.x; + D_PAD_CENTER_Y = buttonState.position.y; + } + } +} + +/** + * Sets analog stick positions for class const var + */ +- (void) setAnalogStickPositions { + OSCProfile *oscProfile = [profilesManager getSelectedProfile]; // returns the currently selected OSCProfile + + for (NSData *buttonStateEncoded in oscProfile.buttonStates) { + OnScreenButtonState *buttonState = [NSKeyedUnarchiver unarchivedObjectOfClass:[OnScreenButtonState class] fromData:buttonStateEncoded error:nil]; + + if ([buttonState.name isEqualToString:@"leftStickBackground"]) { + LS_CENTER_X = buttonState.position.x; + LS_CENTER_Y = buttonState.position.y; + } + if ([buttonState.name isEqualToString:@"rightStickBackground"]) { + RS_CENTER_X = buttonState.position.x; + RS_CENTER_Y = buttonState.position.y; + } + } +} + +/** + * Loads the selected OSC profile and lays out each button associated with that profile onto the screen + */ +- (void) positionOSCButtons { + OSCProfile *oscProfile = [profilesManager getSelectedProfile]; + + for (NSData *buttonStateEncoded in oscProfile.buttonStates) { + + OnScreenButtonState *buttonStateDecoded = [NSKeyedUnarchiver unarchivedObjectOfClass:[OnScreenButtonState class] fromData:buttonStateEncoded error:nil]; + + for (CALayer *buttonLayer in self.OSCButtonLayers) { // iterate through each button layer on screen and position and hide/unhide each according to the instructions of its associated 'buttonState' + + if ([buttonLayer.name isEqualToString:buttonStateDecoded.name]) { + + if ([buttonLayer.name isEqualToString:@"upButton"] == NO && + [buttonLayer.name isEqualToString:@"rightButton"] == NO && + [buttonLayer.name isEqualToString:@"downButton"] == NO && + [buttonLayer.name isEqualToString:@"leftButton"] == NO && + [buttonLayer.name isEqualToString:@"leftStick"] == NO && + [buttonLayer.name isEqualToString:@"rightStick"] == NO) { // Don't move these buttons since they've already been positioned correctly in the 'drawButtons' and 'drawSticks' methods called before this method is called. The 'buttonStateDecoded' object associated with these buttons contains positions relative to a parent CALayer which only exists on 'LayoutOnScreenControls'. These positions relative to the parent layers would translate incorrectly when placed on the game stream VC's 'view' layer + + buttonLayer.position = buttonStateDecoded.position; + } + buttonLayer.hidden = buttonStateDecoded.isHidden; + } + } + } +} + - (void) drawStartSelect { // create Start button UIImage* startButtonImage = [UIImage imageNamed:@"StartButton"]; @@ -982,4 +1121,5 @@ static float L3_Y; } + @end diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.h b/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.h new file mode 100644 index 00000000..ee758a07 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.h @@ -0,0 +1,19 @@ +// +// ProfileTableViewCell.h +// Moonlight +// +// Created by Long Le on 12/11/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ProfileTableViewCell : UITableViewCell + +@property (weak, nonatomic) IBOutlet UILabel *name; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.m b/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.m new file mode 100644 index 00000000..f1388f47 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.m @@ -0,0 +1,21 @@ +// +// ProfileTableViewCell.m +// Moonlight +// +// Created by Long Le on 12/11/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import "ProfileTableViewCell.h" + +@implementation ProfileTableViewCell + +- (void)awakeFromNib { + [super awakeFromNib]; +} + +- (void)setSelected:(BOOL)selected animated:(BOOL)animated { + [super setSelected:selected animated:animated]; +} + +@end diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.xib b/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.xib new file mode 100644 index 00000000..f32dd8f4 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/Cells/ProfileTableViewCell.xib @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.h b/Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.h new file mode 100644 index 00000000..acbaa5d3 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.h @@ -0,0 +1,36 @@ +// +// LayoutOnScreenControlsViewController.h +// Moonlight +// +// Created by Long Le on 9/27/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import +#import "LayoutOnScreenControls.h" +#import "ToolBarContainerView.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + This view controller provides the user interface which allows the user to position on screen controller buttons anywhere they'd like on the screen. It also provides the user with the abilities to undo a change, save the on screen controller layout for later retrieval, and load previously saved controller layouts + */ +@interface LayoutOnScreenControlsViewController : UIViewController + + +@property LayoutOnScreenControls *layoutOSC; // object that contains a view which contains the on screen controller buttons that allows the user to drag and positions each button on the screen using touch +@property int OSCSegmentSelected; + +@property (weak, nonatomic) IBOutlet UIButton *trashCanButton; +@property (weak, nonatomic) IBOutlet UIButton *undoButton; + +@property (weak, nonatomic) IBOutlet ToolBarContainerView *toolbarRootView; +@property (weak, nonatomic) IBOutlet UIView *chevronView; +@property (weak, nonatomic) IBOutlet UIImageView *chevronImageView; +@property (weak, nonatomic) IBOutlet UIStackView *toolbarStackView; + + + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.m b/Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.m new file mode 100644 index 00000000..da7dcb7f --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/LayoutOnScreenControlsViewController.m @@ -0,0 +1,358 @@ +// +// LayoutOnScreenControlsViewController.m +// Moonlight +// +// Created by Long Le on 9/27/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import "LayoutOnScreenControlsViewController.h" +#import "OSCProfilesTableViewController.h" +#import "OnScreenButtonState.h" +#import "OnScreenControls.h" +#import "OSCProfilesManager.h" + +@interface LayoutOnScreenControlsViewController () + +@end + + +@implementation LayoutOnScreenControlsViewController { + BOOL isToolbarHidden; + OSCProfilesManager *profilesManager; + __weak IBOutlet NSLayoutConstraint *toolbarTopConstraintiPhone; + __weak IBOutlet NSLayoutConstraint *toolbarTopConstraintiPad; +} + +@synthesize trashCanButton; +@synthesize undoButton; +@synthesize OSCSegmentSelected; +@synthesize toolbarRootView; +@synthesize chevronView; +@synthesize chevronImageView; + +- (void) viewDidLoad { + [super viewDidLoad]; + + profilesManager = [OSCProfilesManager sharedManager]; + + isToolbarHidden = NO; // keeps track if the toolbar is hidden up above the screen so that we know whether to hide or show it when the user taps the toolbar's hide/show button + + /* add curve to bottom of chevron tab view */ + UIBezierPath *maskPath = [UIBezierPath bezierPathWithRoundedRect:self.chevronView.bounds byRoundingCorners:(UIRectCornerBottomLeft | UIRectCornerBottomRight) cornerRadii:CGSizeMake(10.0, 10.0)]; + CAShapeLayer *maskLayer = [[CAShapeLayer alloc] init]; + maskLayer.frame = self.view.bounds; + maskLayer.path = maskPath.CGPath; + self.chevronView.layer.mask = maskLayer; + + /* Add swipe gesture to toolbar to allow user to swipe it up and off screen */ + UISwipeGestureRecognizer *swipeUp = [[UISwipeGestureRecognizer alloc] initWithTarget:self action:@selector(moveToolbar:)]; + swipeUp.direction = UISwipeGestureRecognizerDirectionUp; + [self.toolbarRootView addGestureRecognizer:swipeUp]; + + /* Add tap gesture to toolbar's chevron to allow user to tap it in order to move the toolbar on and off screen */ + UITapGestureRecognizer *singleFingerTap = + [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(moveToolbar:)]; + [self.chevronView addGestureRecognizer:singleFingerTap]; + + self.layoutOSC = [[LayoutOnScreenControls alloc] initWithView:self.view controllerSup:nil streamConfig:nil oscLevel:OSCSegmentSelected]; + self.layoutOSC._level = 4; + [self.layoutOSC show]; // draw on screen controls + + [self addInnerAnalogSticksToOuterAnalogLayers]; // allows inner and analog sticks to be dragged together around the screen together as one unit which is the expected behavior + + self.undoButton.alpha = 0.3; // no changes to undo yet, so fade out the undo button a bit + + if ([[profilesManager getAllProfiles] count] == 0) { // if no saved OSC profiles exist yet then create one called 'Default' and associate it with Moonlight's legacy 'Full' OSC layout that's already been laid out on the screen at this point + [profilesManager saveProfileWithName:@"Default" andButtonLayers:self.layoutOSC.OSCButtonLayers]; + } + + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(OSCLayoutChanged) name:@"OSCLayoutChanged" object:nil]; // used to notifiy this view controller that the user made a change to the OSC layout so that the VC can either fade in or out its 'Undo button' which will signify to the user whether there are any OSC layout changes to undo + + /* This will animate the toolbar with a subtle up and down motion intended to telegraph to the user that they can hide the toolbar if they wish*/ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + + [UIView animateWithDuration:0.3 + delay:0.25 + usingSpringWithDamping:0.8 + initialSpringVelocity:0.5 + options:UIViewAnimationOptionCurveEaseInOut animations:^{ // Animate toolbar up a a very small distance. Note the 0.35 time delay is necessary to avoid a bug that keeps animations from playing if the animation is presented immediately on a modally presented VC + self.toolbarRootView.frame = CGRectMake(self.toolbarRootView.frame.origin.x, self.toolbarRootView.frame.origin.y - 25, self.toolbarRootView.frame.size.width, self.toolbarRootView.frame.size.height); + } + completion:^(BOOL finished) { + [UIView animateWithDuration:0.3 + delay:0 + usingSpringWithDamping:0.7 + initialSpringVelocity:1.0 + options:UIViewAnimationOptionCurveEaseIn animations:^{ // Animate the toolbar back down that same distance + self.toolbarRootView.frame = CGRectMake(self.toolbarRootView.frame.origin.x, self.toolbarRootView.frame.origin.y + 25, self.toolbarRootView.frame.size.width, self.toolbarRootView.frame.size.height); + } + completion:^(BOOL finished) { + NSLog (@"done"); + }]; + }]; + }); +} + + +#pragma mark - Class Helper Functions + +/* fades the 'Undo Button' in or out depending on whether the user has any OSC layout changes to undo */ +- (void) OSCLayoutChanged { + if ([self.layoutOSC.layoutChanges count] > 0) { + self.undoButton.alpha = 1.0; + } + else { + self.undoButton.alpha = 0.3; + } +} + +/* animates the toolbar up and off the screen or back down onto the screen */ +- (void) moveToolbar:(UISwipeGestureRecognizer *)sender { + BOOL isPad = [[UIDevice currentDevice].model hasPrefix:@"iPad"]; + NSLayoutConstraint *toolbarTopConstraint = isPad ? self->toolbarTopConstraintiPad : self->toolbarTopConstraintiPhone; + if (isToolbarHidden == NO) { + [UIView animateWithDuration:0.2 animations:^{ // animates toolbar up and off screen + toolbarTopConstraint.constant -= self.toolbarRootView.frame.size.height; + [self.view layoutIfNeeded]; + + } + completion:^(BOOL finished) { + if (finished) { + self->isToolbarHidden = YES; + self.chevronImageView.image = [UIImage imageNamed:@"ChevronCompactDown"]; + } + }]; + } + else { + [UIView animateWithDuration:0.2 animations:^{ // animates the toolbar back down into the screen + toolbarTopConstraint.constant += self.toolbarRootView.frame.size.height; + [self.view layoutIfNeeded]; + } + completion:^(BOOL finished) { + if (finished) { + self->isToolbarHidden = NO; + self.chevronImageView.image = [UIImage imageNamed:@"ChevronCompactUp"]; + } + }]; + } +} + +/** + * Makes the inner analog stick layers a child layer of its corresponding outer analog stick layers so that both the inner and its corresponding outer layers move together when the user drags them around the screen as is the expected behavior when laying out OSC. Note that this is NOT expected behavior on the game stream view where the inner analog sticks move to follow toward the user's touch and their corresponding outer analog stick layers do not move + */ +- (void)addInnerAnalogSticksToOuterAnalogLayers { + // right stick + [self.layoutOSC._rightStickBackground addSublayer: self.layoutOSC._rightStick]; + self.layoutOSC._rightStick.position = CGPointMake(self.layoutOSC._rightStickBackground.frame.size.width / 2, self.layoutOSC._rightStickBackground.frame.size.height / 2); + + // left stick + [self.layoutOSC._leftStickBackground addSublayer: self.layoutOSC._leftStick]; + self.layoutOSC._leftStick.position = CGPointMake(self.layoutOSC._leftStickBackground.frame.size.width / 2, self.layoutOSC._leftStickBackground.frame.size.height / 2); +} + + +#pragma mark - UIButton Actions + +- (IBAction) closeTapped:(id)sender { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (IBAction) trashCanTapped:(id)sender { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Delete Buttons Here" message:@"Drag and drop buttons onto this trash can to remove them from the interface" preferredStyle:UIAlertControllerStyleAlert]; + + UIAlertAction *ok = [UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:nil]; + [alert addAction:ok]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (IBAction) undoTapped:(id)sender { + if ([self.layoutOSC.layoutChanges count] > 0) { // check if there are layout changes to roll back to + OnScreenButtonState *buttonState = [self.layoutOSC.layoutChanges lastObject]; // Get the 'OnScreenButtonState' object that contains the name, position, and visiblity state of the button the user last moved + + CALayer *buttonLayer = [self.layoutOSC buttonLayerFromName:buttonState.name]; // get the on screen button layer that corresponds with the 'OnScreenButtonState' object that we retrieved above + + /* Set the button's position and visiblity to what it was before the user last moved it */ + buttonLayer.position = buttonState.position; + buttonLayer.hidden = buttonState.isHidden; + + /* if user is showing or hiding dPad, then show or hide all four dPad button child layers as well since setting the 'hidden' property on the parent CALayer is not automatically setting the individual dPad child CALayers */ + if ([buttonLayer.name isEqualToString:@"dPad"]) { + self.layoutOSC._upButton.hidden = buttonState.isHidden; + self.layoutOSC._rightButton.hidden = buttonState.isHidden; + self.layoutOSC._downButton.hidden = buttonState.isHidden; + self.layoutOSC._leftButton.hidden = buttonState.isHidden; + } + + /* if user is showing or hiding the left or right analog sticks, then show or hide their corresponding inner analog stick child layers as well since setting the 'hidden' property on the parent analog stick doesn't automatically hide its child inner analog stick CALayer */ + if ([buttonLayer.name isEqualToString:@"leftStickBackground"]) { + self.layoutOSC._leftStick.hidden = buttonState.isHidden; + } + if ([buttonLayer.name isEqualToString:@"rightStickBackground"]) { + self.layoutOSC._rightStick.hidden = buttonState.isHidden; + } + + [self.layoutOSC.layoutChanges removeLastObject]; + + [self OSCLayoutChanged]; // will fade the undo button in or out depending on whether there are any further changes to undo + } + else { // there are no changes to undo. let user know there are no changes to undo + UIAlertController * savedAlertController = [UIAlertController alertControllerWithTitle: [NSString stringWithFormat:@"Nothing to Undo"] message: @"There are no changes to undo" preferredStyle:UIAlertControllerStyleAlert]; + [savedAlertController addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + [savedAlertController dismissViewControllerAnimated:NO completion:nil]; + }]]; + [self presentViewController:savedAlertController animated:YES completion:nil]; + } +} + +/* show pop up notification that lets users choose to save the current OSC layout configuration as a profile they can load when they want. User can also choose to cancel out of this pop up */ +- (IBAction) saveTapped:(id)sender { + UIAlertController * inputNameAlertController = [UIAlertController alertControllerWithTitle: @"Enter the name you want to save this controller profile as" message: @"" preferredStyle:UIAlertControllerStyleAlert]; + [inputNameAlertController addTextFieldWithConfigurationHandler:^(UITextField *textField) { // pop up notification with text field where user can enter the text they wish to name their OSC layout profile + textField.placeholder = @"name"; + textField.textColor = [UIColor lightGrayColor]; + textField.clearButtonMode = UITextFieldViewModeWhileEditing; + textField.borderStyle = UITextBorderStyleNone; + }]; + [inputNameAlertController addAction:[UIAlertAction actionWithTitle:@"Save" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { // add save button to allow user to save the on screen controller configuration + NSArray *textFields = inputNameAlertController.textFields; + UITextField *nameField = textFields[0]; + NSString *enteredProfileName = nameField.text; + + if ([enteredProfileName isEqualToString:@"Default"]) { // don't let user user overwrite the 'Default' profile + UIAlertController *alertController = [UIAlertController alertControllerWithTitle: [NSString stringWithFormat:@""] message: [NSString stringWithFormat:@"Saving over the 'Default' profile is not allowed"] preferredStyle:UIAlertControllerStyleAlert]; + + [alertController addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + [alertController dismissViewControllerAnimated:NO completion:^{ + [self presentViewController:inputNameAlertController animated:YES completion:nil]; + }]; + }]]; + [self presentViewController:alertController animated:YES completion:nil]; + } + else if ([enteredProfileName length] == 0) { // if user entered no text and taps the 'Save' button let them know they can't do that + UIAlertController * savedAlertController = [UIAlertController alertControllerWithTitle: [NSString stringWithFormat:@""] message: [NSString stringWithFormat:@"Profile name cannot be blank!"] preferredStyle:UIAlertControllerStyleAlert]; + + [savedAlertController addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { // show pop up notification letting user know they must enter a name in the text field if they wish to save the controller profile + + [savedAlertController dismissViewControllerAnimated:NO completion:^{ + [self presentViewController:inputNameAlertController animated:YES completion:nil]; + }]; + }]]; + [self presentViewController:savedAlertController animated:YES completion:nil]; + } + else if ([self->profilesManager profileNameAlreadyExist:enteredProfileName] == YES) { // if the entered profile name already exists then let the user know. Offer to allow them to overwrite the existing profile + UIAlertController * savedAlertController = [UIAlertController alertControllerWithTitle: [NSString stringWithFormat:@""] message: [NSString stringWithFormat:@"Another profile with the name '%@' already exists! Do you want to overwrite it?", enteredProfileName] preferredStyle:UIAlertControllerStyleAlert]; + + [savedAlertController addAction:[UIAlertAction actionWithTitle:@"Yes" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { // overwrite existing profile + [self->profilesManager saveProfileWithName: enteredProfileName andButtonLayers:self.layoutOSC.OSCButtonLayers]; + }]]; + + [savedAlertController addAction:[UIAlertAction actionWithTitle:@"No" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { // don't overwrite the existing profile + [savedAlertController dismissViewControllerAnimated:NO completion:nil]; + }]]; + [self presentViewController:savedAlertController animated:YES completion:nil]; + } + else { // if user entered a valid name that doesn't already exist then save the profile to persistent storage + [self->profilesManager saveProfileWithName: enteredProfileName andButtonLayers:self.layoutOSC.OSCButtonLayers]; + [self->profilesManager setProfileToSelected: enteredProfileName]; + + UIAlertController * savedAlertController = [UIAlertController alertControllerWithTitle: [NSString stringWithFormat:@""] message: [NSString stringWithFormat:@"%@ profile saved and set as your active in-game controller profile layout", enteredProfileName] preferredStyle:UIAlertControllerStyleAlert]; // Let user know this profile has been saved and is now the selected controller layout + + [savedAlertController addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + [savedAlertController dismissViewControllerAnimated:NO completion:nil]; + }]]; + [self presentViewController:savedAlertController animated:YES completion:nil]; + } + }]]; + [inputNameAlertController addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { // adds a button that allows user to decline the option to save the controller layout they currently see on screen + [inputNameAlertController dismissViewControllerAnimated:NO completion:nil]; + }]]; + [self presentViewController:inputNameAlertController animated:YES completion:nil]; +} + +/* Presents the view controller that lists all OSC profiles the user can choose from */ +- (IBAction) loadTapped:(id)sender { + UIStoryboard *storyboard; + BOOL isIPhone = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone); + if (isIPhone) { + storyboard = [UIStoryboard storyboardWithName:@"iPhone" bundle:nil]; + } + else { + storyboard = [UIStoryboard storyboardWithName:@"iPad" bundle:nil]; + } + + OSCProfilesTableViewController *vc = [storyboard instantiateViewControllerWithIdentifier:@"OSCProfilesTableViewController"] ; + + vc.didDismissOSCProfilesTVC = ^() { // a block that will be called when the modally presented 'OSCProfilesTableViewController' VC is dismissed. By the time the 'OSCProfilesTableViewController' VC is dismissed the user would have potentially selected a different OSC profile with a different layout and they want to see this layout on this 'LayoutOnScreenControlsViewController.' This block of code will load the profile and then hide/show and move each OSC button to their appropriate position + [self.layoutOSC updateControls]; // creates and saves a 'Default' OSC profile or loads the one the user selected on the previous screen + + [self addInnerAnalogSticksToOuterAnalogLayers]; + + [self.layoutOSC.layoutChanges removeAllObjects]; // since a new OSC profile is being loaded, this will remove all previous layout changes made from the array + + [self OSCLayoutChanged]; // fades the 'Undo Button' out + }; + [self presentViewController:vc animated:YES completion:nil]; +} + + +#pragma mark - Touch + +- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { + for (UITouch* touch in touches) { + + CGPoint touchLocation = [touch locationInView:self.view]; + touchLocation = [[touch view] convertPoint:touchLocation toView:nil]; + CALayer *layer = [self.view.layer hitTest:touchLocation]; + + if (layer == self.toolbarRootView.layer || + layer == self.chevronView.layer || + layer == self.chevronImageView.layer || + layer == self.toolbarStackView.layer || + layer == self.view.layer) { // don't let user move toolbar or toolbar UI buttons, toolbar's chevron 'pull tab', or the layer associated with this VC's view + return; + } + } + [self.layoutOSC touchesBegan:touches withEvent:event]; +} + +- (void) touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event { + [self.layoutOSC touchesMoved:touches withEvent:event]; + + if ([self.layoutOSC isLayer:self.layoutOSC.layerBeingDragged + hoveringOverButton:trashCanButton]) { // check if user is dragging around a button and hovering it over the trash can button + trashCanButton.tintColor = [UIColor redColor]; + } + else { + trashCanButton.tintColor = [UIColor colorWithRed:171.0/255.0 green:157.0/255.0 blue:255.0/255.0 alpha:1]; + } +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { + if (self.layoutOSC.layerBeingDragged != nil && + [self.layoutOSC isLayer:self.layoutOSC.layerBeingDragged hoveringOverButton:trashCanButton]) { // check if user wants to throw OSC button into the trash can + + self.layoutOSC.layerBeingDragged.hidden = YES; + + if ([self.layoutOSC.layerBeingDragged.name isEqualToString:@"dPad"]) { // if user is hiding dPad, then hide all four dPad button child layers as well since setting the 'hidden' property on the parent dPad CALayer doesn't automatically hide the four child CALayer dPad buttons + self.layoutOSC._upButton.hidden = YES; + self.layoutOSC._rightButton.hidden = YES; + self.layoutOSC._downButton.hidden = YES; + self.layoutOSC._leftButton.hidden = YES; + } + + /* if user is hiding left or right analog sticks, then hide their corresponding inner analog stick child layers as well since setting the 'hidden' property on the parent analog stick doesn't automatically hide its child inner analog stick CALayer */ + if ([self.layoutOSC.layerBeingDragged.name isEqualToString:@"leftStickBackground"]) { + self.layoutOSC._leftStick.hidden = YES; + } + if ([self.layoutOSC.layerBeingDragged.name isEqualToString:@"rightStickBackground"]) { + self.layoutOSC._rightStick.hidden = YES; + } + + trashCanButton.tintColor = [UIColor colorWithRed:171.0/255.0 green:157.0/255.0 blue:255.0/255.0 alpha:1]; + } + [self.layoutOSC touchesEnded:touches withEvent:event]; +} + +@end diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.h b/Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.h new file mode 100644 index 00000000..448d0f87 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.h @@ -0,0 +1,24 @@ +// +// OSCProfilesTableViewController.h +// Moonlight +// +// Created by Long Le on 11/28/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + This view displays a list of on screen controller profiles and gives the user the ability to select any of the profiles to be the 'Selected' profile whose on screen controller layout configuration will be shown on the game stream view, or in the on screen controller layout view. This view also allows the user to swipe and delete any of the listed profiles. + */ +@interface OSCProfilesTableViewController : UIViewController + +@property (weak, nonatomic) IBOutlet UITableView *tableView; +@property (nonatomic, copy) void (^didDismissOSCProfilesTVC)(void); + + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.m b/Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.m new file mode 100644 index 00000000..6d1c09a8 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/OSCProfilesTableViewController.m @@ -0,0 +1,158 @@ +// +// OSCProfilesTableViewController.m +// Moonlight +// +// Created by Long Le on 11/28/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import "OSCProfilesTableViewController.h" +#import "LayoutOnScreenControlsViewController.h" +#import "ProfileTableViewCell.h" +#import "OSCProfile.h" +#import "OnScreenButtonState.h" +#import "OSCProfilesManager.h" + +const double NAV_BAR_HEIGHT = 50; + +@interface OSCProfilesTableViewController () + +@end + +@implementation OSCProfilesTableViewController { + OSCProfilesManager *profilesManager; +} + +@synthesize tableView; + +- (void) viewDidLoad { + [super viewDidLoad]; + + profilesManager = [OSCProfilesManager sharedManager]; + + self.tableView.tableHeaderView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, NAV_BAR_HEIGHT)]; + + self.tableView.delegate = self; + self.tableView.dataSource = self; + + [self.tableView registerNib:[UINib nibWithNibName:@"ProfileTableViewCell" bundle:nil] + forCellReuseIdentifier:@"Cell"]; // Register the custom cell nib file with the table view + +} + +- (void) viewDidAppear:(BOOL)animated { + [super viewDidAppear: animated]; + + if ([[profilesManager getAllProfiles] count] > 0) { // scroll to selected profile if user has any saved profiles + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:[profilesManager getIndexOfSelectedProfile] inSection:0]; + [self.tableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionMiddle animated:YES]; + } +} + + +#pragma mark - UIButton Actions + +/* Loads the OSC profile that user selected, dismisses this VC, then tells the presenting view controller to lay out the on screen buttons according to the selected profile's instructions */ +- (IBAction) loadTapped:(id)sender { + [self dismissViewControllerAnimated:YES completion:nil]; + + if (self.didDismissOSCProfilesTVC) { // tells the presenting view controller to lay out the on screen buttons according to the selected profile's instructions + self.didDismissOSCProfilesTVC(); + } +} + +- (IBAction) cancelTapped:(id)sender { + [self dismissViewControllerAnimated:YES completion:nil]; +} + + +#pragma mark - TableView DataSource + +- (NSInteger) tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return [[profilesManager getAllProfiles] count]; +} + +- (UITableViewCell *) tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + ProfileTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath]; + OSCProfile *profile = [[profilesManager getAllProfiles] objectAtIndex: indexPath.row]; + cell.name.text = profile.name; + + if ([profile.name isEqualToString: [profilesManager getSelectedProfile].name]) { // if this cell contains the name of the currently selected OSC profile then add a checkmark to the right side of the cell + cell.accessoryType = UITableViewCellAccessoryCheckmark; + } + else { + cell.accessoryType = UITableViewCellAccessoryNone; + } + return cell; +} + +- (BOOL) tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { + return YES; +} + +- (void) tableView:(UITableView *)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { + NSMutableArray *profiles = [profilesManager getAllProfiles]; + + if ([[[profiles objectAtIndex:indexPath.row] name] isEqualToString:@"Default"]) { // if user is attempting to delete the 'Default' profile then show a pop up telling user they can't do that and return out of this method + UIAlertController *alertController = [UIAlertController alertControllerWithTitle: [NSString stringWithFormat:@""] message: @"Deleting the 'Default' profile is not allowed" preferredStyle:UIAlertControllerStyleAlert]; + + [alertController addAction:[UIAlertAction actionWithTitle:@"Ok" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + [alertController dismissViewControllerAnimated:NO completion:nil]; + }]]; + [self presentViewController:alertController animated:YES completion:nil]; + + return; + } + + if (editingStyle == UITableViewCellEditingStyleDelete) { + OSCProfile *profile = [profiles objectAtIndex:indexPath.row]; + if (profile.isSelected) { // if user is deleting the currently selected OSC profile then make the profile at its previous index the currently selected profile + if (indexPath.row > 0) { // check that row is greater than zero to avoid an out of bounds crash, although that should not be possible right now since the 'Default' profile is always at row 0 and they're not allowed to delete it + OSCProfile *profile = [profiles objectAtIndex:indexPath.row - 1]; + profile.isSelected = YES; + } + } + + [profiles removeObjectAtIndex:indexPath.row]; + + /* save OSC profiles array to persistent storage */ + NSMutableArray *profilesEncoded = [[NSMutableArray alloc] init]; + for (OSCProfile *profileDecoded in profiles) { // encode each OSC profile object and add them to an array + + NSData *profileEncoded = [NSKeyedArchiver archivedDataWithRootObject:profileDecoded requiringSecureCoding:YES error:nil]; + [profilesEncoded addObject:profileEncoded]; + } + NSData *data = [NSKeyedArchiver archivedDataWithRootObject:profilesEncoded + requiringSecureCoding:YES error:nil]; // encode the array itself, NOT the objects in the array + [[NSUserDefaults standardUserDefaults] setObject:data forKey:@"OSCProfiles"]; + [[NSUserDefaults standardUserDefaults] synchronize]; + + [tableView reloadData]; + } +} + + +#pragma mark - TableView Delegate + +/* When user taps a cell it moves the checkmark to that cell indicating to the user the profile associated with that cell is now the selected profile. It also sets that cell's associated OSCProfile object's 'isSelected' property to YES */ +- (void) tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + NSIndexPath *selectedIndexPath = [NSIndexPath indexPathForRow:indexPath.row inSection:0]; + NSIndexPath *lastSelectedIndexPath = [NSIndexPath indexPathForRow:[profilesManager getIndexOfSelectedProfile] inSection:0]; + + if (selectedIndexPath != lastSelectedIndexPath) { + /* Place checkmark on selected cell and set profile associated with cell as selected profile */ + UITableViewCell *selectedCell = [tableView cellForRowAtIndexPath: selectedIndexPath]; + selectedCell.accessoryType = UITableViewCellAccessoryCheckmark; // add checkmark to the cell the user tapped + OSCProfile *profile = [[profilesManager getAllProfiles] objectAtIndex:indexPath.row]; + [profilesManager setProfileToSelected: profile.name]; // set the profile associated with this cell's 'isSelected' property to YES + + /* Remove checkmark on the previously selected cell */ + UITableViewCell *lastSelectedCell = [tableView cellForRowAtIndexPath: lastSelectedIndexPath]; + lastSelectedCell.accessoryType = UITableViewCellAccessoryNone; + [tableView deselectRowAtIndexPath:lastSelectedIndexPath animated:YES]; + } +} + + +@end diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.h b/Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.h new file mode 100644 index 00000000..a6063db2 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.h @@ -0,0 +1,18 @@ +// +// ToolBarContainerView.h +// Moonlight +// +// Created by Long Le on 12/10/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/* Allows the ToolBar in the 'LayoutOnScreenControlsViewController' to accept touches on the small UIView 'tab' protruding from its bottom center*/ +@interface ToolBarContainerView : UIView + +@end + +NS_ASSUME_NONNULL_END diff --git a/Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.m b/Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.m new file mode 100644 index 00000000..c08fc758 --- /dev/null +++ b/Limelight/ViewControllers/Custom OnScreen Controls/ToolBarContainerView.m @@ -0,0 +1,26 @@ +// +// ToolBarContainerView.m +// Moonlight +// +// Created by Long Le on 12/10/22. +// Copyright © 2022 Moonlight Game Streaming Project. All rights reserved. +// + +#import "ToolBarContainerView.h" + +@implementation ToolBarContainerView + +- (BOOL) pointInside:(CGPoint)point withEvent:(UIEvent *)event { + + for (UIView *view in self.subviews) { + + if (!view.hidden && view.alpha > 0 && + view.userInteractionEnabled && + [view pointInside:[self convertPoint:point toView:view] withEvent:event]) + return YES; + } + + return NO; +} + +@end diff --git a/Limelight/ViewControllers/SettingsViewController.h b/Limelight/ViewControllers/SettingsViewController.h index af3caff0..f3859b28 100644 --- a/Limelight/ViewControllers/SettingsViewController.h +++ b/Limelight/ViewControllers/SettingsViewController.h @@ -8,6 +8,7 @@ #import #import "AppDelegate.h" +#import "LayoutOnScreenControlsViewController.h" @interface SettingsViewController : UIViewController @property (strong, nonatomic) IBOutlet UILabel *bitrateLabel; @@ -26,6 +27,7 @@ @property (strong, nonatomic) IBOutlet UISegmentedControl *btMouseSelector; @property (strong, nonatomic) IBOutlet UISegmentedControl *statsOverlaySelector; @property (strong, nonatomic) IBOutlet UIScrollView *scrollView; +@property (strong, nonatomic) LayoutOnScreenControlsViewController *layoutOnScreenControlsVC; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wunguarded-availability" diff --git a/Limelight/ViewControllers/SettingsViewController.m b/Limelight/ViewControllers/SettingsViewController.m index 49be8632..1928acb5 100644 --- a/Limelight/ViewControllers/SettingsViewController.m +++ b/Limelight/ViewControllers/SettingsViewController.m @@ -13,9 +13,12 @@ #import #import + @implementation SettingsViewController { NSInteger _bitrate; NSInteger _lastSelectedResolutionIndex; + NSInteger previouslySelectedSegmentIndex; + UITapGestureRecognizer *tapGesture; } @dynamic overrideUserInterfaceStyle; @@ -81,11 +84,12 @@ CGSize resolutionTable[RESOLUTION_TABLE_SIZE]; // indicators. Ignore any views we don't recognize. if (![view isKindOfClass:[UILabel class]] && ![view isKindOfClass:[UISegmentedControl class]] && - ![view isKindOfClass:[UISlider class]]) { + ![view isKindOfClass:[UISlider class]] && + ![view isKindOfClass:[UIButton class]]) { continue; } - CGFloat currentViewY = view.frame.origin.y + view.frame.size.height; + CGFloat currentViewY = view.frame.origin.y; if (currentViewY > highestViewY) { highestViewY = currentViewY; } @@ -258,6 +262,22 @@ BOOL isCustomResolution(CGSize res) { [self.bitrateSlider addTarget:self action:@selector(bitrateSliderMoved) forControlEvents:UIControlEventValueChanged]; [self updateBitrateText]; [self updateCustomResolutionText]; + + tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(OSCSegmentControlReselected:)]; // detects when OSC segmented control button is re-selected + [self.onscreenControlSelector addGestureRecognizer:tapGesture]; + + /* sets a reference to the correct 'LayoutOnScreenControlsViewController' depending on whether the user is on an iPhone or iPad */ + self.layoutOnScreenControlsVC = [[LayoutOnScreenControlsViewController alloc] init]; + BOOL isIPhone = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone); + if (isIPhone) { + UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"iPhone" bundle:nil]; + self.layoutOnScreenControlsVC = [storyboard instantiateViewControllerWithIdentifier:@"LayoutOnScreenControlsViewController"]; + } + else { + UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"iPad" bundle:nil]; + self.layoutOnScreenControlsVC = [storyboard instantiateViewControllerWithIdentifier:@"LayoutOnScreenControlsViewController"]; + self.layoutOnScreenControlsVC.modalPresentationStyle = UIModalPresentationFullScreen; + } } - (void) touchModeChanged { @@ -497,9 +517,60 @@ BOOL isCustomResolution(CGSize res) { #pragma mark - Navigation +/* detects a tap on the 'Custom' segment of the OnScreen Controls 'UISegmentControl' object. It will load the appropriate VC if the 'Custom' segment is tapped */ +- (IBAction)OSCSegmentedControlsTapped:(id)sender { + UISegmentedControl *segmentedControl = (UISegmentedControl*) sender; + + LayoutOnScreenControlsViewController *vc = [[LayoutOnScreenControlsViewController alloc] init]; + BOOL isIPhone = ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone); + if (isIPhone) { + UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"iPhone" bundle:nil]; + vc = [storyboard instantiateViewControllerWithIdentifier:@"LayoutOnScreenControlsViewController"]; + } + else { + UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"iPad" bundle:nil]; + vc = [storyboard instantiateViewControllerWithIdentifier:@"LayoutOnScreenControlsViewController"]; + vc.modalPresentationStyle = UIModalPresentationFullScreen; + } -- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender { + switch ([segmentedControl selectedSegmentIndex]) { + case 0: + break; + case 1: + break; + case 2: + break; + case 3: + break; + case 4: + if (self.layoutOnScreenControlsVC.isBeingPresented == NO) { + [self presentViewController:self.layoutOnScreenControlsVC animated:YES completion:nil]; + } + break; + } } +/* Used to detect when user taps on an on screen control UISegmentedControl button, even if that button is already selected */ +- (void)OSCSegmentControlReselected:(id)sender { + + CGPoint point = [tapGesture locationInView:self.onscreenControlSelector]; + NSUInteger segmentSize = self.onscreenControlSelector.bounds.size.width / self.onscreenControlSelector.numberOfSegments; + + + NSUInteger touchedSegment = point.x / segmentSize; // Warning: If you are using segments not equally sized, you have to adapt the code here + + if (self.onscreenControlSelector.selectedSegmentIndex != touchedSegment) { // normal behavior, previously unselected button is selected + self.onscreenControlSelector.selectedSegmentIndex = touchedSegment; + + } else { // already selected button is tapped again + if (self.onscreenControlSelector.selectedSegmentIndex == 4) { + if (self.layoutOnScreenControlsVC.isBeingPresented == NO) { + [self presentViewController:self.layoutOnScreenControlsVC animated:YES completion:nil]; + } + } + } + + [self OSCSegmentedControlsTapped:self.onscreenControlSelector]; // must call selector because the UIControlEventValueChanged can't work together with UITapGestureRecognizer +} @end diff --git a/iPad.storyboard b/Limelight/iPad.storyboard similarity index 67% rename from iPad.storyboard rename to Limelight/iPad.storyboard index d0f87e2d..b563e4e3 100644 --- a/iPad.storyboard +++ b/Limelight/iPad.storyboard @@ -1,9 +1,9 @@ - + - + @@ -141,16 +141,20 @@ - + + + + + + + + + + + + + + + + diff --git a/iPhone.storyboard b/Limelight/iPhone.storyboard similarity index 64% rename from iPhone.storyboard rename to Limelight/iPhone.storyboard index 312542e6..24a35654 100644 --- a/iPhone.storyboard +++ b/Limelight/iPhone.storyboard @@ -1,9 +1,10 @@ - + - + + @@ -12,7 +13,7 @@ - + @@ -82,7 +83,7 @@ - + - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -308,14 +194,14 @@ - + @@ -324,9 +210,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -348,14 +354,220 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -406,7 +618,7 @@ - + @@ -418,7 +630,20 @@ - + + + + + + + + + + + + + +