From a1b592611d081319e4e5e51baadce5edf46dc395 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Fri, 31 Aug 2018 00:42:24 +0300 Subject: [PATCH] no message --- .../BackwardButton.imageset/Contents.json | 22 ++ .../VideoPlayerBackwardIcon@2x.png | Bin 0 -> 903 bytes .../VideoPlayerBackwardIcon@3x.png | Bin 0 -> 1264 bytes .../ForwardButton.imageset/Contents.json | 22 ++ .../VideoPlayerForwardIcon@2x.png | Bin 0 -> 945 bytes .../VideoPlayerForwardIcon@3x.png | Bin 0 -> 1298 bytes Images.xcassets/Open In/Contents.json | 9 + .../Open In/Maps.imageset/Contents.json | 22 ++ .../Maps.imageset/ShareSearchIcon@2x.png | Bin 0 -> 1123 bytes .../Maps.imageset/ShareSearchIcon@3x.png | Bin 0 -> 758 bytes .../Open In/Safari.imageset/Contents.json | 22 ++ .../Safari.imageset/OpenInSafariIcon@2x.png | Bin 0 -> 14795 bytes .../Open In/Safari.imageset/Safari@3x.png | Bin 0 -> 9119 bytes TelegramUI.xcodeproj/project.pbxproj | 101 ++++++ TelegramUI/ChatController.swift | 21 +- .../ChatItemGalleryFooterContentNode.swift | 69 +++- .../ChatMessageAttachedContentNode.swift | 4 +- .../ChatMessageWebpageBubbleContentNode.swift | 28 +- .../ChatRecentActionsControllerNode.swift | 3 + .../ChatVideoGalleryItemScrubberView.swift | 10 +- TelegramUI/GalleryControllerNode.swift | 4 +- TelegramUI/GenericEmbedImplementation.swift | 69 ++++ TelegramUI/GroupInfoController.swift | 5 +- TelegramUI/InstantPageControllerNode.swift | 9 +- .../InstantPageSettingsButtonItemNode.swift | 43 +++ TelegramUI/InstantPageSettingsNode.swift | 18 +- TelegramUI/LegacyComponentsStickers.swift | 2 + TelegramUI/MediaPlayer.swift | 1 - TelegramUI/MediaPlayerScrubbingNode.swift | 10 +- TelegramUI/MediaResources.swift | 48 +++ TelegramUI/OpenChatMessage.swift | 32 +- TelegramUI/OpenInActionSheetController.swift | 208 ++++++++++++ TelegramUI/OpenInAppIconResources.swift | 59 ++++ TelegramUI/OpenInOptions.swift | 218 +++++++++++++ .../PeerMediaCollectionController.swift | 5 +- TelegramUI/PhotoResources.swift | 98 ++++++ TelegramUI/Resources/WebEmbed/Generic.html | 28 ++ .../Resources/WebEmbed/GenericUserScript.js | 26 ++ TelegramUI/Resources/WebEmbed/Instagram.html | 40 +++ TelegramUI/Resources/WebEmbed/Twitch.html | 47 +++ .../Resources/WebEmbed/TwitchUserScript.js | 114 +++++++ TelegramUI/Resources/WebEmbed/Vimeo.html | 101 ++++++ .../Resources/WebEmbed/VimeoUserScript.js | 53 ++++ TelegramUI/Resources/WebEmbed/Youtube.html | 99 ++++++ .../Resources/WebEmbed/YoutubeUserScript.js | 58 ++++ .../SoundCloudEmbedImplementation.swift | 59 ++++ .../StreamableEmbedImplementation.swift | 5 + .../TelegramAccountAuxiliaryMethods.swift | 2 + TelegramUI/TwitchEmbedImplementation.swift | 5 + TelegramUI/UniversalVideoCalleryItem.swift | 36 ++- TelegramUI/VKEmbedImplementation.swift | 5 + TelegramUI/VimeoEmbedImplementation.swift | 208 ++++++++++++ TelegramUI/WebEmbedPlayerNode.swift | 148 +++++++++ TelegramUI/WebEmbedVideoContent.swift | 175 +++-------- TelegramUI/YoutubeEmbedImplementation.swift | 295 ++++++++++++++++++ 55 files changed, 2477 insertions(+), 189 deletions(-) create mode 100644 Images.xcassets/Media Gallery/BackwardButton.imageset/Contents.json create mode 100644 Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@2x.png create mode 100644 Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@3x.png create mode 100644 Images.xcassets/Media Gallery/ForwardButton.imageset/Contents.json create mode 100644 Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@2x.png create mode 100644 Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@3x.png create mode 100644 Images.xcassets/Open In/Contents.json create mode 100644 Images.xcassets/Open In/Maps.imageset/Contents.json create mode 100644 Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@2x.png create mode 100644 Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@3x.png create mode 100644 Images.xcassets/Open In/Safari.imageset/Contents.json create mode 100644 Images.xcassets/Open In/Safari.imageset/OpenInSafariIcon@2x.png create mode 100644 Images.xcassets/Open In/Safari.imageset/Safari@3x.png create mode 100644 TelegramUI/GenericEmbedImplementation.swift create mode 100644 TelegramUI/InstantPageSettingsButtonItemNode.swift create mode 100644 TelegramUI/OpenInActionSheetController.swift create mode 100644 TelegramUI/OpenInAppIconResources.swift create mode 100644 TelegramUI/OpenInOptions.swift create mode 100755 TelegramUI/Resources/WebEmbed/Generic.html create mode 100644 TelegramUI/Resources/WebEmbed/GenericUserScript.js create mode 100755 TelegramUI/Resources/WebEmbed/Instagram.html create mode 100755 TelegramUI/Resources/WebEmbed/Twitch.html create mode 100644 TelegramUI/Resources/WebEmbed/TwitchUserScript.js create mode 100755 TelegramUI/Resources/WebEmbed/Vimeo.html create mode 100644 TelegramUI/Resources/WebEmbed/VimeoUserScript.js create mode 100755 TelegramUI/Resources/WebEmbed/Youtube.html create mode 100644 TelegramUI/Resources/WebEmbed/YoutubeUserScript.js create mode 100644 TelegramUI/SoundCloudEmbedImplementation.swift create mode 100644 TelegramUI/StreamableEmbedImplementation.swift create mode 100644 TelegramUI/TwitchEmbedImplementation.swift create mode 100644 TelegramUI/VKEmbedImplementation.swift create mode 100644 TelegramUI/VimeoEmbedImplementation.swift create mode 100644 TelegramUI/WebEmbedPlayerNode.swift create mode 100644 TelegramUI/YoutubeEmbedImplementation.swift diff --git a/Images.xcassets/Media Gallery/BackwardButton.imageset/Contents.json b/Images.xcassets/Media Gallery/BackwardButton.imageset/Contents.json new file mode 100644 index 0000000000..714534a855 --- /dev/null +++ b/Images.xcassets/Media Gallery/BackwardButton.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "VideoPlayerBackwardIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "VideoPlayerBackwardIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@2x.png b/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..dd3b84b8496ec33cd1c9bc4c197ead0dd63d2fdb GIT binary patch literal 903 zcmV;219<$2P)##!&PZQHhO+qPAm`~H*kOD2;!onm-1|K#^Huc!TPPu)Y} z+$%0FE-o&vtgP&4^g;p`*&II-osq!xQ5)_@9DsC}<@gua2kD8Z40bD)!`fZhx+yMpY%OHnq4h3nH<@iuIb&t)u ziy$ov3#gf!mG3;!hQ01jKjc|ZlD zrBE=Z(8A#kOj_jTfIT5iFLprwe5z%bC1`=(NSXfqZOyv>LfYWwfDIunD`CK^uqmsd z&Ao62Mr$?eHh{F;?bmzKw&J&_~lQjOt6?hga zAXT+l^BzbF=ej*$S4f*mc$jz(3c3~)asI9hc)!zI2$pMVOI9PzTFa0&t+yLhH0zy+ z6r?s@JzH4|X^Ts1o|?!1=?y7^v%MPd2`EUFe0mcr1F56eXE+y0X z9`G_GAtiC6M;{NYhNSSdU#}dVg@TmATQ07D3AVyq3%_3;{b?Cep)n3EaozWjfVrtb zekPHDxxM(c$TzB1+WgRl1pN-$V@SZ-evHK1I9zku`_KtnHLndTgynt9b2Xn1NeXG~ z!A>NhpldZA3hS2<=0Ty*6t?1{D7+JWCMseH(hk?`CbY$^QGV dadB~R2LWT}>0{OMatQzc002ovPDHLkV1iK{wax$l literal 0 HcmV?d00001 diff --git a/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@3x.png b/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9b278a7135ef3b42b42d87a369c10cbf155407d4 GIT binary patch literal 1264 zcmVY3?elMP;WQih1V7_(9W19 zC435(DN3NLVF&avOV15# zNTIG_aT;hBwO+S59VgUjGZoh;Qm_Z(44Z(W)ay3K;Mf|$&cU@56#X>NF0Ojr=0>cb ziMhCrf+C*=evh+Wx7ik_qlx*2+Ec}0-3|ASyMrP^~cAs?>jnVfnC59~Poym%IhJ9R7_yf_0$X6A1KSl)Rm{r=k)D@ht-# z6{wkwuUdF$o&Kz3SpS4P1v*Whv+!;isKrI3y^0CLXqJadsxW zN4Ry9e6R9~WuV)Nnk#W0n(%QvZlo+RT$p;K5kzmC7qD-FfgUev%9d}Utfwq6%m$23 zBha}RWL|=SUXPMi{0Z;DmvN9`PaCNOmN)pbvhXZiN-)sp<+^Zl(`!H?R3G--qeXif z_81ShR2fCzdwPbwbI&Zlh}Ap8f%Zm8&n$It^D0~XzDy2sn~$i?v)0icQR3tA8S3Nl z=%eJvtbRYK5hbm-bq4ykweE|g5m(Fdmt{SalyIjM`?V96P~zb}DyxXcKS>!+PifAo zj1nKO$;-ssyJ*74$N%RMF&|C5o447__BBEsgL@~q?!9qLjWFA*JfbJh{2Iab#w}yM zVeegMoi8iM`)iApI?vui@D5xl!tz(rZHI^;B^A85Nbj|mupT~6#CP#Z+#Z(`!+6`{ zmAYo4Zc~qve8w+As7vANSjMpU+OU-MI+s#KMLz)ih#n?|r`NHXDme1_{|{`zxM aIR5|&`zPDdcFTVN0000uU2Bt(#)SiWMtX ztXS?A7Z*1fo$!d^_$c(iwwZ?e5M&tT7j>lbwKI5 zeA^}(P_pFPHd2SW1K*n8K14s9hm+Nhgu$l3-z;2#U48LHxCXlg zm2eq}+l3A|4qeOf71l%78hjtluiAr{tvPuN!pAlznG-0CTMTE>(?~ z!&^3IQ48JhnC3bt^~F=+{&~9}DDzd^no4ab*->)X*ZU@u4(@_1SQC~mPi z{zxQU1~MUj1(VZF^8+{msaJA)DyR%;7amAX!`*`;kh0sXeHA4=zSi6jl=R~Ma159Y z(r(;lSeF-PA=OQEyav4WLO2HO2dT2=_~RG_$uwYlsJ3IA!Q-JG?q(c@^s#1*40i(A zZ3Y|$RkcX^`Fj)65!|dZ^H7I*)ud`#y~<9zQ!dU~xzt0v(V)-wnw^Z{$Nc?1AbUJ~iz7 zH>Al<4wwT{zaR#D3==$ZXwJe%p&DV>H#?*OP7e4xq#1z@=q=WEgle1NctuFfoE-37 zNSlNB3OLx`?iKu#_8w3t58{ZD!>6hh4ho=)yGPxCK&cz0V$Q!XVRIF z@&`EJt=J9KKBUB>cn1R^rPu8F0;J2h-{}EMLYff7W#V&OfT?aieEf-STnk@flq{V)(x=q8O&(D<zsUziy|9;fRdFZ-^eOQWl*p7=ZH4Lvs;=9vl&;=LaZx*KG z%V>B>d@s_VHx9yrt}~d38_NI%&QKl|XM^?zEP&V^ns0dqySqohC>RB!U=#oV5vZjg T_V8iP00000NkvXXu0mjfh=IY+ literal 0 HcmV?d00001 diff --git a/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@3x.png b/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b4f4360d331b502e3616439df66a2ca8c4857006 GIT binary patch literal 1298 zcmV+t1?~EYP)CL( z2HAa){?Qb-U}j&@A3^QB%=kD6-)K5zGBhQJ;;>M;r!d1Eg> zT#e7$|DL%Yj@p2TI*!ppez>|nZ~ukPfTM;QI7S={MFJ1Z+iIB=;HVLr%J4E{;x**P zYAUxDx*Kxa;ZIW;US<)QywATg%J^xh=`Svhc6T2<6KIF={xJ^Lx!4Ht`g%0~07P<^#9}(D_ z`GhzMF)y79oh!W^tEEFCyM6oq3SygF+-5z*wxYPsg>DgM`uW8dqs)+NqjRBO&a< z;^{btV-VYH#zL1v?2-o;8Xpw}p}jd?7l^geaSoRuw%iK89lh|>LJb}-iP7UR^8#Ws z%vfl5$c4ASLW4kTp^i%^<+@qr2f5rHddN4z$p7eW@E$r&$K{lA<;_@V637j8<2dPb zEOaKs;(2qtZV;>I;_nZIToSY1i#~zaRS(@m%R}rq!Z`W$;IlCI1(99+{i_grZ04K$ zMUYG4frSRdS%|&FP}D{XEQA-t2D$MlTV&=!t3a-cZ!T2&$3KJKHY1>mzug6LRm@yy zP`rcK0|fKMg~~s<%!3!4^$o*xEHfA$Kv)YMvg4}zgu+TZCzS{375Mu--vyJqZ-=nN4$P; zlou8tjtBpC94vqr9M#Xx7ejo3qrPGk!u#?Y_EGo>M}0v}yI&BI1&`sZx0s5e2<*o3 z1EVOWf@3-DF|uejPX1Ek7+m!p%h3=y5D5XLukj)w2O45I-osVL^nS*-+tqq?a34nd z3>fY{>cGF|$C5t)bi!+Rs@Ldb!W(weKmYf{b@*1-(G%gczd06=75#7k-=VkfIDmf0 zs;Qj5Y9~iIv_?-1`;VSzjdDniz?#KVsZ=VJN~Kb%R4Ua!09i}X)F}5TEdT%j07*qo IM6N<$g3_FKumAu6 literal 0 HcmV?d00001 diff --git a/Images.xcassets/Open In/Contents.json b/Images.xcassets/Open In/Contents.json new file mode 100644 index 0000000000..38f0c81fc2 --- /dev/null +++ b/Images.xcassets/Open In/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "provides-namespace" : true + } +} \ No newline at end of file diff --git a/Images.xcassets/Open In/Maps.imageset/Contents.json b/Images.xcassets/Open In/Maps.imageset/Contents.json new file mode 100644 index 0000000000..d5bf557e5a --- /dev/null +++ b/Images.xcassets/Open In/Maps.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ShareSearchIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ShareSearchIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@2x.png b/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..289227ed05d517509c252be414519250f38d9c43 GIT binary patch literal 1123 zcmV-p1f2VcP)Px(9Z5t%R9FekSiMgaK@@+p2r&^tL!}|cV5u=;WoK*zVyJ9R!aw0x0S7rfu<+}T zfB=m&MnM~6rIi??rNkH`JpmH|x$*a#+3}X+SZ0=myLXwL*_rq8f12%2rYstjW&l-Q6|)Pf7ReD>N5zX35*J83cm{4J?MEFq$k0=8!*9EXzfNgk~>*J?*z;+Q1TLrBlJI|6}v%6eGNxgM`BE$ha+A= zcQ#B$m&iq8f%-71++ zW6pJr35dj0&+~~VMG)XYhO}lfm>nPfli89sNmUHyHSNI|?ui+e_Dr{F8nUk0)V>sp zI}H13qlbB5;U{MgG5d<-$lz=`3?8bP(JWwh>hciEjm6n?7(5gw6Nd+T<5?II)${C8 z<8hvK7(DDVBrM>7IP%cz$Kq@{3?5PpiRGBDS9th7Dzdg%osb*``GF2RSf%@h)i{B3r23>ENhO2oGsdBh3!d^DaEzCj`@d8S>q|3nT@3r>pe@0 zA=d$RTWgUAkk$CL;q=5B>~M)TY;nrkrW|6-0_MGeEyXEYfR_hDlmziw-7HF(U1vki z!W6N9IpVzm%ND9(!C2DgIGHO^Cw0??J^0XO5LE%&OO66mu|RB>E$kk7f@VEOovw0L zvVpp>H3%)3tAHuVOAg)}kXYSr$%kCRgJDn+7N6Zlx7FVT{qNwEv;XHj2DYZFWjw5l zlv+D{SMm#fzJjCew|dL9bM*m}tNvBh6b#H~ysBD*>;}BtRSO~n^xlA}HSfzn#w^1? z%c-oO(*ZlJ_8MRup6G+IgI%{WW)A?`53mEC*M;^0K!*W#!1L%cf^HmO{?QpgHx_UP z(2WNi2)a1{2Y_xKz@DIYo+T*MNMd?snr6DLB_^9K8Eh`V1i2UJNbcjd{#kQ{MAECt pEstO)R4KXKoMVC>)wh!1{sC|mfX>2)w}1cu002ovPDHLkV1ipb4jcdg literal 0 HcmV?d00001 diff --git a/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@3x.png b/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..837458279a868bdc76fccbc7533bac72f406ecbc GIT binary patch literal 758 zcmeAS@N?(olHy`uVBq!ia0vp^P9V&|3?#2~eYgdr76tf(xB_X0hW`vT{~4;lsQNzx z1Od5l29OO`0}%vr;R+xkwO}*GS-<==*_k!*=P%Lb z4a)zj{0)tcrc3_y`(R$tAgP-)hk=3dtfz}(NCo5D>8+b?8wj}O&v;^BY<&3t|ESW_ zTZJm`=j^mSJZo-plu=C5M;D{1=TeMch8PLxHMKh`R=2HY+TqnbM$>o~-7t7l*~fb3r0SwbBY$R_g(qqv6laNkUd%CFLOYv- z)A?Eq51(b)R~eyaXTHkFeEK)LAW!+d9lP}z4kNcWj^`#HiM-30!m{SCg_FvgMCKqV zGpj}c&+3YnDT{mklw3~9ZBtm9x;U6);F)YCRe7oG<)&p zUAtdclrO9hf2F_ZMEA1U;=kTpx!$oa=<8pn`&+;44ZeTyYq5*{*%0Ha#y7uTocy=u z;#=`2Rl#|db?qtFKUuR#&wnlOZZ6|@zoIgke{a9v$eO=vdD-h9mA`Fc)(N%Ov+w7W z`Df4AdHBD*Qu&YnT~GL?|L^~^Xm!*FLqul2s$qNM7B52V^d||{9aH3_H^}g JS?83{1OWRiVRrxk literal 0 HcmV?d00001 diff --git a/Images.xcassets/Open In/Safari.imageset/Contents.json b/Images.xcassets/Open In/Safari.imageset/Contents.json new file mode 100644 index 0000000000..af75ade06d --- /dev/null +++ b/Images.xcassets/Open In/Safari.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "OpenInSafariIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "Safari@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Open In/Safari.imageset/OpenInSafariIcon@2x.png b/Images.xcassets/Open In/Safari.imageset/OpenInSafariIcon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cecc92b072e66d72e050346fef753e412a7ae09 GIT binary patch literal 14795 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 literal 0 HcmV?d00001 diff --git a/Images.xcassets/Open In/Safari.imageset/Safari@3x.png b/Images.xcassets/Open In/Safari.imageset/Safari@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..ea18cf1ea2dc5d189153775df3e5d964f097d6ce GIT binary patch literal 9119 zcmaKSWl$X5)-JBWeF!iN1a}GUE`tRE!EJDuVQ_Z~4k37e;1JxM5S-xd1eXL0PJm0! zci!{eANSm@+P$UL^DNn`y1Vx3C=E3Q94r790s;b#lA^5EbMN`LVIV!<8`1K@o;wOx zIX%}mP%BptGdKi6$`Wc00V+9~SwplSW|m&gLl98}1Y{;#Z9P{#u&S^H)RDvNuMCH$ zBkUQCfFLU22{W^>hqwaGA=b7|VxW_@E)dYxQVgWa59R{HWFR)Sir#R@8*epj3vYW1 zAxn^iI8fA6_?f^F;%Wx;baZfX5%v@V{fk%ldHi>o69oL1h^xIA=zomT18V?fpl}F~ zpW`*V1veiTP(X--`!yFIKQ|kYhl^W)lZ&5|>oq$Uw=kEWFb_BI-xugv8{E=LSW8y^ z-@2Y>Vjvq=SC}v-r-z3JhX*eQ6mHGQEhHqw$;HFT!^8e8!S3Saa6Hx%@j!&jI7~G=p(+b8!82=^sHb`2R2J z==dLL7gsIF|JwWiBzDpEfD+{%7bA&j#{)4srU+nAuiv61qj<9FVvJkkf zJH%2R4s`_n%W+}b|85KKf93l(*7CpG!uMaXoX^H^{@vdHYrFqhdd{K0!~Z1j^W;D2 z4{>_VdH8d3v-R|uA|L>9lw_r}J(qqOq5Bf|U9YFFcC;CKt1C*Y&}k7$6KHrdDWM!- zJZfs5S*Kb(xEo~p#X1<%$FyqqOq6B0WH{J~$?&6fz{)hq@Lf zl!9utr3q9%RLcsn!ca7aq9|S}2Ge6OVzsNRGvbq# zhrYr3AzkR1QZvdq90o!Y%tgGiNh5k+tw4W>k^!7Sa;{gsDG!yY?)+#OD^J;Hu5a--`@=!~6Z5KK$P_A5E!2`8S5LmQQ4xdt4C!UZo$%{1!1koU1pEMD>2@k3_*$&?3AU%qHnkWQrWH%F ze{eY^&I6BY&*ggoHSa;Rl$PNNy+|p&5lwEKT|8L=^?9!u;)bb`sVuy+s=9+BYrg9F`u9Vu^e|jZyFFIApeD!1X6<|Lyiy0+MX2m2pw>5{q9uW~6`LyUIhY8(*G4Xqzw=0=&PgB{i zOY{>hEM~)USF)wdP5FkCB0=<}lVzE;LI5H>Z;J}6X3n6YIi^sSkAnI=1Kw$`-=M$u zB6>icA82ZhcXDQkO86zo4NW573W`rT*AVQj`ylOr^rmEz-KB#&;VcBr75K9Q2#?7; zI6{#R%r;ChHINLc%<@lEDJ@fotsL5D9iEWs1Vt+2nr_lu(ycOAOj-?+>JC1qI-l8v zgLF&vt8rrSkBox6QQ-~=Elya(sHn#rT%wf~xMlv|<574%0AAsSq7LHY+o}mwBdbDK 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 literal 0 HcmV?d00001 diff --git a/TelegramUI.xcodeproj/project.pbxproj b/TelegramUI.xcodeproj/project.pbxproj index d49e1a8a6f..2f3c15a00f 100644 --- a/TelegramUI.xcodeproj/project.pbxproj +++ b/TelegramUI.xcodeproj/project.pbxproj @@ -7,6 +7,23 @@ objects = { /* Begin PBXBuildFile section */ + 0941A9A0210B057200EBE194 /* OpenInActionSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0941A99F210B057200EBE194 /* OpenInActionSheetController.swift */; }; + 0941A9A4210B0E2E00EBE194 /* OpenInAppIconResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0941A9A3210B0E2E00EBE194 /* OpenInAppIconResources.swift */; }; + 0941A9A6210B822D00EBE194 /* OpenInOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0941A9A5210B822D00EBE194 /* OpenInOptions.swift */; }; + 09797873210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09797872210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift */; }; + 0979787C210642CB0077D77F /* WebEmbedPlayerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0979787B210642CB0077D77F /* WebEmbedPlayerNode.swift */; }; + 0979787E210646C00077D77F /* YoutubeEmbedImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0979787D210646C00077D77F /* YoutubeEmbedImplementation.swift */; }; + 09874E4F21078FA100E190B8 /* Generic.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788321065F8C0077D77F /* Generic.html */; }; + 09874E5021078FA100E190B8 /* GenericUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788821065F8C0077D77F /* GenericUserScript.js */; }; + 09874E5121078FA100E190B8 /* Instagram.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788421065F8C0077D77F /* Instagram.html */; }; + 09874E5221078FA100E190B8 /* Twitch.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788521065F8C0077D77F /* Twitch.html */; }; + 09874E5321078FA100E190B8 /* TwitchUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788621065F8C0077D77F /* TwitchUserScript.js */; }; + 09874E5421078FA100E190B8 /* Vimeo.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788221065F8C0077D77F /* Vimeo.html */; }; + 09874E5521078FA100E190B8 /* VimeoUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788021065F8B0077D77F /* VimeoUserScript.js */; }; + 09874E5621078FA100E190B8 /* Youtube.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788721065F8C0077D77F /* Youtube.html */; }; + 09874E5721078FA100E190B8 /* YoutubeUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788121065F8B0077D77F /* YoutubeUserScript.js */; }; + 09874E582107A4C300E190B8 /* VimeoEmbedImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09874E3A21075BF400E190B8 /* VimeoEmbedImplementation.swift */; }; + 09874E592107BD4100E190B8 /* GenericEmbedImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09874E4021075C1700E190B8 /* GenericEmbedImplementation.swift */; }; D007019C2029E8F2006B9E34 /* LegqacyICloudFileController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D007019B2029E8F2006B9E34 /* LegqacyICloudFileController.swift */; }; D007019E2029EFDD006B9E34 /* ICloudResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = D007019D2029EFDD006B9E34 /* ICloudResources.swift */; }; D00701A12029F6D0006B9E34 /* TGMimeTypeMap.h in Headers */ = {isa = PBXBuildFile; fileRef = D007019F2029F6D0006B9E34 /* TGMimeTypeMap.h */; }; @@ -969,6 +986,27 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0941A99F210B057200EBE194 /* OpenInActionSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInActionSheetController.swift; sourceTree = ""; }; + 0941A9A3210B0E2E00EBE194 /* OpenInAppIconResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInAppIconResources.swift; sourceTree = ""; }; + 0941A9A5210B822D00EBE194 /* OpenInOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInOptions.swift; sourceTree = ""; }; + 09797872210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantPageSettingsButtonItemNode.swift; sourceTree = ""; }; + 0979787B210642CB0077D77F /* WebEmbedPlayerNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebEmbedPlayerNode.swift; sourceTree = ""; }; + 0979787D210646C00077D77F /* YoutubeEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YoutubeEmbedImplementation.swift; sourceTree = ""; }; + 0979788021065F8B0077D77F /* VimeoUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = VimeoUserScript.js; sourceTree = ""; }; + 0979788121065F8B0077D77F /* YoutubeUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = YoutubeUserScript.js; sourceTree = ""; }; + 0979788221065F8C0077D77F /* Vimeo.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Vimeo.html; sourceTree = ""; }; + 0979788321065F8C0077D77F /* Generic.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Generic.html; sourceTree = ""; }; + 0979788421065F8C0077D77F /* Instagram.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Instagram.html; sourceTree = ""; }; + 0979788521065F8C0077D77F /* Twitch.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Twitch.html; sourceTree = ""; }; + 0979788621065F8C0077D77F /* TwitchUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = TwitchUserScript.js; sourceTree = ""; }; + 0979788721065F8C0077D77F /* Youtube.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Youtube.html; sourceTree = ""; }; + 0979788821065F8C0077D77F /* GenericUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = GenericUserScript.js; sourceTree = ""; }; + 09874E3A21075BF400E190B8 /* VimeoEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimeoEmbedImplementation.swift; sourceTree = ""; }; + 09874E3C21075C0500E190B8 /* TwitchEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitchEmbedImplementation.swift; sourceTree = ""; }; + 09874E3E21075C0D00E190B8 /* SoundCloudEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundCloudEmbedImplementation.swift; sourceTree = ""; }; + 09874E4021075C1700E190B8 /* GenericEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericEmbedImplementation.swift; sourceTree = ""; }; + 09874E4221075C3000E190B8 /* VKEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VKEmbedImplementation.swift; sourceTree = ""; }; + 09874E4421075C3F00E190B8 /* StreamableEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamableEmbedImplementation.swift; sourceTree = ""; }; D00219051DDD1C9E00BE708A /* ImageContainingNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageContainingNode.swift; sourceTree = ""; }; D002A0D01E9B99F500A81812 /* SoftwareVideoSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoftwareVideoSource.swift; sourceTree = ""; }; D002A0D21E9BBE6700A81812 /* MultiplexedSoftwareVideoSourceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiplexedSoftwareVideoSourceManager.swift; sourceTree = ""; }; @@ -2054,6 +2092,47 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0941A99E210B053300EBE194 /* Open In */ = { + isa = PBXGroup; + children = ( + 0941A9A5210B822D00EBE194 /* OpenInOptions.swift */, + 0941A99F210B057200EBE194 /* OpenInActionSheetController.swift */, + ); + name = "Open In"; + sourceTree = ""; + }; + 0979787F21065EAA0077D77F /* Web Embed */ = { + isa = PBXGroup; + children = ( + 0979788321065F8C0077D77F /* Generic.html */, + 0979788821065F8C0077D77F /* GenericUserScript.js */, + 0979788421065F8C0077D77F /* Instagram.html */, + 0979788521065F8C0077D77F /* Twitch.html */, + 0979788621065F8C0077D77F /* TwitchUserScript.js */, + 0979788221065F8C0077D77F /* Vimeo.html */, + 0979788021065F8B0077D77F /* VimeoUserScript.js */, + 0979788721065F8C0077D77F /* Youtube.html */, + 0979788121065F8B0077D77F /* YoutubeUserScript.js */, + ); + name = "Web Embed"; + path = TelegramUI/Resources/WebEmbed; + sourceTree = ""; + }; + 09CC52A7210615AA000578F8 /* Web Embed */ = { + isa = PBXGroup; + children = ( + 0979787B210642CB0077D77F /* WebEmbedPlayerNode.swift */, + 09874E4021075C1700E190B8 /* GenericEmbedImplementation.swift */, + 0979787D210646C00077D77F /* YoutubeEmbedImplementation.swift */, + 09874E3A21075BF400E190B8 /* VimeoEmbedImplementation.swift */, + 09874E3C21075C0500E190B8 /* TwitchEmbedImplementation.swift */, + 09874E3E21075C0D00E190B8 /* SoundCloudEmbedImplementation.swift */, + 09874E4221075C3000E190B8 /* VKEmbedImplementation.swift */, + 09874E4421075C3F00E190B8 /* StreamableEmbedImplementation.swift */, + ); + name = "Web Embed"; + sourceTree = ""; + }; D00C7CDA1E3776CA0080C3D5 /* Secret Preview */ = { isa = PBXGroup; children = ( @@ -2351,6 +2430,7 @@ D0E9BA681F056F4C00F079A4 /* Stripe */, D0E9B9E91F00853C00F079A4 /* PhoneCountries.txt */, D0471B531EFD8ECA0074D609 /* currencies.json */, + 0979787F21065EAA0077D77F /* Web Embed */, ); name = Resources; sourceTree = ""; @@ -2358,6 +2438,7 @@ D0477D191F617E4B00412B44 /* Video */ = { isa = PBXGroup; children = ( + 09CC52A7210615AA000578F8 /* Web Embed */, D0477D1A1F617E5800412B44 /* UniversalVideoNode.swift */, D06BEC761F62F68B0035A545 /* OverlayUniversalVideoNode.swift */, D0943B041FDDFDA0001522CC /* OverlayInstantVideoNode.swift */, @@ -2741,6 +2822,7 @@ D048EA881F4F297500188713 /* InstantPageSettingsFontFamilyItemNode.swift */, D048EA8A1F4F298A00188713 /* InstantPageSettingsThemeItemNode.swift */, D048EA8C1F4F299A00188713 /* InstantPageSettingsSwitchItemNode.swift */, + 09797872210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift */, ); name = "Instant Page"; sourceTree = ""; @@ -3736,6 +3818,7 @@ D0C50E361E93CAF200F62E39 /* Notifications */, D0430AFE1FF456F400A35ADD /* Web */, D0F8C3952017747300236FC5 /* Feed */, + 0941A99E210B053300EBE194 /* Open In */, ); name = Controllers; sourceTree = ""; @@ -4117,6 +4200,7 @@ D056CD731FF2996B00880D28 /* ExternalMusicAlbumArtResources.swift */, D007019D2029EFDD006B9E34 /* ICloudResources.swift */, D09F9DCE20768DAF00DB4DE1 /* SecureIdLocalResource.swift */, + 0941A9A3210B0E2E00EBE194 /* OpenInAppIconResources.swift */, ); name = Resources; sourceTree = ""; @@ -4355,6 +4439,15 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 09874E4F21078FA100E190B8 /* Generic.html in Resources */, + 09874E5021078FA100E190B8 /* GenericUserScript.js in Resources */, + 09874E5121078FA100E190B8 /* Instagram.html in Resources */, + 09874E5221078FA100E190B8 /* Twitch.html in Resources */, + 09874E5321078FA100E190B8 /* TwitchUserScript.js in Resources */, + 09874E5421078FA100E190B8 /* Vimeo.html in Resources */, + 09874E5521078FA100E190B8 /* VimeoUserScript.js in Resources */, + 09874E5621078FA100E190B8 /* Youtube.html in Resources */, + 09874E5721078FA100E190B8 /* YoutubeUserScript.js in Resources */, D0EB42051F3143AB00838FE6 /* LegacyComponentsResources.bundle in Resources */, D0E9BAA21F056F4C00F079A4 /* stp_card_discover@3x.png in Resources */, D0E9BAB01F056F4C00F079A4 /* stp_card_mastercard@3x.png in Resources */, @@ -4439,6 +4532,7 @@ D0B2F76C2052A7D600D3BFB9 /* SinglePhoneInputNode.swift in Sources */, D04281F6200E5AC2009DDE36 /* ChatRecentActionsControllerNode.swift in Sources */, D0EC6CB61EB9F58800EBF1C3 /* RMGeometry.m in Sources */, + 0941A9A0210B057200EBE194 /* OpenInActionSheetController.swift in Sources */, D079FCDD1F05C4F20038FADE /* LocalAuth.swift in Sources */, D0B2F76820528E3D00D3BFB9 /* UserInfoEditingPhoneActionItem.swift in Sources */, D0EC6CB71EB9F58800EBF1C3 /* RMIntroPageView.m in Sources */, @@ -4461,6 +4555,7 @@ D0EC6CC01EB9F58800EBF1C3 /* LegacyMediaPickers.swift in Sources */, D0AF7C4A1ED84CE000CD8E0F /* LanguageSelectionControllerNode.swift in Sources */, D0EC6CC11EB9F58800EBF1C3 /* LegacyCamera.swift in Sources */, + 0941A9A6210B822D00EBE194 /* OpenInOptions.swift in Sources */, D0754D1E1EEDDF6200884F6E /* ChatMessageAttachedContentNode.swift in Sources */, D0E9BAC71F05738600F079A4 /* STPAPIClient.m in Sources */, D0CFBB911FD881A600B65C0D /* AudioRecordningToneData.swift in Sources */, @@ -4746,6 +4841,7 @@ D0EC6D611EB9F58800EBF1C3 /* GridMessageSelectionNode.swift in Sources */, D0754D201EEDEBA000884F6E /* ChatMessageGameBubbleContentNode.swift in Sources */, D09E63AA1F0FC681003444CD /* PictureInPictureVideoControlsNode.swift in Sources */, + 09874E592107BD4100E190B8 /* GenericEmbedImplementation.swift in Sources */, D0C0B5921EDC5A3B000F4D2C /* LinkHighlightingNode.swift in Sources */, D0EC6D621EB9F58800EBF1C3 /* ContactListNode.swift in Sources */, D0EC6D631EB9F58800EBF1C3 /* ContactListActionItem.swift in Sources */, @@ -4774,6 +4870,7 @@ D0EC6D721EB9F58800EBF1C3 /* AuthorizationSequencePasswordEntryControllerNode.swift in Sources */, D09D886F1F86C11F00BEB4C9 /* AuthorizationTheme.swift in Sources */, D0EC6D731EB9F58800EBF1C3 /* AuthorizationSequenceSignUpController.swift in Sources */, + 0979787C210642CB0077D77F /* WebEmbedPlayerNode.swift in Sources */, D0C12EB01F9A8D1300600BB2 /* ListMessageDateHeader.swift in Sources */, D0E9BA5D1F055A3300F079A4 /* STPBINRange.m in Sources */, D0EC6D741EB9F58800EBF1C3 /* AuthorizationSequenceSignUpControllerNode.swift in Sources */, @@ -4826,6 +4923,7 @@ D01DBA9B209CC6AD00C64E64 /* ChatLinkPreview.swift in Sources */, D044A0FB20BDC40C00326FAC /* CachedChannelAdmins.swift in Sources */, D0EC6D901EB9F58900EBF1C3 /* ChatMessageBubbleContentNode.swift in Sources */, + 09874E582107A4C300E190B8 /* VimeoEmbedImplementation.swift in Sources */, D0EC6D911EB9F58900EBF1C3 /* ChatMessageBubbleItemNode.swift in Sources */, D0E8B8BD204479A500605593 /* SecretChatKeyController.swift in Sources */, D0B85C1C1FF6F76000E795B4 /* AuthorizationSequencePasswordRecoveryController.swift in Sources */, @@ -4865,6 +4963,7 @@ D0EC6D9E1EB9F58900EBF1C3 /* ChatMessageWebpageBubbleContentNode.swift in Sources */, D06CF82720D0080200AC4CFF /* SecureIdAuthListContentNode.swift in Sources */, D0C0B5901EDB505E000F4D2C /* ActivityIndicator.swift in Sources */, + 09797873210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift in Sources */, D0EC6D9F1EB9F58900EBF1C3 /* ChatUnreadItem.swift in Sources */, D0E9B9E81EFEFB9500F079A4 /* BotPaymentDisclosureItemNode.swift in Sources */, D0EC6DA01EB9F58900EBF1C3 /* ChatHoleItem.swift in Sources */, @@ -5002,6 +5101,7 @@ D06D37B22077E77F009219B6 /* AutodownloadSizeLimitItem.swift in Sources */, D0EC6DED1EB9F58900EBF1C3 /* ChatHistoryNavigationButtonNode.swift in Sources */, D0FB87B21F7C4C19004DE005 /* FetchMediaUtils.swift in Sources */, + 0979787E210646C00077D77F /* YoutubeEmbedImplementation.swift in Sources */, D0E9BA0C1F04580700F079A4 /* BotCheckoutWebInteractionControllerNode.swift in Sources */, D0EC6DF11EB9F58900EBF1C3 /* ShareController.swift in Sources */, D0EC6DF21EB9F58900EBF1C3 /* ShareControllerNode.swift in Sources */, @@ -5080,6 +5180,7 @@ D0EC6E211EB9F58900EBF1C3 /* InstantPageController.swift in Sources */, D0EC6E221EB9F58900EBF1C3 /* InstantPageControllerNode.swift in Sources */, D0EC6E231EB9F58900EBF1C3 /* StickerPackPreviewController.swift in Sources */, + 0941A9A4210B0E2E00EBE194 /* OpenInAppIconResources.swift in Sources */, D0EC6E241EB9F58900EBF1C3 /* StickerPackPreviewControllerNode.swift in Sources */, D0FC194D201F82A000FEDBB2 /* OpenResolvedUrl.swift in Sources */, D0EC6E251EB9F58900EBF1C3 /* StickerPackPreviewGridItem.swift in Sources */, diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 08506d11e8..68da097d36 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -641,6 +641,7 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin case let .url(url): var cleanUrl = url var canAddToReadingList = true + let canOpenIn = true let mailtoString = "mailto:" let telString = "tel:" var openText = strongSelf.presentationData.strings.Conversation_LinkDialogOpen @@ -651,6 +652,8 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin canAddToReadingList = false cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...]) openText = strongSelf.presentationData.strings.Conversation_Call + } else if canOpenIn { + openText = strongSelf.presentationData.strings.Conversation_FileOpenIn } let actionSheet = ActionSheetController(presentationTheme: strongSelf.presentationData.theme) @@ -659,7 +662,11 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin items.append(ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { - strongSelf.openUrl(url) + if canOpenIn { + strongSelf.openUrlIn(url) + } else { + strongSelf.openUrl(url) + } } })) items.append(ActionSheetButtonItem(title: canAddToReadingList ? strongSelf.presentationData.strings.ShareMenu_CopyShareLink : strongSelf.presentationData.strings.Conversation_ContextMenuCopy, color: .accent, action: { [weak actionSheet] in @@ -4112,6 +4119,18 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin })) } + private func openUrlIn(_ url: String) { + if let applicationContext = self.account.applicationContext as? TelegramApplicationContext { + let actionSheet = OpenInActionSheetController(postbox: self.account.postbox, applicationContext: applicationContext, theme: self.presentationData.theme, strings: self.presentationData.strings, item: .url(url), openUrl: { [weak self] url in + if let strongSelf = self, let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext, let navigationController = strongSelf.navigationController as? NavigationController { + openExternalUrl(account: strongSelf.account, url: url, presentationData: strongSelf.presentationData, applicationContext: applicationContext, navigationController: navigationController) + } + }) + self.chatDisplayNode.dismissInput() + self.present(actionSheet, in: .window(.root)) + } + } + @available(iOSApplicationExtension 9.0, *) public func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { if previewingContext.sourceView === (self.chatInfoNavigationButton?.buttonItem.customDisplayNode as? ChatAvatarNavigationNode)?.avatarNode.view { diff --git a/TelegramUI/ChatItemGalleryFooterContentNode.swift b/TelegramUI/ChatItemGalleryFooterContentNode.swift index bae49ba6bb..56a3a7f683 100644 --- a/TelegramUI/ChatItemGalleryFooterContentNode.swift +++ b/TelegramUI/ChatItemGalleryFooterContentNode.swift @@ -9,6 +9,9 @@ import Photos private let deleteImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Acessory Panels/MessageSelectionThrash"), color: .white) private let actionImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Acessory Panels/MessageSelectionAction"), color: .white) +private let backwardImage = UIImage(bundleImageName: "Media Gallery/BackwardButton") +private let forwardImage = UIImage(bundleImageName: "Media Gallery/ForwardButton") + private let pauseImage = generateImage(CGSize(width: 16.0, height: 16.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) @@ -58,8 +61,7 @@ private let dateFont = Font.regular(14.0) enum ChatItemGalleryFooterContent { case info - case playbackPause - case playbackPlay + case playback(paused: Bool, seekable: Bool) } final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { @@ -72,6 +74,8 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { private let textNode: ASTextNode private let authorNameNode: ASTextNode private let dateNode: ASTextNode + private let backwardButton: HighlightableButtonNode + private let forwardButton: HighlightableButtonNode private let playbackControlButton: HighlightableButtonNode private var currentMessageText: String? @@ -83,26 +87,35 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { private let messageContextDisposable = MetaDisposable() var playbackControl: (() -> Void)? + var seekBackward: (() -> Void)? + var seekForward: (() -> Void)? var content: ChatItemGalleryFooterContent = .info { didSet { - if self.content != oldValue { + //if self.content != oldValue { switch self.content { case .info: self.authorNameNode.isHidden = false self.dateNode.isHidden = false + self.backwardButton.isHidden = true + self.forwardButton.isHidden = true self.playbackControlButton.isHidden = true - case .playbackPause: + case let .playback(paused, seekable): self.authorNameNode.isHidden = true self.dateNode.isHidden = true + self.backwardButton.isHidden = !seekable + self.forwardButton.isHidden = !seekable self.playbackControlButton.isHidden = false - self.playbackControlButton.setImage(pauseImage, for: []) - case .playbackPlay: - self.authorNameNode.isHidden = true - self.dateNode.isHidden = true - self.playbackControlButton.isHidden = false - self.playbackControlButton.setImage(playImage, for: []) + self.playbackControlButton.setImage(paused ? playImage : pauseImage, for: []) } + //} + } + } + + var scrubberView: ChatVideoGalleryItemScrubberView? { + didSet { + if let scrubberView = self.scrubberView { + self.view.addSubview(scrubberView) } } } @@ -128,10 +141,20 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { self.dateNode.maximumNumberOfLines = 1 self.dateNode.isLayerBacked = true self.dateNode.displaysAsynchronously = false + + self.backwardButton = HighlightableButtonNode() + self.backwardButton.isHidden = true + self.backwardButton.setImage(backwardImage, for: []) + + self.forwardButton = HighlightableButtonNode() + self.forwardButton.isHidden = true + self.forwardButton.setImage(forwardImage, for: []) self.playbackControlButton = HighlightableButtonNode() self.playbackControlButton.isHidden = true + self.scrubberView = ChatVideoGalleryItemScrubberView() + super.init() self.view.addSubview(self.deleteButton) @@ -139,12 +162,16 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { self.addSubnode(self.textNode) self.addSubnode(self.authorNameNode) self.addSubnode(self.dateNode) - + + self.addSubnode(self.backwardButton) + self.addSubnode(self.forwardButton) self.addSubnode(self.playbackControlButton) self.deleteButton.addTarget(self, action: #selector(self.deleteButtonPressed), for: [.touchUpInside]) self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), for: [.touchUpInside]) + self.backwardButton.addTarget(self, action: #selector(self.backwardButtonPressed), forControlEvents: .touchUpInside) + self.forwardButton.addTarget(self, action: #selector(self.forwardButtonPressed), forControlEvents: .touchUpInside) self.playbackControlButton.addTarget(self, action: #selector(self.playbackControlPressed), forControlEvents: .touchUpInside) } @@ -267,8 +294,20 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { self.textNode.frame = CGRect(origin: CGPoint(x: sideInset, y: topInset), size: textSize) } + if let scrubberView = self.scrubberView { + let sideInset: CGFloat = 8.0 + leftInset + let topInset: CGFloat = 8.0 + let bottomInset: CGFloat = 8.0 + panelHeight += 34.0 + topInset + bottomInset + + scrubberView.frame = CGRect(origin: CGPoint(x: sideInset, y: topInset), size: CGSize(width: width - sideInset * 2.0, height: 34.0)) + } + self.actionButton.frame = CGRect(origin: CGPoint(x: leftInset, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) self.deleteButton.frame = CGRect(origin: CGPoint(x: width - 44.0 - rightInset, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) + + self.backwardButton.frame = CGRect(origin: CGPoint(x: floor((width - 44.0) / 2.0) - 66.0, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) + self.forwardButton.frame = CGRect(origin: CGPoint(x: floor((width - 44.0) / 2.0) + 66.0, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) self.playbackControlButton.frame = CGRect(origin: CGPoint(x: floor((width - 44.0) / 2.0), y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) @@ -521,4 +560,12 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { @objc func playbackControlPressed() { self.playbackControl?() } + + @objc func backwardButtonPressed() { + self.seekBackward?() + } + + @objc func forwardButtonPressed() { + self.seekForward?() + } } diff --git a/TelegramUI/ChatMessageAttachedContentNode.swift b/TelegramUI/ChatMessageAttachedContentNode.swift index c40fd64b1e..a319b6c561 100644 --- a/TelegramUI/ChatMessageAttachedContentNode.swift +++ b/TelegramUI/ChatMessageAttachedContentNode.swift @@ -311,8 +311,8 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { for media in message.media { if let media = media as? TelegramMediaWebpage { if case let .Loaded(content) = media.content, let instantPage = content.instantPage, let image = content.image { - switch websiteType(of: content) { - case .instagram, .twitter: + switch instantPageType(of: content) { + case .album: let count = instantPageGalleryMedia(webpageId: media.webpageId, page: instantPage, galleryMedia: image).count if count > 1 { webpageGalleryMediaCount = count diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index 8f3ca5e8a9..ec617fa966 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -22,6 +22,24 @@ func websiteType(of webpage: TelegramMediaWebpageLoadedContent) -> WebsiteType { return .generic } +enum InstantPageType { + case generic + case album +} + +func instantPageType(of webpage: TelegramMediaWebpageLoadedContent) -> InstantPageType { + if let type = webpage.type, type == "telegram_album" { + return .album + } + + switch websiteType(of: webpage) { + case .instagram, .twitter: + return .album + default: + return .generic + } +} + func instantPageGalleryMedia(webpageId: MediaId, page: InstantPage, galleryMedia: Media) -> [InstantPageGalleryEntry] { var result: [InstantPageGalleryEntry] = [] var counter: Int = 0 @@ -190,7 +208,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { mediaAndFlags = (file, []) } } else if let image = mainMedia as? TelegramMediaImage { - if let type = webpage.type, ["photo", "video", "embed", "article"].contains(type) { + if let type = webpage.type, ["photo", "video", "embed", "article", "telegram_album"].contains(type) { var flags = ChatMessageAttachedContentNodeMediaFlags() if webpage.instantPage != nil, let largest = largestImageRepresentation(image.representations) { if largest.dimensions.width >= 256.0 { @@ -206,12 +224,12 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } if let _ = webpage.instantPage { - switch type { - case .twitter, .instagram: - break - default: + switch instantPageType(of: webpage) { + case .generic: actionIcon = .instant actionTitle = item.presentationData.strings.Conversation_InstantPagePreview + default: + break } } else if let type = webpage.type { switch type { diff --git a/TelegramUI/ChatRecentActionsControllerNode.swift b/TelegramUI/ChatRecentActionsControllerNode.swift index 1cd38a16f2..4e151867f2 100644 --- a/TelegramUI/ChatRecentActionsControllerNode.swift +++ b/TelegramUI/ChatRecentActionsControllerNode.swift @@ -242,6 +242,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { switch action { case let .url(url): var cleanUrl = url + let canOpenIn = true var canAddToReadingList = true let mailtoString = "mailto:" let telString = "tel:" @@ -253,6 +254,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { canAddToReadingList = false cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...]) openText = strongSelf.presentationData.strings.Conversation_Call + } else if canOpenIn { + openText = strongSelf.presentationData.strings.Conversation_FileOpenIn } let actionSheet = ActionSheetController(presentationTheme: strongSelf.presentationData.theme) diff --git a/TelegramUI/ChatVideoGalleryItemScrubberView.swift b/TelegramUI/ChatVideoGalleryItemScrubberView.swift index ca2ade29e4..3c93a8a0d9 100644 --- a/TelegramUI/ChatVideoGalleryItemScrubberView.swift +++ b/TelegramUI/ChatVideoGalleryItemScrubberView.swift @@ -32,11 +32,11 @@ final class ChatVideoGalleryItemScrubberView: UIView { var seek: (Double) -> Void = { _ in } override init(frame: CGRect) { - self.scrubberNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 3.0, lineCap: .round, scrubberHandle: .line, backgroundColor: UIColor(white: 1.0, alpha: 0.42), foregroundColor: .white)) + self.scrubberNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 4.0, lineCap: .round, scrubberHandle: .circle, backgroundColor: UIColor(white: 1.0, alpha: 0.42), foregroundColor: .white)) self.leftTimestampNode = MediaPlayerTimeTextNode(textColor: .white) self.rightTimestampNode = MediaPlayerTimeTextNode(textColor: .white) - self.leftTimestampNode.alignment = .right + self.rightTimestampNode.alignment = .right self.rightTimestampNode.mode = .reversed super.init(frame: frame) @@ -92,9 +92,9 @@ final class ChatVideoGalleryItemScrubberView: UIView { let scrubberHeight: CGFloat = 14.0 - self.leftTimestampNode.frame = CGRect(origin: CGPoint(x: -10.0, y: 15.0), size: CGSize(width: 57.0 - 15.0, height: 20.0)) - self.rightTimestampNode.frame = CGRect(origin: CGPoint(x: size.width - 57.0 + 30.0, y: 15.0), size: CGSize(width: 57.0 - 10.0, height: 20.0)) + self.leftTimestampNode.frame = CGRect(origin: CGPoint(x: 6.0, y: 22.0), size: CGSize(width: 60.0, height: 20.0)) + self.rightTimestampNode.frame = CGRect(origin: CGPoint(x: size.width - 60.0 - 6.0, y: 22.0), size: CGSize(width: 60.0, height: 20.0)) - self.scrubberNode.frame = CGRect(origin: CGPoint(x: 57.0 - 15.0, y: floor((size.height - scrubberHeight) / 2.0) + 1.0), size: CGSize(width: size.width - 57.0 * 2.0 + 35.0, height: scrubberHeight)) + self.scrubberNode.frame = CGRect(origin: CGPoint(x: 6.0, y: 6.0), size: CGSize(width: size.width - 6.0 * 2.0, height: scrubberHeight)) } } diff --git a/TelegramUI/GalleryControllerNode.swift b/TelegramUI/GalleryControllerNode.swift index eebd567af6..e44797390e 100644 --- a/TelegramUI/GalleryControllerNode.swift +++ b/TelegramUI/GalleryControllerNode.swift @@ -314,7 +314,9 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate, UIGestureRecog func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { targetContentOffset.pointee = scrollView.contentOffset - if abs(velocity.y) > 1.0 { + let distanceFromEquilibrium = scrollView.contentOffset.y - scrollView.contentSize.height / 3.0 + let minimalDismissDistance = scrollView.contentSize.height / 12.0 + if abs(velocity.y) > 1.0 || abs(distanceFromEquilibrium) > minimalDismissDistance { if let backgroundColor = self.backgroundNode.backgroundColor { self.backgroundNode.layer.animate(from: backgroundColor, to: UIColor(white: 0.0, alpha: 0.0).cgColor, keyPath: "backgroundColor", timingFunction: kCAMediaTimingFunctionLinear, duration: 0.2, removeOnCompletion: false) } diff --git a/TelegramUI/GenericEmbedImplementation.swift b/TelegramUI/GenericEmbedImplementation.swift new file mode 100644 index 0000000000..d2e3536bb3 --- /dev/null +++ b/TelegramUI/GenericEmbedImplementation.swift @@ -0,0 +1,69 @@ +import Foundation +import WebKit +import SwiftSignalKit + +final class GenericEmbedImplementation: WebEmbedImplementation { + private var evalImpl: ((String) -> Void)? + private var updateStatus: ((MediaPlayerStatus) -> Void)? + private var onPlaybackStarted: (() -> Void)? + + private let url: String + + init(url: String) { + self.url = url + //self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true)) + } + + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + let bundle = Bundle(for: type(of: self)) + guard let userScriptPath = bundle.path(forResource: "GenericUserScript", ofType: "js") else { + return + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return + } + guard let htmlTemplatePath = bundle.path(forResource: "Generic", ofType: "html") else { + return + } + guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else { + return + } + guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else { + return + } + + self.evalImpl = evaluateJavaScript + self.updateStatus = updateStatus + self.onPlaybackStarted = onPlaybackStarted + //updateStatus(self.status) + + let html = String(format: htmlTemplate, self.url) + webView.loadHTMLString(html, baseURL: URL(string: "about:blank")) + + userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) + } + + func play() { + } + + func pause() { + } + + func togglePlayPause() { + } + + func seek(timestamp: Double) { + } + + func pageReady() { + if let onPlaybackStarted = self.onPlaybackStarted { + onPlaybackStarted() + } + } + + func callback(url: URL) { + } +} diff --git a/TelegramUI/GroupInfoController.swift b/TelegramUI/GroupInfoController.swift index 46c0f717a2..e250e751bb 100644 --- a/TelegramUI/GroupInfoController.swift +++ b/TelegramUI/GroupInfoController.swift @@ -1792,10 +1792,13 @@ func handlePeerInfoAboutTextAction(account: Account, peerId: PeerId, navigateDis case .longTap: switch itemLink { case let .url(url): + let canOpenIn = true + let openText = canOpenIn ? presentationData.strings.Conversation_FileOpenIn : presentationData.strings.Conversation_LinkDialogOpen + let actionSheet = ActionSheetController(presentationTheme: presentationData.theme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: url), - ActionSheetButtonItem(title: presentationData.strings.Conversation_LinkDialogOpen, color: .accent, action: { [weak actionSheet] in + ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() openLinkImpl(url) }), diff --git a/TelegramUI/InstantPageControllerNode.swift b/TelegramUI/InstantPageControllerNode.swift index e3b2b06051..1a4ef7bdc6 100644 --- a/TelegramUI/InstantPageControllerNode.swift +++ b/TelegramUI/InstantPageControllerNode.swift @@ -595,10 +595,13 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate { } case .longTap: if let url = self.urlForTapLocation(location) { + let canOpenIn = true + let openText = canOpenIn ? self.strings.Conversation_FileOpenIn : self.strings.Conversation_LinkDialogOpen + let actionSheet = ActionSheetController(presentationTheme: self.presentationTheme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: url.url), - ActionSheetButtonItem(title: self.self.strings.Conversation_LinkDialogOpen, color: .accent, action: { [weak self, weak actionSheet] in + ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { strongSelf.openUrl(url) @@ -823,6 +826,10 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate { return settings }).start() } + }, openInSafari: { [weak self] in + if let strongSelf = self, let webPage = strongSelf.webPage, case let .Loaded(content) = webPage.content { + strongSelf.account.telegramApplicationContext.applicationBindings.openUrl(content.url) + } }) self.addSubnode(settingsNode) self.settingsNode = settingsNode diff --git a/TelegramUI/InstantPageSettingsButtonItemNode.swift b/TelegramUI/InstantPageSettingsButtonItemNode.swift new file mode 100644 index 0000000000..0d7ada8893 --- /dev/null +++ b/TelegramUI/InstantPageSettingsButtonItemNode.swift @@ -0,0 +1,43 @@ +import Foundation +import AsyncDisplayKit +import Display + +final class InstantPageSettingsButtonItemNode: InstantPageSettingsItemNode { + private let title: String + private let tapped: () -> Void + + private let labelNode: ASTextNode + + init(theme: InstantPageSettingsItemTheme, title: String, tapped: @escaping () -> Void) { + self.title = title + self.tapped = tapped + + self.labelNode = ASTextNode() + + super.init(theme: theme, selectable: true) + + self.addSubnode(self.labelNode) + + self.updateTheme(theme) + } + + override func updateTheme(_ theme: InstantPageSettingsItemTheme) { + super.updateTheme(theme) + + self.labelNode.attributedText = NSAttributedString(string: self.title, font: Font.regular(17.0), textColor: theme.accentColor) + } + + override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) { + var separatorInset: CGFloat? + if case .sameSection = previousItem.0, let previousNode = previousItem.1, previousNode is InstantPageSettingsFontFamilyNode { + separatorInset = 46.0 + } + let labelSize = self.labelNode.measure(CGSize(width: width - 15.0 - 5.0, height: 44.0)) + self.labelNode.frame = CGRect(origin: CGPoint(x: 15.0, y: insets.top + floor((44.0 - labelSize.height) / 2.0)), size: labelSize) + return (44.0 + insets.top + insets.bottom, separatorInset) + } + + override func pressed() { + self.tapped() + } +} diff --git a/TelegramUI/InstantPageSettingsNode.swift b/TelegramUI/InstantPageSettingsNode.swift index 02959113a9..b9a1343313 100644 --- a/TelegramUI/InstantPageSettingsNode.swift +++ b/TelegramUI/InstantPageSettingsNode.swift @@ -22,21 +22,24 @@ final class InstantPageSettingsNode: ASDisplayNode { private var theme: InstantPageSettingsItemTheme private let applySettings: (InstantPagePresentationSettings) -> Void + private let openInSafari: () -> Void private var sections: [[InstantPageSettingsItemNode]] = [] private let sansFamilyNode: InstantPageSettingsFontFamilyNode private let serifFamilyNode: InstantPageSettingsFontFamilyNode private let themeItemNode: InstantPageSettingsThemeItemNode private let autoNightItemNode: InstantPageSettingsSwitchNode + private let openInItemNode: InstantPageSettingsButtonItemNode private let arrowNode: ASImageNode private let itemContainerNode: ASDisplayNode - init(strings: PresentationStrings, settings: InstantPagePresentationSettings, applySettings: @escaping (InstantPagePresentationSettings) -> Void) { + init(strings: PresentationStrings, settings: InstantPagePresentationSettings, applySettings: @escaping (InstantPagePresentationSettings) -> Void, openInSafari: @escaping () -> Void) { self.settings = settings self.theme = InstantPageSettingsItemTheme.themeFor(settings) self.applySettings = applySettings + self.openInSafari = openInSafari self.arrowNode = ASImageNode() self.arrowNode.displayWithoutProcessing = true @@ -51,6 +54,7 @@ final class InstantPageSettingsNode: ASDisplayNode { var updateSerifImpl: ((Bool) -> Void)? var updateThemeTypeImpl: ((InstantPageThemeType) -> Void)? var updateAutoNightImpl: ((Bool) -> Void)? + var openInSafariImpl: (() -> Void)? self.sansFamilyNode = InstantPageSettingsFontFamilyNode(theme: self.theme, title: "San Francisco", family: nil, checked: !settings.forceSerif, tapped: { updateSerifImpl?(false) @@ -64,7 +68,9 @@ final class InstantPageSettingsNode: ASDisplayNode { self.autoNightItemNode = InstantPageSettingsSwitchNode(theme: theme, title: strings.InstantPage_AutoNightTheme, isOn: settings.autoNightMode, isEnabled: settings.themeType != .dark, toggled: { value in updateAutoNightImpl?(value) }) - + self.openInItemNode = InstantPageSettingsButtonItemNode(theme: theme, title: strings.Web_OpenExternal, tapped: { + openInSafariImpl?() + }) super.init() self.addSubnode(self.arrowNode) @@ -89,6 +95,9 @@ final class InstantPageSettingsNode: ASDisplayNode { [ self.themeItemNode, self.autoNightItemNode + ], + [ + self.openInItemNode ] ] @@ -121,6 +130,11 @@ final class InstantPageSettingsNode: ASDisplayNode { } } } + openInSafariImpl = { [weak self] in + if let strongSelf = self { + strongSelf.openInSafari() + } + } } func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { diff --git a/TelegramUI/LegacyComponentsStickers.swift b/TelegramUI/LegacyComponentsStickers.swift index 25e96830aa..a15bb1bef6 100644 --- a/TelegramUI/LegacyComponentsStickers.swift +++ b/TelegramUI/LegacyComponentsStickers.swift @@ -35,6 +35,8 @@ func legacyComponentsStickers(postbox: Postbox, namespace: Int32) -> SSignal { attributes.append(TGDocumentAttributeSticker(alt: displayText, packReference: nil, mask: maskData.flatMap { return TGStickerMaskDescription(n: $0.n, point: CGPoint(x: CGFloat($0.x), y: CGFloat($0.y)), zoom: CGFloat($0.zoom)) })) + case let .ImageSize(size): + attributes.append(TGDocumentAttributeImageSize(size: size)) default: break } diff --git a/TelegramUI/MediaPlayer.swift b/TelegramUI/MediaPlayer.swift index 29034cd13d..ed0a966c7b 100644 --- a/TelegramUI/MediaPlayer.swift +++ b/TelegramUI/MediaPlayer.swift @@ -3,7 +3,6 @@ import SwiftSignalKit import Postbox import CoreMedia import TelegramCore -import Postbox private let traceEvents = false diff --git a/TelegramUI/MediaPlayerScrubbingNode.swift b/TelegramUI/MediaPlayerScrubbingNode.swift index 9af34f66c8..0273bc98c6 100644 --- a/TelegramUI/MediaPlayerScrubbingNode.swift +++ b/TelegramUI/MediaPlayerScrubbingNode.swift @@ -85,16 +85,18 @@ private final class StandardMediaPlayerScrubbingNodeContentNode { let bufferingNode: MediaPlayerScrubbingBufferingNode let foregroundContentNode: ASImageNode let foregroundNode: MediaPlayerScrubbingForegroundNode + let handle: MediaPlayerScrubbingNodeHandle let handleNode: ASDisplayNode? let handleNodeContainer: MediaPlayerScrubbingNodeButton? - init(lineHeight: CGFloat, lineCap: MediaPlayerScrubbingNodeCap, backgroundNode: ASImageNode, bufferingNode: MediaPlayerScrubbingBufferingNode, foregroundContentNode: ASImageNode, foregroundNode: MediaPlayerScrubbingForegroundNode, handleNode: ASDisplayNode?, handleNodeContainer: MediaPlayerScrubbingNodeButton?) { + init(lineHeight: CGFloat, lineCap: MediaPlayerScrubbingNodeCap, backgroundNode: ASImageNode, bufferingNode: MediaPlayerScrubbingBufferingNode, foregroundContentNode: ASImageNode, foregroundNode: MediaPlayerScrubbingForegroundNode, handle: MediaPlayerScrubbingNodeHandle, handleNode: ASDisplayNode?, handleNodeContainer: MediaPlayerScrubbingNodeButton?) { self.lineHeight = lineHeight self.lineCap = lineCap self.backgroundNode = backgroundNode self.bufferingNode = bufferingNode self.foregroundContentNode = foregroundContentNode self.foregroundNode = foregroundNode + self.handle = handle self.handleNode = handleNode self.handleNodeContainer = handleNodeContainer } @@ -286,7 +288,7 @@ final class MediaPlayerScrubbingNode: ASDisplayNode { handleNodeContainerImpl = handleNodeContainer case .circle: let handleNode = ASImageNode() - handleNode.image = generateFilledCircleImage(diameter: 7.0, color: foregroundColor) + handleNode.image = generateFilledCircleImage(diameter: lineHeight + 4.0, color: foregroundColor) handleNode.isLayerBacked = true handleNodeImpl = handleNode @@ -297,7 +299,7 @@ final class MediaPlayerScrubbingNode: ASDisplayNode { handleNodeContainerImpl?.isUserInteractionEnabled = enableScrubbing - return .standard(StandardMediaPlayerScrubbingNodeContentNode(lineHeight: lineHeight, lineCap: lineCap, backgroundNode: backgroundNode, bufferingNode: bufferingNode, foregroundContentNode: foregroundContentNode, foregroundNode: foregroundNode, handleNode: handleNodeImpl, handleNodeContainer: handleNodeContainerImpl)) + return .standard(StandardMediaPlayerScrubbingNodeContentNode(lineHeight: lineHeight, lineCap: lineCap, backgroundNode: backgroundNode, bufferingNode: bufferingNode, foregroundContentNode: foregroundContentNode, foregroundNode: foregroundNode, handle: scrubberHandle, handleNode: handleNodeImpl, handleNodeContainer: handleNodeContainerImpl)) case let .custom(backgroundNode, foregroundContentNode): let foregroundNode = MediaPlayerScrubbingForegroundNode() foregroundNode.isLayerBacked = true @@ -510,7 +512,7 @@ final class MediaPlayerScrubbingNode: ASDisplayNode { if let handleNode = node.handleNode { var handleSize: CGSize = CGSize(width: 2.0, height: bounds.size.height) - if let handleNode = handleNode as? ASImageNode, let image = handleNode.image, image.size.width.isEqual(to: 7.0) { + if case .circle = node.handle, let handleNode = handleNode as? ASImageNode, let image = handleNode.image { handleSize = image.size } handleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: floor((bounds.size.height - handleSize.height) / 2.0)), size: handleSize) diff --git a/TelegramUI/MediaResources.swift b/TelegramUI/MediaResources.swift index 3cfb7d02cd..0d2b322b3b 100644 --- a/TelegramUI/MediaResources.swift +++ b/TelegramUI/MediaResources.swift @@ -310,3 +310,51 @@ public class ExternalMusicAlbumArtResource: TelegramMediaResource { } } } + +public struct OpenInAppIconResourceId: MediaResourceId { + public let appStoreId: Int64 + + public var uniqueId: String { + return "app-icon-\(appStoreId)" + } + + public var hashValue: Int { + return self.appStoreId.hashValue + } + + public func isEqual(to: MediaResourceId) -> Bool { + if let to = to as? OpenInAppIconResourceId { + return self.appStoreId == to.appStoreId + } else { + return false + } + } +} + +public class OpenInAppIconResource: TelegramMediaResource { + public let appStoreId: Int64 + + public init(appStoreId: Int64) { + self.appStoreId = appStoreId + } + + public required init(decoder: PostboxDecoder) { + self.appStoreId = decoder.decodeInt64ForKey("i", orElse: 0) + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeInt64(self.appStoreId, forKey: "i") + } + + public var id: MediaResourceId { + return OpenInAppIconResourceId(appStoreId: self.appStoreId) + } + + public func isEqual(to: TelegramMediaResource) -> Bool { + if let to = to as? OpenInAppIconResource { + return self.appStoreId == to.appStoreId + } else { + return false + } + } +} diff --git a/TelegramUI/OpenChatMessage.swift b/TelegramUI/OpenChatMessage.swift index 6b45299753..06da9f698f 100644 --- a/TelegramUI/OpenChatMessage.swift +++ b/TelegramUI/OpenChatMessage.swift @@ -29,24 +29,20 @@ private func chatMessageGalleryControllerData(account: Account, message: Message } else if let image = media as? TelegramMediaImage { galleryMedia = image } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { - if content.embedUrl != nil && !webEmbedVideoContentSupportsWebpage(content) { - return .url(content.url) - } else { - if let file = content.file { - galleryMedia = file - } else if let image = content.image { - galleryMedia = image - } - if let instantPage = content.instantPage, let galleryMedia = galleryMedia { - switch websiteType(of: content) { - case .instagram, .twitter: - let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: galleryMedia) - if medias.count > 1 { - instantPageMedia = (webpage, medias) - } - case .generic: - break - } + if let file = content.file { + galleryMedia = file + } else if let image = content.image { + galleryMedia = image + } + if let instantPage = content.instantPage, let galleryMedia = galleryMedia { + switch instantPageType(of: content) { + case .album: + let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: galleryMedia) + if medias.count > 1 { + instantPageMedia = (webpage, medias) + } + default: + break } } } else if let mapMedia = media as? TelegramMediaMap { diff --git a/TelegramUI/OpenInActionSheetController.swift b/TelegramUI/OpenInActionSheetController.swift new file mode 100644 index 0000000000..20f88ce5fe --- /dev/null +++ b/TelegramUI/OpenInActionSheetController.swift @@ -0,0 +1,208 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +final class OpenInActionSheetController: ActionSheetController { + private let theme: PresentationTheme + private let strings: PresentationStrings + private let openUrl: (String) -> Void + + private let _ready = Promise() + override var ready: Promise { + return self._ready + } + + init(postbox: Postbox, applicationContext: TelegramApplicationContext, theme: PresentationTheme, strings: PresentationStrings, item: OpenInItem, openUrl: @escaping (String) -> Void) { + self.theme = theme + self.strings = strings + self.openUrl = openUrl + + super.init(theme: ActionSheetControllerTheme(presentationTheme: theme)) + + self._ready.set(.single(true)) + + var items: [ActionSheetItem] = [] + items.append(OpenInActionSheetItem(postbox: postbox, applicationContext: applicationContext, strings: strings, options: availableOpenInOptions(applicationContext: applicationContext, item: item))) + self.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: strings.Common_Cancel, action: { [weak self] in + self?.dismissAnimated() + }), + ]) + ]) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class OpenInActionSheetItem: ActionSheetItem { + let postbox: Postbox + let applicationContext: TelegramApplicationContext + let strings: PresentationStrings + let options: [OpenInOption] + + init(postbox: Postbox, applicationContext: TelegramApplicationContext, strings: PresentationStrings, options: [OpenInOption]) { + self.postbox = postbox + self.applicationContext = applicationContext + self.strings = strings + self.options = options + } + + func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode { + return OpenInActionSheetItemNode(postbox: self.postbox, applicationContext: self.applicationContext, theme: theme, strings: self.strings, options: self.options) + } + + func updateNode(_ node: ActionSheetItemNode) { + } +} + +private let titleFont = Font.medium(20.0) +private let textFont = Font.regular(11.0) + +private final class OpenInActionSheetItemNode: ActionSheetItemNode { + private let theme: ActionSheetControllerTheme + private let strings: PresentationStrings + + private let titleNode: ASTextNode + private let scrollNode: ASScrollNode + + private let openInNodes: [OpenInAppNode] + + init(postbox: Postbox, applicationContext: TelegramApplicationContext, theme: ActionSheetControllerTheme, strings: PresentationStrings, options: [OpenInOption]) { + self.theme = theme + self.strings = strings + + self.titleNode = ASTextNode() + self.titleNode.isLayerBacked = true + self.titleNode.displaysAsynchronously = true + self.titleNode.attributedText = NSAttributedString(string: strings.Map_OpenIn, font: titleFont, textColor: theme.primaryTextColor, paragraphAlignment: .center) + + self.scrollNode = ASScrollNode() + self.scrollNode.view.showsVerticalScrollIndicator = false + self.scrollNode.view.showsHorizontalScrollIndicator = false + self.scrollNode.view.clipsToBounds = false + self.scrollNode.view.scrollsToTop = false + self.scrollNode.view.delaysContentTouches = false + self.scrollNode.scrollableDirections = [.left, .right] + + self.openInNodes = options.map { option in + let node = OpenInAppNode() + node.setup(postbox: postbox, applicationContext: applicationContext, theme: theme, option: option) + return node + } + + super.init(theme: theme) + + self.addSubnode(self.titleNode) + + if !self.openInNodes.isEmpty { + for openInNode in openInNodes { + self.scrollNode.addSubnode(openInNode) + } + self.addSubnode(self.scrollNode) + } + } + + override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + return CGSize(width: constrainedSize.width, height: 148.0) + } + + override func layout() { + super.layout() + + let bounds = self.bounds + + let titleSize = self.titleNode.measure(bounds.size) + self.titleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 16.0), size: CGSize(width: bounds.size.width, height: titleSize.height)) + + self.scrollNode.frame = CGRect(origin: CGPoint(x: 0, y: 36.0), size: CGSize(width: bounds.size.width, height: bounds.height - 36.0)) + + let nodeInset: CGFloat = 2.0 + let nodeSize = CGSize(width: 80.0, height: 112.0) + var nodeOffset = nodeInset + + for node in self.openInNodes { + node.frame = CGRect(origin: CGPoint(x: nodeOffset, y: 0.0), size: nodeSize) + nodeOffset += nodeSize.width + } + } +} + +private final class OpenInAppNode : ASDisplayNode { + private let iconNode: TransformImageNode + private let textNode: ASTextNode + private var action: (() -> Void)? + + override init() { + self.iconNode = TransformImageNode() + self.iconNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 60.0, height: 60.0)) + self.iconNode.isLayerBacked = true + + self.textNode = ASTextNode() + self.textNode.isLayerBacked = true + self.textNode.displaysAsynchronously = true + + super.init() + + self.addSubnode(self.iconNode) + self.addSubnode(self.textNode) + } + + func setup(postbox: Postbox, applicationContext: TelegramApplicationContext, theme: ActionSheetControllerTheme, option: OpenInOption) { + self.textNode.attributedText = NSAttributedString(string: option.title, font: textFont, textColor: theme.primaryTextColor, paragraphAlignment: .center) + + let iconSize = CGSize(width: 60.0, height: 60.0) + let makeLayout = self.iconNode.asyncLayout() + let applyLayout = makeLayout(TransformImageArguments(corners: ImageCorners(radius: 16.0), imageSize: iconSize, boundingSize: iconSize, intrinsicInsets: UIEdgeInsets())) + applyLayout() + + //option.a + +// switch option.action { +// case .o +//// case .safari: +//// self.iconNode.setSignal(openInAppIcon(postbox: postbox, appIcon: nil)) +//// self.action = { +//// applicationContext.applicationBindings.openUrl("https://telegram.org") +//// } +//// // //self.iconNode.setSignal( +//// // case .maps: +//// // nil +//// case let .external(identifier, _, _): +//// self.iconNode.setSignal(openInAppIcon(postbox: postbox, appIcon: OpenInAppIconResource(appStoreId: identifier))) +//// self.action = { +//// applicationContext.applicationBindings.openUrl("googlechromes://telegram.org") +//// } +//// default: +//// break +//// } +// } + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.action?() + } + } + + override func layout() { + super.layout() + + let bounds = self.bounds + + self.iconNode.frame = CGRect(origin: CGPoint(x: 10.0, y: 14.0), size: CGSize(width: 60.0, height: 60.0)) + self.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 14.0 + 60.0 + 4.0), size: CGSize(width: bounds.size.width, height: 16.0)) + } +} diff --git a/TelegramUI/OpenInAppIconResources.swift b/TelegramUI/OpenInAppIconResources.swift new file mode 100644 index 0000000000..78fe322d28 --- /dev/null +++ b/TelegramUI/OpenInAppIconResources.swift @@ -0,0 +1,59 @@ +import Foundation +import TelegramCore +import SwiftSignalKit +import Postbox + +func fetchOpenInAppIconResource(account: Account, resource: OpenInAppIconResource) -> Signal { + return Signal { subscriber in + subscriber.putNext(.reset) + + let metaUrl = "https://itunes.apple.com/lookup?id=\(resource.appStoreId)" + + let fetchDisposable = MetaDisposable() + + let disposable = fetchHttpResource(url: metaUrl).start(next: { result in + if case let .dataPart(_, data, _, complete) = result, complete { + guard let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + guard let results = dict["results"] as? [Any] else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + guard let result = results.first as? [String: Any] else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + guard let artworkUrl = result["artworkUrl100"] as? String else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + if artworkUrl.isEmpty { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } else { + fetchDisposable.set(fetchHttpResource(url: artworkUrl).start(next: { next in + subscriber.putNext(next) + }, completed: { + subscriber.putCompletion() + })) + } + } + }) + + return ActionDisposable { + disposable.dispose() + fetchDisposable.dispose() + } + } +} diff --git a/TelegramUI/OpenInOptions.swift b/TelegramUI/OpenInOptions.swift new file mode 100644 index 0000000000..eb7bd2b81a --- /dev/null +++ b/TelegramUI/OpenInOptions.swift @@ -0,0 +1,218 @@ +import UIKit +import TelegramCore +import CoreLocation +import MapKit + +enum OpenInItem { + case url(_ url: String) + case location(_ location: TelegramMediaMap, withDirections: Bool) +} + +enum OpenInApplication { + case safari + case maps + case other(title: String, identifier: Int64, scheme: String) +} + +enum OpenInAction { + case openUrl(_ url: String) + case openLocation(latitude: Double, longitude: Double, withDirections: Bool) +} + +final class OpenInOption { + let application: OpenInApplication + let action: () -> OpenInAction + + init(application: OpenInApplication, action: @escaping () -> OpenInAction) { + self.application = application + self.action = action + } + + var title: String { + get { + switch self.application { + case .safari: + return "Safari" + case .maps: + return "Maps" + case let .other(title, _, _): + return title + } + } + } +} + +func availableOpenInOptions(applicationContext: TelegramApplicationContext, item: OpenInItem) -> [OpenInOption] { + return allOpenInOptions(applicationContext: applicationContext, item: item).filter { option in + if case let .other(_, _, scheme) = option.application { + return applicationContext.applicationBindings.canOpenUrl("\(scheme)://") + } else { + return true + } + } +} + +private func allOpenInOptions(applicationContext: TelegramApplicationContext, item: OpenInItem) -> [OpenInOption] { + var options: [OpenInOption] = [] + switch item { + case let .url(url): + options.append(OpenInOption(application: .safari, action: { + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Chrome", identifier: 535886823, scheme: "chrome"), action: { +// NSURL *url = (NSURL *)self.object; +// NSString *scheme = [url.scheme lowercaseString]; +// +// bool secure = [scheme isEqualToString:@"https"]; +// if (!secure && ![scheme isEqualToString:@"http"]) +// return; +// +// NSURL *openInURL = nil; +// if (iosMajorVersion() >= 7) +// { +// NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:true]; +// components.scheme = secure ? @"googlechromes" : @"googlechrome"; +// openInURL = components.URL; +// } +// else +// { +// NSString *str = url.absoluteString; +// NSInteger colon = [str rangeOfString:@":"].location; +// if (colon != NSNotFound) +// str = [(secure ? @"googlechromes" : @"googlechrome") stringByAppendingString:[str substringFromIndex:colon]]; +// openInURL = [NSURL URLWithString:str]; +// } + + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Firefox", identifier: 989804926, scheme: "firefox"), action: { +// NSURL *url = (NSURL *)self.object; +// NSString *scheme = [url.scheme lowercaseString]; +// +// if (![scheme isEqualToString:@"http"] && ![scheme isEqualToString:@"https"]) +// return; +// +// NSURL *openInURL = [NSURL URLWithString:[NSString stringWithFormat:@"firefox://open-url?url=%@", [TGStringUtils stringByEscapingForURL:url.absoluteString]]]; +// [TGOpenInBrowserItem openURL:openInURL]; + + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Opera Mini", identifier: 363729560, scheme: "opera-http"), action: { +// bool secure = [scheme isEqualToString:@"https"]; +// if (!secure && ![scheme isEqualToString:@"http"]) +// return; +// +// NSURL *openInURL = nil; +// if (iosMajorVersion() >= 7) +// { +// NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:true]; +// components.scheme = secure ? @"opera-https" : @"opera-http"; +// openInURL = components.URL; +// } +// else +// { +// NSString *str = url.absoluteString; +// NSInteger colon = [str rangeOfString:@":"].location; +// if (colon != NSNotFound) +// str = [(secure ? @"opera-https" : @"opera-http") stringByAppendingString:[str substringFromIndex:colon]]; +// openInURL = [NSURL URLWithString:str]; +// } + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Yandex", identifier: 483693909, scheme: "yandexbrowser-open-url"), action: { +// NSURL *url = (NSURL *)self.object; +// NSString *scheme = [url.scheme lowercaseString]; +// +// if (![scheme isEqualToString:@"http"] && ![scheme isEqualToString:@"https"]) +// return; +// +// NSURL *openInURL = [NSURL URLWithString:[NSString stringWithFormat:@"yandexbrowser-open-url://%@", [TGStringUtils stringByEscapingForURL:url.absoluteString]]]; +// [TGOpenInBrowserItem openURL:openInURL]; + return .openUrl(url) + })) + + + case let .location(location, withDirections): + let lat = location.latitude + let lon = location.longitude + + if let venue = location.venue, let venueId = venue.id, let provider = venue.provider, provider == "foursquare" { + options.append(OpenInOption(application: .other(title: "Foursquare", identifier: 306934924, scheme: "foursquare"), action: { + return .openUrl("foursquare://venues/\(venueId)") + })) + } + + options.append(OpenInOption(application: .maps, action: { + return .openLocation(latitude: lat, longitude: lon, withDirections: withDirections) + })) + + options.append(OpenInOption(application: .other(title: "Google Maps", identifier: 585027354, scheme: "comgooglemaps-x-callback"), action: { + let coordinates = "\(lat),\(lon)" + if withDirections { + return .openUrl("comgooglemaps-x-callback://?daddr=\(coordinates)&directionsmode=driving&x-success=telegram://?resume=true&&x-source=Telegram") + } else { + return .openUrl("comgooglemaps-x-callback://?center=\(coordinates)&q=\(coordinates)&x-success=telegram://?resume=true&&x-source=Telegram") + } + })) + + options.append(OpenInOption(application: .other(title: "Yandex.Maps", identifier: 313877526, scheme: "yandexmaps"), action: { + if withDirections { + return .openUrl("yandexmaps://build_route_on_map?lat_to=\(lat)&lon_to=\(lon)") + } else { + return .openUrl("yandexmaps://maps.yandex.ru/?pt=\(lat),\(lon)&z=16") + } + })) + + options.append(OpenInOption(application: .other(title: "Uber", identifier: 368677368, scheme: "uber"), action: { + var dropoffName = "" + var dropoffAddress = "" + if let title = location.venue?.title.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), title.count > 0 { + dropoffName = title + } + if let address = location.venue?.address?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), address.count > 0 { + dropoffAddress = address + } + return .openUrl("uber://?client_id=&action=setPickup&pickup=my_location&dropoff[latitude]=\(lat)&dropoff[longitude]=\(lon)&dropoff[nickname]=\(dropoffName)&dropoff[formatted_address]=\(dropoffAddress)") + })) + + options.append(OpenInOption(application: .other(title: "Lyft", identifier: 529379082, scheme: "lyft"), action: { + return .openUrl("lyft://ridetype?id=lyft&destination[latitude]=\(lat)&destination[longitude]=\(lon)") + })) + + options.append(OpenInOption(application: .other(title: "Citymapper", identifier: 469463298, scheme: "citymapper"), action: { + var endName = "" + var endAddress = "" + if let title = location.venue?.title.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), title.count > 0 { + endName = title + } + if let address = location.venue?.address?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), address.count > 0 { + endAddress = address + } + return .openUrl("citymapper://directions?endcoord=\(lat),\(lon)&endname=\(endName)&endaddress=\(endAddress)") + })) + + if withDirections { + options.append(OpenInOption(application: .other(title: "Yandex.Navi", identifier: 474500851, scheme: "yandexnavi"), action: { + return .openUrl("yandexnavi://build_route_on_map?lat_to=\(lat)&lon_to=\(lon)") + })) + } + + options.append(OpenInOption(application: .other(title: "HERE Maps", identifier: 955837609, scheme: "here-location"), action: { + return .openUrl("here-location://\(lat),\(lon)") + })) + + options.append(OpenInOption(application: .other(title: "Waze", identifier: 323229106, scheme: "waze"), action: { + let url = "waze://?ll=\(lat),\(lon)" + if withDirections { + return .openUrl(url.appending("&navigate=yes")) + } else { + return .openUrl(url) + } + })) + } + return options +} diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift index 02a34ad840..efff4cd806 100644 --- a/TelegramUI/PeerMediaCollectionController.swift +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -175,10 +175,13 @@ public class PeerMediaCollectionController: TelegramController { if let strongSelf = self { switch content { case let .url(url): + let canOpenIn = true + let openText = canOpenIn ? strongSelf.presentationData.strings.Conversation_FileOpenIn : strongSelf.presentationData.strings.Conversation_LinkDialogOpen + let actionSheet = ActionSheetController(presentationTheme: strongSelf.presentationData.theme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: url), - ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_LinkDialogOpen, color: .accent, action: { [weak actionSheet] in + ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { diff --git a/TelegramUI/PhotoResources.swift b/TelegramUI/PhotoResources.swift index 3e43cf7c28..1089d1d0ac 100644 --- a/TelegramUI/PhotoResources.swift +++ b/TelegramUI/PhotoResources.swift @@ -2427,3 +2427,101 @@ func securePhotoInternal(account: Account, resource: TelegramMediaResource, acce }) } } + +private func openInAppIconData(postbox: Postbox, appIcon: MediaResource) -> Signal<(Data?), NoError> { + let appIconResource = postbox.mediaBox.resourceData(appIcon) + + let signal = appIconResource |> take(1) |> mapToSignal { maybeData -> Signal<(Data?), NoError> in + if maybeData.complete { + let loadedData: Data? = try? Data(contentsOf: URL(fileURLWithPath: maybeData.path), options: []) + return .single((loadedData)) + } else { + let fetchedAppIcon = postbox.mediaBox.fetchedResource(appIcon, parameters: nil) + + let appIcon = Signal { subscriber in + let fetchedDisposable = fetchedAppIcon.start() + let appIconDisposable = appIconResource.start(next: { next in + subscriber.putNext(next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: [])) + }, error: subscriber.putError, completed: subscriber.putCompletion) + + return ActionDisposable { + fetchedDisposable.dispose() + appIconDisposable.dispose() + } + } + + return appIcon + } + } |> distinctUntilChanged(isEqual: { lhs, rhs in + if lhs == nil && rhs == nil { + return true + } else { + return false + } + }) + + return signal +} + +private func drawOpenInAppIconBorder(into c: CGContext, arguments: TransformImageArguments) { + c.setBlendMode(.normal) + c.setStrokeColor(UIColor(rgb: 0xeeeeee).cgColor) + c.setLineWidth(1.0) + + var cornerRadius: CGFloat = 0.0 + if case let .Corner(radius) = arguments.corners.topLeft, radius > CGFloat.ulpOfOne { + cornerRadius = radius + } + + let path = UIBezierPath(roundedRect: arguments.drawingRect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: cornerRadius) + c.addPath(path.cgPath) + c.strokePath() +} + +func openInAppIcon(postbox: Postbox, appIcon: TelegramMediaResource?) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + if let appIcon = appIcon { + return openInAppIconData(postbox: postbox, appIcon: appIcon) |> map { data in + return { arguments in + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + var sourceImage: UIImage? + if let data = data, let image = UIImage(data: data) { + sourceImage = image + } + + if let sourceImage = sourceImage, let cgImage = sourceImage.cgImage { + let imageSize = sourceImage.size.aspectFilled(arguments.drawingRect.size) + context.withFlippedContext { c in + c.draw(cgImage, in: CGRect(origin: CGPoint(x: floor((arguments.drawingRect.size.width - imageSize.width) / 2.0), y: floor((arguments.drawingRect.size.height - imageSize.height) / 2.0)), size: imageSize)) + drawOpenInAppIconBorder(into: c, arguments: arguments) + } + } else { + context.withFlippedContext { c in + drawOpenInAppIconBorder(into: c, arguments: arguments) + } + } + + addCorners(context, arguments: arguments) + + return context + } + } + } else { + return .single({ arguments in + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + let img = UIImage(bundleImageName: "Open In/Safari") + + context.withFlippedContext { c in + if let image = img { + c.draw(image.cgImage!, in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: arguments.drawingSize)) + } + drawOpenInAppIconBorder(into: c, arguments: arguments) + } + + addCorners(context, arguments: arguments) + + return context + }) + } +} diff --git a/TelegramUI/Resources/WebEmbed/Generic.html b/TelegramUI/Resources/WebEmbed/Generic.html new file mode 100755 index 0000000000..bb80007337 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Generic.html @@ -0,0 +1,28 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/GenericUserScript.js b/TelegramUI/Resources/WebEmbed/GenericUserScript.js new file mode 100644 index 0000000000..4430900d38 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/GenericUserScript.js @@ -0,0 +1,26 @@ +function initialize() { + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitExitFullscreen = undefined; + + video.play(); + } +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); diff --git a/TelegramUI/Resources/WebEmbed/Instagram.html b/TelegramUI/Resources/WebEmbed/Instagram.html new file mode 100755 index 0000000000..147d33a100 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Instagram.html @@ -0,0 +1,40 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/Twitch.html b/TelegramUI/Resources/WebEmbed/Twitch.html new file mode 100755 index 0000000000..b1b5faa9e3 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Twitch.html @@ -0,0 +1,47 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/TwitchUserScript.js b/TelegramUI/Resources/WebEmbed/TwitchUserScript.js new file mode 100644 index 0000000000..c4865582a7 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/TwitchUserScript.js @@ -0,0 +1,114 @@ +function initialize() { + var controls = document.getElementsByClassName("player-controls-bottom")[0]; + if (controls == null) { + controls = document.getElementsByClassName("player-overlay-container")[0]; + } + if (controls != null) { + controls.style.display = "none"; + } + + var topBar = document.getElementById("top-bar"); + if (topBar == null) { + topBar = document.getElementsByClassName("player-controls-top")[0]; + } + if (topBar != null) { + topBar.style.display = "none"; + } + + var pauseOverlay = document.getElementsByClassName("player-play-overlay")[0]; + if (pauseOverlay == null) { + pauseOverlay = document.getElementsByClassName("player-controls-bottom")[0]; + } + if (pauseOverlay != null) { + pauseOverlay.style.display = "none"; + } + + var statusOverlay = document.getElementsByClassName("player-streamstatus")[0]; + if (statusOverlay != null) { + statusOverlay.style.right = undefined; + statusOverlay.style.left = "0px"; + statusOverlay.style.padding = "1.5em 1.5em 5.5em 2.5em"; + } + + var recommendationOverlay = document.getElementById("js-player-recommendations-overlay"); + if (recommendationOverlay != null) { + recommendationOverlay.style.display = "none"; + } + + var adOverlay = document.getElementsByClassName("player-ad-overlay")[0]; + if (adOverlay != null) { + adOverlay.style.display = "none"; + } + + var alertOverlay = document.getElementById("js-player-alert-container"); + if (alertOverlay != null) { + alertOverlay.style.display = "none"; + } + + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitEnterFullscreen = undefined; + video.addEventListener("playing", onPlaybackStart, false); + video.play(); + } + + var css = "video::-webkit-media-controls { display: none !important } video::--webkit-media-controls-play-button { display: none !important; -webkit-appearance: none; } video::-webkit-media-controls-start-playback-button { display: none !important; -webkit-appearance: none; }", + head = document.head || document.getElementsByTagName("head")[0], + style = document.createElement("style"); + style.type = "text/css"; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + head.appendChild(style); + + var ageButton = document.getElementById("mature-link"); + if (ageButton != null) { + eventFire(ageButton, "click"); + } +} + +function onPlaybackStart() { + window.parent.postMessage("playbackStarted", "*"); +} + +function eventFire(el, etype){ + if (el.fireEvent) { + el.fireEvent("on" + etype); + } else { + var evObj = document.createEvent("Events"); + evObj.initEvent(etype, true, false); + el.dispatchEvent(evObj); + } +} + +function play() { + var playButton = document.getElementsByClassName("js-control-playpause-button")[0]; + if (playButton == null) { + playButton = document.getElementsByClassName("player-button--playpause")[0]; + } + + eventFire(playButton, "click"); +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + else if (obj.command == "play") + play(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); + diff --git a/TelegramUI/Resources/WebEmbed/Vimeo.html b/TelegramUI/Resources/WebEmbed/Vimeo.html new file mode 100755 index 0000000000..ecea52af89 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Vimeo.html @@ -0,0 +1,101 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/VimeoUserScript.js b/TelegramUI/Resources/WebEmbed/VimeoUserScript.js new file mode 100644 index 0000000000..dae2e18316 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/VimeoUserScript.js @@ -0,0 +1,53 @@ +function initialize() { + var controls = document.getElementsByClassName("controls")[0]; + if (controls != null) { + controls.style.display = "none"; + } + + var sidedock = document.getElementsByClassName("sidedock")[0]; + if (sidedock != null) { + sidedock.style.display = "none"; + } + + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitEnterFullscreen = undefined; + } +} + +function eventFire(el, etype){ + if (el.fireEvent) { + el.fireEvent("on" + etype); + } else { + var evObj = document.createEvent("Events"); + evObj.initEvent(etype, true, false); + el.dispatchEvent(evObj); + } +} + +function autoplay() { + var playButton = document.getElementsByClassName("play")[0]; + if (playButton != null) { + eventFire(playButton, "click"); + } +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + else if (obj.command == "autoplay") + autoplay(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); diff --git a/TelegramUI/Resources/WebEmbed/Youtube.html b/TelegramUI/Resources/WebEmbed/Youtube.html new file mode 100755 index 0000000000..911bfcff04 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Youtube.html @@ -0,0 +1,99 @@ + + + + + + +
+
+
+ + + + diff --git a/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js b/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js new file mode 100644 index 0000000000..e0b3cbc341 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js @@ -0,0 +1,58 @@ +function initialize() { + var css = "video::-webkit-media-controls { display: none !important } video::--webkit-media-controls-play-button { display: none !important; -webkit-appearance: none; } video::-webkit-media-controls-start-playback-button { display: none !important; -webkit-appearance: none; }", + head = document.head || document.getElementsByTagName("head")[0], + style = document.createElement("style"); + + style.type = "text/css"; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + + head.appendChild(style); +} + +function tick() { + var watermark = document.getElementsByClassName("ytp-watermark")[0]; + if (watermark != null) { + watermark.style.display = "none"; + } + + var button = document.getElementsByClassName("ytp-large-play-button")[0]; + if (button != null) { + button.style.display = "none"; + button.style.opacity = "0"; + } + + var progress = document.getElementsByClassName("ytp-spinner-container")[0]; + if (progress != null) { + progress.style.display = "none"; + progress.style.opacity = "0"; + } + + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitEnterFullscreen = undefined; + } +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + else if (obj.command == "tick") + tick(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); diff --git a/TelegramUI/SoundCloudEmbedImplementation.swift b/TelegramUI/SoundCloudEmbedImplementation.swift new file mode 100644 index 0000000000..f893c8151c --- /dev/null +++ b/TelegramUI/SoundCloudEmbedImplementation.swift @@ -0,0 +1,59 @@ +import UIKit + +func extractSoundCloudTrackIdAndTimestamp(url: String) -> (String, Int)? { + guard let url = URL(string: url), let host = url.host?.lowercased() else { + return nil + } + + let domain = "w.soundcloud.com" + let match = host == domain || host.contains(".\(domain)") + + guard match else { + return nil + } + + var videoId: String? + var timestamp: Int? + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "v" { + videoId = value + } else if queryItem.name == "t" || queryItem.name == "time_continue" { + if value.contains("s") { + + } else { + timestamp = Int(value) + } + } + } + } + } + + if videoId == nil { + let pathComponents = components.path.components(separatedBy: "/") + var nextComponentIsVideoId = host.contains("youtu.be") + + for component in pathComponents { + if nextComponentIsVideoId { + videoId = component + break + } else if component == "embed" { + nextComponentIsVideoId = true + } + } + } + } + + if let videoId = videoId { + return (videoId, timestamp ?? 0) + } + + return nil +} + +final class SoundCloudEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/StreamableEmbedImplementation.swift b/TelegramUI/StreamableEmbedImplementation.swift new file mode 100644 index 0000000000..87b193e031 --- /dev/null +++ b/TelegramUI/StreamableEmbedImplementation.swift @@ -0,0 +1,5 @@ +import UIKit + +final class StreamableEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/TelegramAccountAuxiliaryMethods.swift b/TelegramUI/TelegramAccountAuxiliaryMethods.swift index 891040dba4..881b9a73bb 100644 --- a/TelegramUI/TelegramAccountAuxiliaryMethods.swift +++ b/TelegramUI/TelegramAccountAuxiliaryMethods.swift @@ -25,6 +25,8 @@ public let telegramAccountAuxiliaryMethods = AccountAuxiliaryMethods(updatePeerC return fetchICloudFileResource(resource: resource) } else if let resource = resource as? SecureIdLocalImageResource { return fetchSecureIdLocalImageResource(postbox: account.postbox, resource: resource) + } else if let resource = resource as? OpenInAppIconResource { + return fetchOpenInAppIconResource(account: account, resource: resource) } return nil }, fetchResourceMediaReferenceHash: { resource in diff --git a/TelegramUI/TwitchEmbedImplementation.swift b/TelegramUI/TwitchEmbedImplementation.swift new file mode 100644 index 0000000000..25bc13273b --- /dev/null +++ b/TelegramUI/TwitchEmbedImplementation.swift @@ -0,0 +1,5 @@ +import UIKit + +final class TwitchEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/UniversalVideoCalleryItem.swift b/TelegramUI/UniversalVideoCalleryItem.swift index 1dedf40688..469defb826 100644 --- a/TelegramUI/UniversalVideoCalleryItem.swift +++ b/TelegramUI/UniversalVideoCalleryItem.swift @@ -38,6 +38,11 @@ class UniversalVideoGalleryItem: GalleryItem { func node() -> GalleryItemNode { let node = UniversalVideoGalleryItemNode(account: self.account, theme: self.theme, strings: self.strings) + + if let indexData = self.indexData { + node._title.set(.single("\(indexData.position + 1) \(self.strings.Common_of) \(indexData.totalCount)")) + } + node.setupItem(self) return node @@ -45,6 +50,10 @@ class UniversalVideoGalleryItem: GalleryItem { func updateNode(node: GalleryItemNode) { if let node = node as? UniversalVideoGalleryItemNode { + if let indexData = self.indexData { + node._title.set(.single("\(indexData.position + 1) \(self.strings.Common_of) \(indexData.totalCount)")) + } + node.setupItem(self) } } @@ -149,6 +158,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.scrubberView = ChatVideoGalleryItemScrubberView() self.footerContentNode = ChatItemGalleryFooterContentNode(account: account, theme: theme, strings: strings) + self.footerContentNode.scrubberView = self.scrubberView self.statusButtonNode = HighlightableButtonNode() self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5)) @@ -175,6 +185,24 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { strongSelf.videoNode?.togglePlayPause() } } + self.footerContentNode.seekBackward = { [weak self] in + if let strongSelf = self, let videoNode = strongSelf.videoNode { + let _ = (videoNode.status |> take(1)).start(next: { [weak videoNode] status in + if let strongVideoNode = videoNode, let timestamp = status?.timestamp { + strongVideoNode.seek(max(0.0, timestamp - 15.0)) + } + }) + } + } + self.footerContentNode.seekForward = { [weak self] in + if let strongSelf = self, let videoNode = strongSelf.videoNode { + let _ = (videoNode.status |> take(1)).start(next: { [weak videoNode] status in + if let strongVideoNode = videoNode, let timestamp = status?.timestamp, let duration = status?.duration { + strongVideoNode.seek(min(duration, timestamp + 15.0)) + } + }) + } + } } deinit { @@ -243,6 +271,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if let strongSelf = self { var initialBuffering = false var isPaused = true + var seekable = false if let value = value { if let zoomableContent = strongSelf.zoomableContent, !value.dimensions.width.isZero && !value.dimensions.height.isZero { let videoSize = CGSize(width: value.dimensions.width * 2.0, height: value.dimensions.height * 2.0) @@ -270,6 +299,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } } } + seekable = value.duration >= 44.0 } if initialBuffering { @@ -286,12 +316,12 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } if isPaused { if strongSelf.didPause { - strongSelf.footerContentNode.content = .playbackPlay + strongSelf.footerContentNode.content = .playback(paused: true, seekable: seekable) } else { strongSelf.footerContentNode.content = .info } } else { - strongSelf.footerContentNode.content = .playbackPause + strongSelf.footerContentNode.content = .playback(paused: false, seekable: seekable) } } })) @@ -308,7 +338,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } if !isAnimated && !isInstagram { - self._titleView.set(.single(self.scrubberView)) + //self._titleView.set(.single(self.scrubberView)) } if !isAnimated { diff --git a/TelegramUI/VKEmbedImplementation.swift b/TelegramUI/VKEmbedImplementation.swift new file mode 100644 index 0000000000..16e18c5867 --- /dev/null +++ b/TelegramUI/VKEmbedImplementation.swift @@ -0,0 +1,5 @@ +import UIKit + +final class VKEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/VimeoEmbedImplementation.swift b/TelegramUI/VimeoEmbedImplementation.swift new file mode 100644 index 0000000000..54ed30cad8 --- /dev/null +++ b/TelegramUI/VimeoEmbedImplementation.swift @@ -0,0 +1,208 @@ +import Foundation +import WebKit +import SwiftSignalKit + +func extractVimeoVideoIdAndTimestamp(url: String) -> (String, Int)? { + guard let url = URL(string: url), let host = url.host?.lowercased() else { + return nil + } + + let domain = "player.vimeo.com" + let match = host == domain || host.contains(".\(domain)") + + guard match else { + return nil + } + + var videoId: String? + var timestamp = 0 + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { +// if let queryItems = components.queryItems { +// for queryItem in queryItems { +// if let value = queryItem.value { +// if queryItem.name == "v" { +// videoId = value +// } else if queryItem.name == "t" || queryItem.name == "time_continue" { +// if value.contains("s") { +// +// } else { +// timestamp = Int(value) +// } +// } +// } +// } +// } + + if videoId == nil { + let pathComponents = components.path.components(separatedBy: "/") + var nextComponentIsVideoId = false + + for component in pathComponents { + if nextComponentIsVideoId { + videoId = component + break + } else if component == "video" { + nextComponentIsVideoId = true + } + } + } + } + + if let videoId = videoId { + return (videoId, timestamp) + } + + return nil +} + +final class VimeoEmbedImplementation: WebEmbedImplementation { + private var evalImpl: ((String) -> Void)? + private var updateStatus: ((MediaPlayerStatus) -> Void)? + private var onPlaybackStarted: (() -> Void)? + + private let videoId: String + private let timestamp: Int + private var status : MediaPlayerStatus + + private var ready: Bool = false + private var started: Bool = false + private var ignorePosition: Int? + + init(videoId: String, timestamp: Int = 0) { + self.videoId = videoId + self.timestamp = timestamp + self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true)) + } + + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + let bundle = Bundle(for: type(of: self)) + guard let userScriptPath = bundle.path(forResource: "VimeoUserScript", ofType: "js") else { + return + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return + } + guard let htmlTemplatePath = bundle.path(forResource: "Vimeo", ofType: "html") else { + return + } + guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else { + return + } + guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else { + return + } + + self.evalImpl = evaluateJavaScript + self.updateStatus = updateStatus + self.onPlaybackStarted = onPlaybackStarted + updateStatus(self.status) + + let html = String(format: htmlTemplate, self.videoId, "true") + webView.loadHTMLString(html, baseURL: URL(string: "https://player.vimeo.com/")) + webView.isUserInteractionEnabled = false + + userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) + } + + func play() { + if let eval = evalImpl { + eval("play();") + } + + ignorePosition = 2 + } + + func pause() { + if let eval = evalImpl { + eval("pause();") + } + } + + func togglePlayPause() { + if case .playing = self.status.status { + pause() + } else { + play() + } + } + + func seek(timestamp: Double) { + if let eval = evalImpl { + eval("seek(\(timestamp));") + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, seekId: self.status.seekId + 1, status: self.status.status) + if let updateStatus = self.updateStatus { + updateStatus(self.status) + } + + ignorePosition = 2 + } + + func pageReady() { + } + + func callback(url: URL) { + if url.host == "onState" { + var newTimestamp = self.status.timestamp + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + var playback: Int? + var position: Double? + var duration: Double? + var download: Float? + var failed: Bool? + + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "playback" { + playback = Int(value) + } else if queryItem.name == "position" { + position = Double(value) + } else if queryItem.name == "duration" { + duration = Double(value) + } else if queryItem.name == "download" { + download = Float(value) + } + } + } + } + + if let position = position { + if let ticksToIgnore = self.ignorePosition { + if ticksToIgnore > 1 { + self.ignorePosition = ticksToIgnore - 1 + } else { + self.ignorePosition = nil + } + } else { + newTimestamp = Double(position) + } + } + + if let updateStatus = self.updateStatus, let playback = playback, let duration = duration { + let playbackStatus: MediaPlayerPlaybackStatus + switch playback { + case 0: + playbackStatus = .paused + case 1: + playbackStatus = .playing + case 2: + playbackStatus = .paused + newTimestamp = 0.0 + default: + playbackStatus = .buffering(initial: true, whilePlaying: false) + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: Double(duration), dimensions: self.status.dimensions, timestamp: newTimestamp, seekId: self.status.seekId, status: playbackStatus) + updateStatus(self.status) + } + } + } + } +} diff --git a/TelegramUI/WebEmbedPlayerNode.swift b/TelegramUI/WebEmbedPlayerNode.swift new file mode 100644 index 0000000000..d8812af13c --- /dev/null +++ b/TelegramUI/WebEmbedPlayerNode.swift @@ -0,0 +1,148 @@ +import Foundation +import AsyncDisplayKit +import SwiftSignalKit +import WebKit + +protocol WebEmbedImplementation { + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) + + func play() + func pause() + func togglePlayPause() + func seek(timestamp: Double) + + func pageReady() + func callback(url: URL) +} + +func webEmbedImplementation(embedUrl: String, url: String) -> WebEmbedImplementation { + if let (videoId, timestamp) = extractYoutubeVideoIdAndTimestamp(url: url) { + return YoutubeEmbedImplementation(videoId: videoId, timestamp: timestamp) + } else if let (videoId, timestamp) = extractVimeoVideoIdAndTimestamp(url: url) { + return VimeoEmbedImplementation(videoId: videoId, timestamp: timestamp) + } + + return GenericEmbedImplementation(url: url) +} + +final class WebEmbedPlayerNode: ASDisplayNode, WKNavigationDelegate { + private let statusValue = ValuePromise(MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, seekId: 0, status: .paused), ignoreRepeated: true) + + var status: Signal { + return self.statusValue.get() + } + + private let impl: WebEmbedImplementation + + private let intrinsicDimensions: CGSize + private let webView: WKWebView + + private let semaphore = DispatchSemaphore(value: 0) + private let queue = Queue() + + init(impl: WebEmbedImplementation, intrinsicDimensions: CGSize) { + self.impl = impl + self.intrinsicDimensions = intrinsicDimensions + + let userContentController = WKUserContentController() + userContentController.addUserScript(WKUserScript(source: "var meta = document.createElement('meta'); meta.setAttribute('name', 'viewport'); meta.setAttribute('content', 'width=device-width'); document.getElementsByTagName('head')[0].appendChild(meta)", injectionTime: .atDocumentEnd, forMainFrameOnly: true)) + + let config = WKWebViewConfiguration() + config.allowsInlineMediaPlayback = true + config.userContentController = userContentController + + if #available(iOSApplicationExtension 10.0, *) { + config.mediaTypesRequiringUserActionForPlayback = [] + } else if #available(iOSApplicationExtension 9.0, *) { + config.requiresUserActionForMediaPlayback = false + } else { + config.mediaPlaybackRequiresUserAction = false + } + + if #available(iOSApplicationExtension 9.0, *) { + config.allowsPictureInPictureMediaPlayback = false + } + + let frame = CGRect(origin: CGPoint.zero, size: intrinsicDimensions) + self.webView = WKWebView(frame: frame, configuration: config) + + super.init() + self.frame = frame + + self.webView.navigationDelegate = self + self.webView.scrollView.isScrollEnabled = false + if #available(iOSApplicationExtension 11.0, *) { + self.webView.accessibilityIgnoresInvertColors = true + self.webView.scrollView.contentInsetAdjustmentBehavior = .never + } + self.view.addSubview(self.webView) + + self.impl.setup(self.webView, userContentController: userContentController, evaluateJavaScript: { [weak self] js in + if let strongSelf = self { + strongSelf.evaluateJavaScript(js: js) + } + }, updateStatus: { [weak self] status in + if let strongSelf = self { + strongSelf.statusValue.set(status) + } + }, onPlaybackStarted: { [weak self] in + + }) + } + + func play() { + self.impl.play() + } + + func pause() { + self.impl.pause() + } + + func togglePlayPause() { + self.impl.togglePlayPause() + } + + func seek(timestamp: Double) { + self.impl.seek(timestamp: timestamp) + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.impl.pageReady() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + if let error = error as? WKError, error.code.rawValue == 204 { + return + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if let url = navigationAction.request.url, url.scheme == "embed" { + self.impl.callback(url: url) + decisionHandler(.cancel) + } else if let _ = navigationAction.targetFrame { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + + private func evaluateJavaScript(js: String) { + self.queue.async { [weak self] in + if let strongSelf = self { + let impl = { + strongSelf.webView.evaluateJavaScript(js, completionHandler: { (_, _) in + strongSelf.semaphore.signal() + }) + } + + Queue.mainQueue().async(impl) + strongSelf.semaphore.wait() + } + } + } +} diff --git a/TelegramUI/WebEmbedVideoContent.swift b/TelegramUI/WebEmbedVideoContent.swift index 879fe0d835..268d7bb3cc 100644 --- a/TelegramUI/WebEmbedVideoContent.swift +++ b/TelegramUI/WebEmbedVideoContent.swift @@ -7,32 +7,6 @@ import TelegramCore import LegacyComponents -func webEmbedVideoContentSupportsWebpage(_ webpageContent: TelegramMediaWebpageLoadedContent) -> Bool { - switch websiteType(of: webpageContent) { - case .instagram: - return true - default: - break - } - - let converted = TGWebPageMediaAttachment() - - converted.url = webpageContent.url - converted.displayUrl = webpageContent.displayUrl - converted.pageType = webpageContent.type - converted.siteName = webpageContent.websiteName - converted.title = webpageContent.title - converted.pageDescription = webpageContent.text - converted.embedUrl = webpageContent.embedUrl - converted.embedType = webpageContent.embedType - converted.embedSize = webpageContent.embedSize ?? CGSize() - let approximateDuration = Int32(webpageContent.duration ?? 0) - converted.duration = approximateDuration as NSNumber - converted.author = webpageContent.author - - return TGEmbedPlayerView.hasNativeSupportFor(x: converted) -} - final class WebEmbedVideoContent: UniversalVideoContent { let id: AnyHashable let webPage: TelegramMediaWebpage @@ -61,15 +35,13 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte private let intrinsicDimensions: CGSize private let approximateDuration: Int32 - private let playerView: TGEmbedPlayerView - private let playerViewContainer: UIView private let audioSessionDisposable = MetaDisposable() private var hasAudioSession = false private let playbackCompletedListeners = Bag<() -> Void>() private var initializedStatus = false - private let _status = ValuePromise() + private let _status = Promise() var status: Signal { return self._status.get() } @@ -91,6 +63,9 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte return self._preloadCompleted.get() } + private let imageNode: TransformImageNode + private let playerNode: WebEmbedPlayerNode + private let thumbnail = Promise() private var thumbnailDisposable: Disposable? @@ -99,72 +74,45 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte init(postbox: Postbox, audioSessionManager: ManagedAudioSession, webPage: TelegramMediaWebpage, webpageContent: TelegramMediaWebpageLoadedContent) { self.webpageContent = webpageContent - - let converted = TGWebPageMediaAttachment() - - converted.url = webpageContent.url - converted.displayUrl = webpageContent.displayUrl - converted.pageType = webpageContent.type - converted.siteName = webpageContent.websiteName - converted.title = webpageContent.title - converted.pageDescription = webpageContent.text - converted.embedUrl = webpageContent.embedUrl - converted.embedType = webpageContent.embedType - converted.embedSize = webpageContent.embedSize ?? CGSize() self.approximateDuration = Int32(webpageContent.duration ?? 0) - converted.duration = self.approximateDuration as NSNumber - converted.author = webpageContent.author if let embedSize = webpageContent.embedSize { self.intrinsicDimensions = embedSize } else { self.intrinsicDimensions = CGSize(width: 480.0, height: 320.0) } - - var thumbmnailSignal: SSignal? - if let _ = webpageContent.image { - let thumbnail = self.thumbnail - thumbmnailSignal = SSignal(generator: { subscriber in - let disposable = thumbnail.get().start(next: { image in - subscriber?.putNext(image) - }) - - return SBlockDisposable(block: { - disposable.dispose() - }) - }) + + self.imageNode = TransformImageNode() + if let embedUrl = webpageContent.embedUrl { + let impl = webEmbedImplementation(embedUrl: embedUrl, url: webpageContent.url) + self.playerNode = WebEmbedPlayerNode(impl: impl, intrinsicDimensions: self.intrinsicDimensions) + } else { + let impl = GenericEmbedImplementation(url: webpageContent.url) + self.playerNode = WebEmbedPlayerNode(impl: impl, intrinsicDimensions: self.intrinsicDimensions) } - self.playerViewContainer = UIView() - - self.playerView = TGEmbedPlayerView.make(forWebPage: converted, thumbnailSignal: thumbmnailSignal)! - self.playerView.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions) - self.playerViewContainer.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions) - self.playerView.disallowPIP = true - self.playerView.isUserInteractionEnabled = false - //self.playerView.disallowAutoplay = true - self.playerView.disableControls = true - super.init() - self.playerViewContainer.addSubview(self.playerView) - self.view.addSubview(self.playerViewContainer) - self.playerView.setup(withEmbedSize: self.intrinsicDimensions) + self.addSubnode(self.playerNode) + self.addSubnode(self.imageNode) - let nativeLoadProgress = self.playerView.loadProgress() - let loadProgress: Signal = Signal { subscriber in - let disposable = nativeLoadProgress?.start(next: { value in - subscriber.putNext((value as! NSNumber).floatValue) - }) - return ActionDisposable { - disposable?.dispose() - } - } - self.loadProgressDisposable = (loadProgress |> deliverOnMainQueue).start(next: { [weak self] value in - if let strongSelf = self { - strongSelf._preloadCompleted.set(value.isEqual(to: 1.0)) - } - }) +// let nativeLoadProgress = nil //self.playerView.loadProgress() +// let loadProgress: Signal = Signal { subscriber in +// let disposable = nativeLoadProgress?.start(next: { value in +// subscriber.putNext((value as! NSNumber).floatValue) +// }) +// return ActionDisposable { +// disposable?.dispose() +// } +// } + + self._preloadCompleted.set(true) + +// self.loadProgressDisposable = (loadProgress |> deliverOnMainQueue).start(next: { [weak self] value in +// if let strongSelf = self { +// strongSelf._preloadCompleted.set(value.isEqual(to: 1.0)) +// } +// }) if let image = webpageContent.image { self.thumbnailDisposable = (rawMessagePhoto(postbox: postbox, photoReference: .webPage(webPage: WebpageReference(webPage), media: image)) @@ -177,39 +125,8 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte } else { self._ready.set(.single(Void())) } - - self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true))) - - let stateSignal = self.playerView.stateSignal()! - self.statusDisposable = (Signal { subscriber in - let innerDisposable = stateSignal.start(next: { next in - if let next = next as? TGEmbedPlayerState { - let status: MediaPlayerPlaybackStatus - if next.playing { - status = .playing - } else if next.buffering { - status = .buffering(initial: false, whilePlaying: next.playing) - } else { - status = .paused - } - subscriber.putNext(MediaPlayerStatus(generationTimestamp: 0.0, duration: next.duration, dimensions: CGSize(), timestamp: max(0.0, next.position), seekId: 0, status: status)) - } - }) - return ActionDisposable { - innerDisposable?.dispose() - } - } |> deliverOnMainQueue).start(next: { [weak self] value in - if let strongSelf = self { - if !strongSelf.initializedStatus { - if case .paused = value.status { - return - } - } - strongSelf.initializedStatus = true - strongSelf._status.set(MediaPlayerStatus(generationTimestamp: value.generationTimestamp, duration: value.duration, dimensions: CGSize(), timestamp: value.timestamp, seekId: strongSelf.seekId, status: value.status)) - } - }) - + + self._status.set(self.playerNode.status) self._bufferingStatus.set(.single(nil)) } @@ -222,34 +139,25 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte } func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { - transition.updatePosition(layer: self.playerViewContainer.layer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - transition.updateTransformScale(layer: self.playerViewContainer.layer, scale: size.width / self.intrinsicDimensions.width) + transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width) + + transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size)) } func play() { assert(Queue.mainQueue().isCurrent()) - if !self.initializedStatus { - self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true))) - } else { - self.playerView.playVideo() - } + self.playerNode.play() } func pause() { assert(Queue.mainQueue().isCurrent()) - if !self.initializedStatus { - self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, seekId: self.seekId, status: .paused)) - } - self.playerView.pauseVideo() + self.playerNode.pause() } func togglePlayPause() { assert(Queue.mainQueue().isCurrent()) - if let state = self.playerView.state, state.playing { - self.pause() - } else { - self.play() - } + self.playerNode.togglePlayPause() } func setSoundEnabled(_ value: Bool) { @@ -264,7 +172,8 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte func seek(_ timestamp: Double) { assert(Queue.mainQueue().isCurrent()) self.seekId += 1 - self.playerView.seek(toPosition: timestamp) + self.playerNode.seek(timestamp: timestamp) + //self.playerView.seek(toPosition: timestamp) } func playOnceWithSound(playAndRecord: Bool) { diff --git a/TelegramUI/YoutubeEmbedImplementation.swift b/TelegramUI/YoutubeEmbedImplementation.swift new file mode 100644 index 0000000000..8926d650d9 --- /dev/null +++ b/TelegramUI/YoutubeEmbedImplementation.swift @@ -0,0 +1,295 @@ +import Foundation +import WebKit +import SwiftSignalKit + +func extractYoutubeVideoIdAndTimestamp(url: String) -> (String, Int)? { + guard let url = URL(string: url), let host = url.host?.lowercased() else { + return nil + } + + let match = ["youtube.com", "youtu.be"].contains(where: { (domain) -> Bool in + return host == domain || host.contains(".\(domain)") + }) + + guard match else { + return nil + } + + var videoId: String? + var timestamp = 0 + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "v" { + videoId = value + } else if queryItem.name == "t" || queryItem.name == "time_continue" { + if value.contains("s") { + var range = value.startIndex.. 0 && nextComponentIsVideoId { + videoId = component + break + } else if component == "embed" { + nextComponentIsVideoId = true + } + } + } + } + + if let videoId = videoId { + return (videoId, timestamp) + } + + return nil +} + +final class YoutubeEmbedImplementation: WebEmbedImplementation { + private var evalImpl: ((String) -> Void)? + private var updateStatus: ((MediaPlayerStatus) -> Void)? + private var onPlaybackStarted: (() -> Void)? + + private let videoId: String + private let timestamp: Int + private var status : MediaPlayerStatus + + private var ready: Bool = false + private var started: Bool = false + private var ignorePosition: Int? + + enum PlaybackDelay { + case None + case AfterPositionUpdates(count: Int) + } + private var playbackDelay = PlaybackDelay.None + + init(videoId: String, timestamp: Int = 0) { + self.videoId = videoId + self.timestamp = timestamp + self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: Double(timestamp), seekId: 0, status: .buffering(initial: true, whilePlaying: true)) + } + + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + let bundle = Bundle(for: type(of: self)) + guard let userScriptPath = bundle.path(forResource: "YoutubeUserScript", ofType: "js") else { + return + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return + } + guard let htmlTemplatePath = bundle.path(forResource: "Youtube", ofType: "html") else { + return + } + guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else { + return + } + guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else { + return + } + + let params: [String : Any] = [ "videoId": self.videoId, + "width": "100%", + "height": "100%", + "events": [ "onReady": "onReady", + "onStateChange": "onStateChange", + "onPlaybackQualityChange": "onPlaybackQualityChange", + "onError": "onPlayerError" ], + "playerVars": [ "cc_load_policy": 1, + "iv_load_policy": 3, + "controls": 0, + "playsinline": 1, + "autohide": 1, + "showinfo": 0, + "rel": 0, + "modestbranding": 1, + "start": timestamp ] ] + + guard let paramsJsonData = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted), let paramsJson = String(data: paramsJsonData, encoding: .utf8) else { + return + } + + self.evalImpl = evaluateJavaScript + self.updateStatus = updateStatus + self.onPlaybackStarted = onPlaybackStarted + updateStatus(self.status) + + let html = String(format: htmlTemplate, paramsJson) + webView.loadHTMLString(html, baseURL: URL(string: "https://youtube.com/")) + webView.isUserInteractionEnabled = false + + userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) + } + + func play() { + guard ready else { + self.playbackDelay = .AfterPositionUpdates(count: 2) + return + } + + if let eval = evalImpl { + eval("play();") + } + } + + func pause() { + if let eval = evalImpl { + eval("pause();") + } + } + + func togglePlayPause() { + if case .playing = self.status.status { + pause() + } else { + play() + } + } + + func seek(timestamp: Double) { + if let eval = evalImpl { + eval("seek(\(timestamp));") + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, seekId: self.status.seekId + 1, status: self.status.status) + if let updateStatus = self.updateStatus { + updateStatus(self.status) + } + } + + func pageReady() { + } + + func callback(url: URL) { + switch url.host { + case "onState": + var newTimestamp = self.status.timestamp + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + var playback: Int? + var position: Double? + var duration: Int? + var download: Float? + var failed: Bool? + + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "playback" { + playback = Int(value) + } else if queryItem.name == "position" { + position = Double(value) + } else if queryItem.name == "duration" { + duration = Int(value) + } else if queryItem.name == "download" { + download = Float(value) + } else if queryItem.name == "failed" { + failed = Bool(value) + } + } + } + } + + if let position = position { + if let ticksToIgnore = self.ignorePosition { + if ticksToIgnore > 1 { + self.ignorePosition = ticksToIgnore - 1 + } else { + self.ignorePosition = nil + } + } else { + newTimestamp = Double(position) + } + } + + if let updateStatus = self.updateStatus, let playback = playback, let duration = duration { + let playbackStatus: MediaPlayerPlaybackStatus + switch playback { + case 0: + playbackStatus = .paused + newTimestamp = 0.0 + case 1: + playbackStatus = .playing + case 2: + playbackStatus = .paused + case 3: + playbackStatus = .buffering(initial: false, whilePlaying: false) + default: + playbackStatus = .buffering(initial: true, whilePlaying: false) + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: Double(duration), dimensions: self.status.dimensions, timestamp: newTimestamp, seekId: 0, status: playbackStatus) + updateStatus(self.status) + } + } + + if case let .AfterPositionUpdates(count) = self.playbackDelay { + if count == 1 { + self.ready = true + self.playbackDelay = .None + self.play() + } else { + self.playbackDelay = .AfterPositionUpdates(count: count - 1) + } + } + case "onReady": + self.ready = true + + if case .AfterPositionUpdates(_) = self.playbackDelay { + self.playbackDelay = .None + self.play() + } + + Queue.mainQueue().async { + self.play() + + Queue.mainQueue().after(2.0, { + if !self.started { + self.play() + } + }) + } + default: + break + } + } +}