From 563ec18786b19957b0db78226590f91168cb7edb Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 16 Dec 2016 20:55:17 +0300 Subject: [PATCH] no message --- .../GlobalMusicPlayer/Contents.json | 9 + .../MinimizedPause.imageset/Contents.json | 22 + .../MusicPlayerMinimizedPause@2x.png | Bin 0 -> 119 bytes .../MusicPlayerMinimizedPause@3x.png | Bin 0 -> 151 bytes .../MinimizedPlay.imageset/Contents.json | 22 + .../MusicPlayerMinimizedPlay@2x.png | Bin 0 -> 308 bytes .../MusicPlayerMinimizedPlay@3x.png | Bin 0 -> 421 bytes .../Next.imageset/Contents.json | 22 + .../MusicPlayerControlForward@2x.png | Bin 0 -> 342 bytes .../MusicPlayerControlForward@3x.png | Bin 0 -> 479 bytes .../Pause.imageset/Contents.json | 22 + .../MusicPlayerControlPause@2x.png | Bin 0 -> 127 bytes .../MusicPlayerControlPause@3x.png | Bin 0 -> 166 bytes .../Play.imageset/Contents.json | 22 + .../MusicPlayerControlPlay@2x.png | Bin 0 -> 414 bytes .../MusicPlayerControlPlay@3x.png | Bin 0 -> 559 bytes .../Previous.imageset/Contents.json | 22 + .../MusicPlayerControlBack@2x.png | Bin 0 -> 342 bytes .../MusicPlayerControlBack@3x.png | Bin 0 -> 512 bytes .../Repeat.imageset/Contents.json | 21 + .../MusicPlayerControlRepeat@2x.png | Bin 0 -> 548 bytes .../RepeatOne.imageset/Contents.json | 21 + .../MusicPlayerControlRepeatOne@2x.png | Bin 0 -> 595 bytes .../Shuffle.imageset/Contents.json | 21 + .../MusicPlayerControlShuffle@2x.png | Bin 0 -> 657 bytes TelegramUI.xcodeproj/project.pbxproj | 104 ++++ TelegramUI/AudioWaveformNode.swift | 2 +- TelegramUI/AvatarNode.swift | 2 +- TelegramUI/ChatController.swift | 18 +- TelegramUI/ChatControllerInteraction.swift | 4 +- .../ChatInterfaceStateContextQueries.swift | 2 +- TelegramUI/ChatListController.swift | 15 +- TelegramUI/ChatListControllerNode.swift | 6 +- TelegramUI/ChatListHoleItem.swift | 4 +- TelegramUI/ChatListItem.swift | 77 ++- TelegramUI/ChatListNode.swift | 8 +- TelegramUI/ChatListSearchContainerNode.swift | 273 +++++++- TelegramUI/ChatListSearchItemHeader.swift | 54 ++ .../ChatListSearchRecentPeersNode.swift | 2 +- TelegramUI/ChatMessageBubbleContentNode.swift | 1 + TelegramUI/ChatMessageBubbleItemNode.swift | 7 +- .../ChatMessageFileBubbleContentNode.swift | 2 +- .../ChatMessageInteractiveFileNode.swift | 107 ++-- .../ChatMessageWebpageBubbleContentNode.swift | 33 +- .../ChatTextInputAudioRecordingTimeNode.swift | 4 +- TelegramUI/ContactsController.swift | 8 +- TelegramUI/ContactsPeerItem.swift | 132 ++-- TelegramUI/ContactsSearchContainerNode.swift | 2 +- TelegramUI/FFMpegMediaFrameSource.swift | 5 +- .../FFMpegMediaFrameSourceContext.swift | 10 +- TelegramUI/FileMediaResourceStatus.swift | 74 +++ TelegramUI/HorizontalPeerItem.swift | 8 +- TelegramUI/InstantPageAnchorItem.swift | 43 ++ TelegramUI/InstantPageController.swift | 40 ++ TelegramUI/InstantPageControllerNode.swift | 287 +++++++++ TelegramUI/InstantPageItem.swift | 18 + TelegramUI/InstantPageLayout.swift | 588 ++++++++++++++++++ TelegramUI/InstantPageLayoutSpacings.swift | 58 ++ TelegramUI/InstantPageLinkSelectionView.swift | 6 + TelegramUI/InstantPageMedia.swift | 13 + TelegramUI/InstantPageMediaItem.swift | 62 ++ TelegramUI/InstantPageMediaNode.swift | 110 ++++ TelegramUI/InstantPageNode.swift | 21 + TelegramUI/InstantPageShapeItem.swift | 73 +++ TelegramUI/InstantPageTextItem.swift | 320 ++++++++++ TelegramUI/InstantPageTextStyleStack.swift | 147 +++++ TelegramUI/InstantPageTile.swift | 44 ++ TelegramUI/InstantPageTileNode.swift | 45 ++ TelegramUI/InstantPageWebEmbedItem.swift | 55 ++ TelegramUI/InstantPageWebEmbedNode.swift | 38 ++ TelegramUI/ListMessageFileItemNode.swift | 273 +++++--- TelegramUI/ListMessageItem.swift | 4 +- TelegramUI/ManagedAudioPlaylistPlayer.swift | 90 ++- TelegramUI/MediaFrameSource.swift | 1 + TelegramUI/MediaManager.swift | 93 ++- ...ediaNavigationAccessoryContainerNode.swift | 134 +++- .../MediaNavigationAccessoryHeaderNode.swift | 350 ++++++++++- ...MediaNavigationAccessoryItemListNode.swift | 201 ++++++ .../MediaNavigationAccessoryPanel.swift | 38 +- TelegramUI/MediaPlayer.swift | 199 ++++-- TelegramUI/MediaPlayerScrubbingNode.swift | 350 +++++++++++ TelegramUI/MediaPlayerTimeTextNode.swift | 140 +++++ TelegramUI/MediaTrackFrameBuffer.swift | 10 +- TelegramUI/PeerMediaAudioPlaylist.swift | 64 +- .../PeerMediaCollectionController.swift | 1 + TelegramUI/PeerSelectionController.swift | 16 +- TelegramUI/PeerSelectionControllerNode.swift | 14 +- TelegramUI/RadialProgressNode.swift | 32 +- TelegramUI/TelegramController.swift | 52 +- TelegramUI/TextNode.swift | 2 +- ...textResultsChatInputContextPanelNode.swift | 110 +++- ...ntextResultsChatInputPanelButtonItem.swift | 158 +++++ 92 files changed, 5044 insertions(+), 424 deletions(-) create mode 100644 Images.xcassets/GlobalMusicPlayer/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/MusicPlayerMinimizedPause@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/MusicPlayerMinimizedPause@3x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/MinimizedPlay.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/MinimizedPlay.imageset/MusicPlayerMinimizedPlay@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/MinimizedPlay.imageset/MusicPlayerMinimizedPlay@3x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Next.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/Next.imageset/MusicPlayerControlForward@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Next.imageset/MusicPlayerControlForward@3x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Pause.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/Pause.imageset/MusicPlayerControlPause@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Pause.imageset/MusicPlayerControlPause@3x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Play.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/Play.imageset/MusicPlayerControlPlay@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Play.imageset/MusicPlayerControlPlay@3x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Previous.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/Previous.imageset/MusicPlayerControlBack@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Previous.imageset/MusicPlayerControlBack@3x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Repeat.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/Repeat.imageset/MusicPlayerControlRepeat@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/RepeatOne.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/RepeatOne.imageset/MusicPlayerControlRepeatOne@2x.png create mode 100644 Images.xcassets/GlobalMusicPlayer/Shuffle.imageset/Contents.json create mode 100644 Images.xcassets/GlobalMusicPlayer/Shuffle.imageset/MusicPlayerControlShuffle@2x.png create mode 100644 TelegramUI/ChatListSearchItemHeader.swift create mode 100644 TelegramUI/FileMediaResourceStatus.swift create mode 100644 TelegramUI/InstantPageAnchorItem.swift create mode 100644 TelegramUI/InstantPageController.swift create mode 100644 TelegramUI/InstantPageControllerNode.swift create mode 100644 TelegramUI/InstantPageItem.swift create mode 100644 TelegramUI/InstantPageLayout.swift create mode 100644 TelegramUI/InstantPageLayoutSpacings.swift create mode 100644 TelegramUI/InstantPageLinkSelectionView.swift create mode 100644 TelegramUI/InstantPageMedia.swift create mode 100644 TelegramUI/InstantPageMediaItem.swift create mode 100644 TelegramUI/InstantPageMediaNode.swift create mode 100644 TelegramUI/InstantPageNode.swift create mode 100644 TelegramUI/InstantPageShapeItem.swift create mode 100644 TelegramUI/InstantPageTextItem.swift create mode 100644 TelegramUI/InstantPageTextStyleStack.swift create mode 100644 TelegramUI/InstantPageTile.swift create mode 100644 TelegramUI/InstantPageTileNode.swift create mode 100644 TelegramUI/InstantPageWebEmbedItem.swift create mode 100644 TelegramUI/InstantPageWebEmbedNode.swift create mode 100644 TelegramUI/MediaNavigationAccessoryItemListNode.swift create mode 100644 TelegramUI/MediaPlayerScrubbingNode.swift create mode 100644 TelegramUI/MediaPlayerTimeTextNode.swift create mode 100644 TelegramUI/VerticalListContextResultsChatInputPanelButtonItem.swift diff --git a/Images.xcassets/GlobalMusicPlayer/Contents.json b/Images.xcassets/GlobalMusicPlayer/Contents.json new file mode 100644 index 0000000000..38f0c81fc2 --- /dev/null +++ b/Images.xcassets/GlobalMusicPlayer/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/GlobalMusicPlayer/MinimizedPause.imageset/Contents.json b/Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/Contents.json new file mode 100644 index 0000000000..11e696b9b2 --- /dev/null +++ b/Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerMinimizedPause@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerMinimizedPause@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/MusicPlayerMinimizedPause@2x.png b/Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/MusicPlayerMinimizedPause@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ec23e02777192ceed8723585cbd49e82d3c669dc GIT binary patch literal 119 zcmeAS@N?(olHy`uVBq!ia0vp^LO?9T!3HFAj=Wa~Qg)s$jv*C{$q6g!w@qF6?qz+* zpB!oCvW95eDb1Z5nQdOV^Gca=2=_~wWL;R{xR|Y1ie**aMb?8?jmjE=Z5ht5mg|dm SVJQHb#Ng@b=d#Wzp$Pz=^d!Up literal 0 HcmV?d00001 diff --git a/Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/MusicPlayerMinimizedPause@3x.png b/Images.xcassets/GlobalMusicPlayer/MinimizedPause.imageset/MusicPlayerMinimizedPause@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a31c1aed50c108c5a0ba88a1fda7a74da8e09392 GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^(m<@m!3HFqJ}JZksW?v;$B+ufw>P#j9x&i>3DnJ7 z@y2tl?4_1h7fc`KyX}ojyV-O(QVT4hGmH6ByitXM;pw@DGmj7{0C32#hy)4rAgvPa-gQ z0naupg%lG=;D%PH;JU7F~`-914U>Cc^e&ewmx z&g??(B<<&rv{l0R29G;;#TN=}Ulf}E0f!d31)E)N9r(p&=7L51{e>k2swc{Fs3Ag9L^!|Js$P$ z)(~>WV0?t<-5Vr?9Cj>n37_VdDF``%b-2$1GC~eBVI6xVgdENwFzt;Zx{x;t7;odv z{2C1*rx^)Pc%z6bl|k{S!l2lPgf|36 zX-*;VEo^YB!a62HVjc5;y^JT+4C6^p;(VzNFnFfht`x;TbZFz!9g+;JmOqTyru zw%qE-Ztl&ZX{p;SZugw-zJ1f|^38JV+YfVUp5OVt@9zJ~qQ42!-)}V32C=Icd$9Y@ zcszmmm&B1drvu$(3@o17uL-kYG&TP`L6S>-!7~;6ADyO{&7|%NQG0M_MDDc_d8`dUN+oV z>);`Ll#gZ2^mrX1>kVy&GH#D`GEL1dSaIJv94@wcE-P=keN?)m_Kvn|kr(prZ4P*= zlOa$Il#V*&y5(F`*{80bS?P|oHyeS{PUjYsifx&@{LJh!_AUL6{M#P2F#j|35IbIY gigELeuE~9h zyEo=8?#z{r&Dq@fRxdW^c1F_eZMnDW_gbt>Up4pk|8wPW-x(fF;`%D17SQhRvZ3)% zS?~sBm)0MLGK8Xi584TQ-E4iud{&d|->XrZe*5yuR2^qX0?}69tov6sI!`>KH1+NXvRS zU;c;--{r!b7gjrEmp#65!MFQo|MLj{owmp0w$7L~-PYZ#yYttLavtS%$3x_I&st*^ z%oV?6+1V+F@+CZEW^1-ipLjCO>*3~*jWdIOh}L%G8ZZ6ebm_K_W=+>8pwj&dtpDHi zQIt6;bLj?9tmhI?%$|3@;%vhqdcYykjmplM^7%1jZ1Qh!obOR{HzU7&G zx8{xI_b;69QCxXeNTb;*MJf9~r_3B7j+ZVbL$28=ic MPgg&ebxsLQ0Ml~Y^Z)<= literal 0 HcmV?d00001 diff --git a/Images.xcassets/GlobalMusicPlayer/Pause.imageset/Contents.json b/Images.xcassets/GlobalMusicPlayer/Pause.imageset/Contents.json new file mode 100644 index 0000000000..761432b793 --- /dev/null +++ b/Images.xcassets/GlobalMusicPlayer/Pause.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerControlPause@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerControlPause@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/GlobalMusicPlayer/Pause.imageset/MusicPlayerControlPause@2x.png b/Images.xcassets/GlobalMusicPlayer/Pause.imageset/MusicPlayerControlPause@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8730f240f982e5b293df3eec4c93fc86890a40ca GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^YCvqn!2%>_6(_C$Qf{6ujv*e$-(KIy%izG_6nL%p zzo5(!?RBal$6h&WR_+a2X}WCfB=0-FFWc`v^0#Hz5nBl)aKmuhFC{&@_52(X*(r6J SDW8BQGI+ZBxvXgTe~DWM4ffcZXk literal 0 HcmV?d00001 diff --git a/Images.xcassets/GlobalMusicPlayer/Play.imageset/Contents.json b/Images.xcassets/GlobalMusicPlayer/Play.imageset/Contents.json new file mode 100644 index 0000000000..7f72e83bd1 --- /dev/null +++ b/Images.xcassets/GlobalMusicPlayer/Play.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerControlPlay@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerControlPlay@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/GlobalMusicPlayer/Play.imageset/MusicPlayerControlPlay@2x.png b/Images.xcassets/GlobalMusicPlayer/Play.imageset/MusicPlayerControlPlay@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ad21af44a36bf85bdc8bf7e002815e395e14cf33 GIT binary patch literal 414 zcmV;P0b%}$P)kcMVPm~}PmC{^+1Us6SVDaydq#*L zo5_d!f`qVQ2#^NXh5qX@)ikvAn`LCx(AFEesc362#Wb{)#CXzaXzLCQHMI4Qb>!C2 z)>nq8XzL6WG_>`Td1TVi)-&3vXlo~hG_)1Z2-0e3>ngQ1wDp&@WY^Hv2l}XJ>jKt)@ZsH&kY`|Hl4p{^)I(of^{=*v5E9eVU zQ@kM`8KQWvf3u40;7^~X5`Z`Tj{X4N^iP(O75wRAlvn)Ayr!4pzwrkP$PE7UAxbOm zkmqz!Tu-UYCL{RMdnuu~RG-jZabG7hl?>od@1`h#H~oNC0N!*W6G#uN)d;Ayro=nKTTkezay7QVnps8N-mOwY}qz>OG5x zAyqFInlcQj{;^pcQaxtQFr@0_C=-Vv)z?-EL#lpmFkKi@HM2_`QoUxOFr@0?Op}En z)o<1hL#hGpH)|MDwR5nE!jS3{%Y-3SA6J+<45=F0HV&ztGjASJ7jTn=A=MAo2t%s= zZZ~5XQnj*o98$e&u{@+M;HC&es=sX-hg6T5BMhlJ)&)EVslKve9#R)@(}p2cGdmX~ z-ixL!NVv}m5^q@s=F{8s@(jJ~P9E~Pl}JNcJJ>{IA^qH92FOA_wmf7ZE$jzbNMF~R zjx^*wOG6gY)Si%q^mdhLNJHMVIAkG>>;hRxFPEB%G~_jlKo;_!{Qrm^&Nl^V$cq+$ zEaWd+Ko-)?Stch9dD^^?h5Tk?$U?d})nue0kC_Xyke{s&Sx6@*n1n23fCtSES;+U+ zf-IziqfJZ}(%(I1g)HQ2t3ejh)?p?h3+eB6GeH*enUx?5Y2^SDk%jbiqZtG?z(AFc xEGM+T3enu&f;(>rLLb+dPI%in*sT5^FbEbAUk1rB6}tcc002ovPDHLkV1naa@Pz;X literal 0 HcmV?d00001 diff --git a/Images.xcassets/GlobalMusicPlayer/Previous.imageset/Contents.json b/Images.xcassets/GlobalMusicPlayer/Previous.imageset/Contents.json new file mode 100644 index 0000000000..cfbea176c4 --- /dev/null +++ b/Images.xcassets/GlobalMusicPlayer/Previous.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerControlBack@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerControlBack@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/GlobalMusicPlayer/Previous.imageset/MusicPlayerControlBack@2x.png b/Images.xcassets/GlobalMusicPlayer/Previous.imageset/MusicPlayerControlBack@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ee078287fb16e56b8da0d1951a7204d50609d9d GIT binary patch literal 342 zcmV-c0jd6pP)(523 zo`dX~v5r*Clj@EMyu&a*s=Lk{rvUS$x??HvSmO?c8_5IA;{aVNwv&ZyU7-)>RcnZDN)tA=JH3Pnsc>g8<_%CoofhJB&s^s41P*fbFNlwBwe7Ya}DIY ogXyvpn#@OxQ@Ya{NHf(H0(s&bmVU&WumAu607*qoM6N<$f*c*1=Kufz literal 0 HcmV?d00001 diff --git a/Images.xcassets/GlobalMusicPlayer/Previous.imageset/MusicPlayerControlBack@3x.png b/Images.xcassets/GlobalMusicPlayer/Previous.imageset/MusicPlayerControlBack@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..839258b11dde67c5b22394757bde806961af6a38 GIT binary patch literal 512 zcmV+b0{{JqP)6l{tf9p|HXv9dj`(0M?p(O-CMJ0kF#SN^k(f0${Dl?@Z(k767YE zuOU}4ECAM;WM@qP>afh@dwTN>i-Kh)P?}>Qpu-{)7!n3LEHQz0SooG=nzJ(n@Ilqt zmI8Q@>MZo&O*AYqKUbVRv9QGSzM>sB<6xQT{mEor$G}q4E6nx?SZ;bPxfTGcP49Qc z@hTWrm|lK1hrVTI{sWPUiTFun2|O9A&x zr0IP}7aloWE3u}Rj%mEWIKa<|Hod?{Tj2_xGKRC(JV#Powa@4(I6Lg? zY2oBC`=xoMv8efMqQUn+O;!^`)eJmd=sJ|=umU~t|Ns33ks#k*2KnadWLvI74k9hv z1=qM*bL&6mbosy}R<GqmKc}(XU`|s{~;@kGO z@Z3&Tor9C4S}G5p4$`pq?q_G+^UKxybD&_sRH0SQpT*2H)f(@amactZ^5I{4i%Jq7Nxe2>ZlhWxw?$ Z^Jj`_K(a8V+ literal 0 HcmV?d00001 diff --git a/Images.xcassets/GlobalMusicPlayer/RepeatOne.imageset/Contents.json b/Images.xcassets/GlobalMusicPlayer/RepeatOne.imageset/Contents.json new file mode 100644 index 0000000000..5d67c8da2e --- /dev/null +++ b/Images.xcassets/GlobalMusicPlayer/RepeatOne.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "MusicPlayerControlRepeatOne@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/GlobalMusicPlayer/RepeatOne.imageset/MusicPlayerControlRepeatOne@2x.png b/Images.xcassets/GlobalMusicPlayer/RepeatOne.imageset/MusicPlayerControlRepeatOne@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..47716a776f7f70352e107e7d413cb213eb24283b GIT binary patch literal 595 zcmeAS@N?(olHy`uVBq!ia0vp^8bBXX zGZE;1wDFmJ0H5UJK(E@B!2zfB88c)0T*_oRe8U-vL{1bcbbquIdZIDI*)eAqQ5~0UeEJRMnmoSZ!d)I+6Ek2j|f#vJUqW6^@0nEz&r4lu?qYs8&Uf zm09E7xvCsbnNBzVE7q-A#mqTaS%*>V! z()??3FOlb>)uorxVTR{F-Z{u~Yl6yL^SRcnm!DpkHv97ZR+|~ohm*RJ!!0PhcuES}oX(U#bGv_MDa$`%WnlRK zpM`bxA7E%OfI{Q!l!IK1i6X5x7p&3M(OHwLqnq=TbI-)7Q*EQOpGpQSnlh<;Lt>(% z$o-4;-I-a>@=cyydHHE~@jLTk>794-LzJF1%>U6CZ4oTxpuA)C>L$?=wI$1!E-yQt zo8upJp*~!aiTRba6!W*(uajPf?|4)sG~I?%c+J&!1%k74o>ud5l}Q9{|C1NlsmN+x z(wLsbT<^7^>9WJuqZ1dDEbdf%-Et=8Lhr4EiI-NrT`Ku;r_?nO3+~oAmsPUj=kHi1 zEg|sd)?)|rhqH74O0(+OF!z1wFXTNQIC+`oz65ER?fV!X^LVaSFFBgY+dpe^rp3YR oxlw!Xr5sH2`?WakvHlKm1)uvh#m&-dfvJSS)78&qol`;+0C Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! diff --git a/TelegramUI/AvatarNode.swift b/TelegramUI/AvatarNode.swift index 8ccd2610ef..181698d65e 100644 --- a/TelegramUI/AvatarNode.swift +++ b/TelegramUI/AvatarNode.swift @@ -117,7 +117,7 @@ public final class AvatarNode: ASDisplayNode { return parameters ?? NSObject() } - @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { assertNotOnMainThread() let context = UIGraphicsGetCurrentContext()! diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 76f89e53d3..0b207de012 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -58,7 +58,9 @@ public class ChatController: TelegramController { self.peerId = peerId self.messageId = messageId - performanceSpinnerAcquire() + /*if #available(iOSApplicationExtension 10.0, *) { + kdebug_signpost(1, 0, 0, 0, 0) + }*/ super.init(account: account) @@ -385,6 +387,20 @@ public class ChatController: TelegramController { } enqueueMessages(account: strongSelf.account, peerId: strongSelf.peerId, messages: [.message(text: command, attributes: [], media: nil, replyToMessageId: postAsReply ? messageId : nil)]).start() } + }, openInstantPage: { [weak self] messageId in + if let strongSelf = self, strongSelf.isNodeLoaded { + if let message = strongSelf.chatDisplayNode.historyNode.messageInCurrentHistoryView(messageId) { + for media in message.media { + if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { + if let instantPage = content.instantPage { + let pageController = InstantPageController(account: strongSelf.account, webPage: webpage) + (strongSelf.navigationController as? NavigationController)?.pushViewController(pageController) + } + break + } + } + } + } }, updateInputState: { [weak self] f in if let strongSelf = self { strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { diff --git a/TelegramUI/ChatControllerInteraction.swift b/TelegramUI/ChatControllerInteraction.swift index c5ea69b98f..9744520410 100644 --- a/TelegramUI/ChatControllerInteraction.swift +++ b/TelegramUI/ChatControllerInteraction.swift @@ -25,9 +25,10 @@ public final class ChatControllerInteraction { let shareCurrentLocation: () -> Void let shareAccountContact: () -> Void let sendBotCommand: (MessageId, String) -> Void + let openInstantPage: (MessageId) -> Void let updateInputState: ((ChatTextInputState) -> ChatTextInputState) -> Void - public init(openMessage: @escaping (MessageId) -> Void, openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (MessageId, ASDisplayNode, CGRect) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, clickThroughMessage: @escaping () -> Void, toggleMessageSelection: @escaping (MessageId) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (TelegramMediaFile) -> Void, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId, String) -> Void, updateInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void) { + public init(openMessage: @escaping (MessageId) -> Void, openPeer: @escaping (PeerId?, ChatControllerInteractionNavigateToPeer) -> Void, openPeerMention: @escaping (String) -> Void, openMessageContextMenu: @escaping (MessageId, ASDisplayNode, CGRect) -> Void, navigateToMessage: @escaping (MessageId, MessageId) -> Void, clickThroughMessage: @escaping () -> Void, toggleMessageSelection: @escaping (MessageId) -> Void, sendMessage: @escaping (String) -> Void, sendSticker: @escaping (TelegramMediaFile) -> Void, requestMessageActionCallback: @escaping (MessageId, MemoryBuffer?) -> Void, openUrl: @escaping (String) -> Void, shareCurrentLocation: @escaping () -> Void, shareAccountContact: @escaping () -> Void, sendBotCommand: @escaping (MessageId, String) -> Void, openInstantPage: @escaping (MessageId) -> Void, updateInputState: @escaping ((ChatTextInputState) -> ChatTextInputState) -> Void) { self.openMessage = openMessage self.openPeer = openPeer self.openPeerMention = openPeerMention @@ -42,6 +43,7 @@ public final class ChatControllerInteraction { self.shareCurrentLocation = shareCurrentLocation self.shareAccountContact = shareAccountContact self.sendBotCommand = sendBotCommand + self.openInstantPage = openInstantPage self.updateInputState = updateInputState } } diff --git a/TelegramUI/ChatInterfaceStateContextQueries.swift b/TelegramUI/ChatInterfaceStateContextQueries.swift index 03d1182937..35560ac7f1 100644 --- a/TelegramUI/ChatInterfaceStateContextQueries.swift +++ b/TelegramUI/ChatInterfaceStateContextQueries.swift @@ -41,7 +41,7 @@ func contextQueryResultStateForChatInterfacePresentationState(_ chatPresentation let participants = peerParticipants(account: account, id: peer.id) |> map { peers -> (ChatPresentationInputQueryResult?) -> ChatPresentationInputQueryResult? in let filteredPeers = peers.filter { peer in - if peer.indexName.match(query: normalizedQuery) { + if peer.indexName.matchesByTokens(normalizedQuery) { return true } if let addressName = peer.addressName, addressName.lowercased().hasPrefix(normalizedQuery) { diff --git a/TelegramUI/ChatListController.swift b/TelegramUI/ChatListController.swift index d6aeb915b5..12ea12569e 100644 --- a/TelegramUI/ChatListController.swift +++ b/TelegramUI/ChatListController.swift @@ -78,9 +78,20 @@ public class ChatListController: ViewController { } } - self.chatListDisplayNode.requestOpenPeerFromSearch = { [weak self] peerId in + self.chatListDisplayNode.requestOpenPeerFromSearch = { [weak self] peer in if let strongSelf = self { - (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: peerId)) + let storedPeer = strongSelf.account.postbox.modify { modifier -> Void in + if modifier.getPeer(peer.id) == nil { + modifier.updatePeers([peer], update: { previousPeer, updatedPeer in + return updatedPeer + }) + } + } + strongSelf.openMessageFromSearchDisposable.set((storedPeer |> deliverOnMainQueue).start(completed: { [weak strongSelf] in + if let strongSelf = strongSelf { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: peer.id)) + } + })) } } diff --git a/TelegramUI/ChatListControllerNode.swift b/TelegramUI/ChatListControllerNode.swift index c563865b6d..d3802de6b5 100644 --- a/TelegramUI/ChatListControllerNode.swift +++ b/TelegramUI/ChatListControllerNode.swift @@ -15,7 +15,7 @@ class ChatListControllerNode: ASDisplayNode { private var containerLayout: (ContainerViewLayout, CGFloat)? var requestDeactivateSearch: (() -> Void)? - var requestOpenPeerFromSearch: ((PeerId) -> Void)? + var requestOpenPeerFromSearch: ((Peer) -> Void)? var requestOpenMessageFromSearch: ((Peer, MessageId) -> Void)? init(account: Account) { @@ -87,9 +87,9 @@ class ChatListControllerNode: ASDisplayNode { } if let placeholderNode = maybePlaceholderNode { - self.searchDisplayController = SearchDisplayController(contentNode: ChatListSearchContainerNode(account: self.account, openPeer: { [weak self] peerId in + self.searchDisplayController = SearchDisplayController(contentNode: ChatListSearchContainerNode(account: self.account, openPeer: { [weak self] peer in if let requestOpenPeerFromSearch = self?.requestOpenPeerFromSearch { - requestOpenPeerFromSearch(peerId) + requestOpenPeerFromSearch(peer) } }, openMessage: { [weak self] peer, messageId in if let requestOpenMessageFromSearch = self?.requestOpenMessageFromSearch { diff --git a/TelegramUI/ChatListHoleItem.swift b/TelegramUI/ChatListHoleItem.swift index 981d805031..5796a725b2 100644 --- a/TelegramUI/ChatListHoleItem.swift +++ b/TelegramUI/ChatListHoleItem.swift @@ -17,7 +17,7 @@ class ChatListHoleItem: ListViewItem { async { let node = ChatListHoleItemNode() node.relativePosition = (first: previousItem == nil, last: nextItem == nil) - node.insets = ChatListItemNode.insets(first: node.relativePosition.first, last: node.relativePosition.last) + node.insets = ChatListItemNode.insets(first: false, last: false, firstWithHeader: false) node.layoutForWidth(width, item: self, previousItem: previousItem, nextItem: nextItem) completion(node, {}) } @@ -78,7 +78,7 @@ class ChatListHoleItemNode: ListViewItemNode { return { width, first, last in let (labelLayout, labelApply) = labelNodeLayout(NSAttributedString(string: "Loading", font: titleFont, textColor: UIColor(0xc8c7cc)), nil, 1, .end, CGSize(width: width, height: CGFloat.greatestFiniteMagnitude), nil) - let insets = ChatListItemNode.insets(first: first, last: last) + let insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: false) let layout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 68.0), insets: insets) return (layout, { [weak self] in diff --git a/TelegramUI/ChatListItem.swift b/TelegramUI/ChatListItem.swift index 2021917ace..5e1d7edecd 100644 --- a/TelegramUI/ChatListItem.swift +++ b/TelegramUI/ChatListItem.swift @@ -16,12 +16,15 @@ class ChatListItem: ListViewItem { let selectable: Bool = true - init(account: Account, message: Message, combinedReadState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?, embeddedState: PeerChatListEmbeddedInterfaceState?, action: @escaping (Message) -> Void) { + let header: ListViewItemHeader? + + init(account: Account, message: Message, combinedReadState: CombinedPeerReadState?, notificationSettings: PeerNotificationSettings?, embeddedState: PeerChatListEmbeddedInterfaceState?, header: ListViewItemHeader?, action: @escaping (Message) -> Void) { self.account = account self.message = message self.combinedReadState = combinedReadState self.notificationSettings = notificationSettings self.embeddedState = embeddedState + self.header = header self.action = action } @@ -29,8 +32,8 @@ class ChatListItem: ListViewItem { async { let node = ChatListItemNode() node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings, embeddedState: self.embeddedState) - node.relativePosition = (first: previousItem == nil || previousItem! is ChatListSearchItem, last: nextItem == nil) - node.insets = ChatListItemNode.insets(first: node.relativePosition.first, last: node.relativePosition.last) + let (first, last, firstWithHeader) = ChatListItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem) + node.insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader) node.layoutForWidth(width, item: self, previousItem: previousItem, nextItem: nextItem) completion(node, {}) } @@ -43,14 +46,12 @@ class ChatListItem: ListViewItem { node.setupItem(account: self.account, message: self.message, combinedReadState: self.combinedReadState, notificationSettings: self.notificationSettings, embeddedState: self.embeddedState) let layout = node.asyncLayout() async { - let first = previousItem == nil || previousItem! is ChatListSearchItem - let last = nextItem == nil + let (first, last, firstWithHeader) = ChatListItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem) - let (nodeLayout, apply) = layout(self.account, width, first, last) + let (nodeLayout, apply) = layout(self.account, self, width, first, last, firstWithHeader) Queue.mainQueue().async { completion(nodeLayout, { [weak node] in apply() - node?.updateBackgroundAndSeparatorsLayout() }) } } @@ -61,6 +62,29 @@ class ChatListItem: ListViewItem { func selected(listView: ListView) { self.action(self.message) } + + static func mergeType(item: ChatListItem, previousItem: ListViewItem?, nextItem: ListViewItem?) -> (first: Bool, last: Bool, firstWithHeader: Bool) { + var first = false + var last = false + var firstWithHeader = false + if let previousItem = previousItem { + if let header = item.header { + if let previousItem = previousItem as? ChatListItem { + firstWithHeader = header.id != previousItem.header?.id + } else { + firstWithHeader = true + } + } + } else { + first = true + firstWithHeader = item.header != nil + } + if let nextItem = nextItem { + } else { + last = true + } + return (first, last, firstWithHeader) + } } private let titleFont = Font.medium(17.0) @@ -133,7 +157,7 @@ class ChatListItemNode: ListViewItemNode { let badgeTextNode: TextNode let mutedIconNode: ASImageNode - var relativePosition: (first: Bool, last: Bool) = (false, false) + var layoutParams: (ChatListItem, first: Bool, last: Bool, firstWithHeader: Bool)? required init() { self.backgroundNode = ASDisplayNode() @@ -223,21 +247,13 @@ class ChatListItemNode: ListViewItemNode { override func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { let layout = self.asyncLayout() - let (_, apply) = layout(self.account, width, self.relativePosition.first, self.relativePosition.last) + let (first, last, firstWithHeader) = ChatListItem.mergeType(item: item as! ChatListItem, previousItem: previousItem, nextItem: nextItem) + let (_, apply) = layout(self.account, item as! ChatListItem, width, first, last, firstWithHeader) apply() } - func updateBackgroundAndSeparatorsLayout() { - let size = self.bounds.size - let insets = self.insets - - self.backgroundNode.frame = CGRect(origin: CGPoint(), size: size) - let topNegativeInset: CGFloat = self.relativePosition.first ? 4.0 : 0.0 - self.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -insets.top - separatorHeight - topNegativeInset), size: CGSize(width: size.width, height: size.height + separatorHeight + topNegativeInset)) - } - - class func insets(first: Bool, last: Bool) -> UIEdgeInsets { - return UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) + class func insets(first: Bool, last: Bool, firstWithHeader: Bool) -> UIEdgeInsets { + return UIEdgeInsets(top: firstWithHeader ? 29.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0) } override func setHighlighted(_ highlighted: Bool, animated: Bool) { @@ -276,7 +292,7 @@ class ChatListItemNode: ListViewItemNode { } } - func asyncLayout() -> (_ account: Account?, _ width: CGFloat, _ first: Bool, _ last: Bool) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ account: Account?, _ item: ChatListItem, _ width: CGFloat, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool) -> (ListViewItemNodeLayout, () -> Void) { let dateLayout = TextNode.asyncLayout(self.dateNode) let textLayout = TextNode.asyncLayout(self.textNode) let titleLayout = TextNode.asyncLayout(self.titleNode) @@ -287,7 +303,7 @@ class ChatListItemNode: ListViewItemNode { let notificationSettings = self.notificationSettings let embeddedState = self.embeddedState - return { account, width, first, last in + return { account, item, width, first, last, firstWithHeader in var textAttributedString: NSAttributedString? var dateAttributedString: NSAttributedString? var titleAttributedString: NSAttributedString? @@ -411,12 +427,12 @@ class ChatListItemNode: ListViewItemNode { let titleRect = CGRect(origin: contentRect.origin, size: CGSize(width: contentRect.width - dateLayout.size.width - 10.0 - statusWidth - muteWidth, height: contentRect.height)) let (titleLayout, titleApply) = titleLayout(titleAttributedString, nil, 1, .end, CGSize(width: titleRect.width, height: CGFloat.greatestFiniteMagnitude), nil) - let insets = ChatListItemNode.insets(first: first, last: last) + let insets = ChatListItemNode.insets(first: first, last: last, firstWithHeader: firstWithHeader) let layout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 68.0), insets: insets) return (layout, { [weak self] in if let strongSelf = self { - strongSelf.relativePosition = (first, last) + strongSelf.layoutParams = (item, first, last, firstWithHeader) strongSelf.avatarNode.frame = CGRect(origin: CGPoint(x: 10.0, y: 4.0), size: CGSize(width: 60.0, height: 60.0)) strongSelf.contentNode.frame = CGRect(origin: CGPoint(x: 78.0, y: 0.0), size: CGSize(width: width - 78.0, height: 60.0)) @@ -477,7 +493,10 @@ class ChatListItemNode: ListViewItemNode { strongSelf.contentSize = layout.contentSize strongSelf.insets = layout.insets - strongSelf.updateBackgroundAndSeparatorsLayout() + + strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(), size: layout.contentSize) + let topNegativeInset: CGFloat = 0.0 + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -separatorHeight - topNegativeInset), size: CGSize(width: layout.contentSize.width, height: layout.contentSize.height + separatorHeight + topNegativeInset)) if updateContentNode { strongSelf.contentNode.setNeedsDisplay() @@ -494,4 +513,12 @@ class ChatListItemNode: ListViewItemNode { override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.5, removeOnCompletion: false) } + + override public func header() -> ListViewItemHeader? { + if let (item, _, _, _) = self.layoutParams { + return item.header + } else { + return nil + } + } } diff --git a/TelegramUI/ChatListNode.swift b/TelegramUI/ChatListNode.swift index c012684c70..4e606e0d0d 100644 --- a/TelegramUI/ChatListNode.swift +++ b/TelegramUI/ChatListNode.swift @@ -40,11 +40,11 @@ private func mappedInsertEntries(account: Account, nodeInteraction: ChatListNode case let .MessageEntry(_, message, combinedReadState, notificationSettings, embeddedState): switch mode { case .chatList: - return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, action: { _ in + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, header: nil, action: { _ in nodeInteraction.peerSelected(message.id.peerId) }), directionHint: entry.directionHint) case .peers: - return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: message.peers[message.id.peerId], presence: nil, index: nil, action: { _ in + return ListViewInsertItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: message.peers[message.id.peerId], status: .none, index: nil, header: nil, action: { _ in nodeInteraction.peerSelected(message.id.peerId) }), directionHint: entry.directionHint) } @@ -66,11 +66,11 @@ private func mappedUpdateEntries(account: Account, nodeInteraction: ChatListNode case let .MessageEntry(_, message, combinedReadState, notificationSettings, embeddedState): switch mode { case .chatList: - return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, action: { _ in + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ChatListItem(account: account, message: message, combinedReadState: combinedReadState, notificationSettings: notificationSettings, embeddedState: embeddedState, header: nil, action: { _ in nodeInteraction.peerSelected(message.id.peerId) }), directionHint: entry.directionHint) case .peers: - return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: message.peers[message.id.peerId], presence: nil, index: nil, action: { _ in + return ListViewUpdateItem(index: entry.index, previousIndex: entry.previousIndex, item: ContactsPeerItem(account: account, peer: message.peers[message.id.peerId], status: .none, index: nil, header: nil, action: { _ in nodeInteraction.peerSelected(message.id.peerId) }), directionHint: entry.directionHint) } diff --git a/TelegramUI/ChatListSearchContainerNode.swift b/TelegramUI/ChatListSearchContainerNode.swift index e9977c5040..b9f761240e 100644 --- a/TelegramUI/ChatListSearchContainerNode.swift +++ b/TelegramUI/ChatListSearchContainerNode.swift @@ -5,8 +5,150 @@ import SwiftSignalKit import Postbox import TelegramCore -private enum ChatListSearchEntry { +private enum ChatListSearchEntryStableId: Hashable { + case localPeerId(PeerId) + case globalPeerId(PeerId) + case messageId(MessageId) + + static func ==(lhs: ChatListSearchEntryStableId, rhs: ChatListSearchEntryStableId) -> Bool { + switch lhs { + case let .localPeerId(peerId): + if case .localPeerId(peerId) = rhs { + return true + } else { + return false + } + case let .globalPeerId(peerId): + if case .globalPeerId(peerId) = rhs { + return true + } else { + return false + } + case let .messageId(messageId): + if case .messageId(messageId) = rhs { + return true + } else { + return false + } + } + } + + var hashValue: Int { + switch self { + case let .localPeerId(peerId): + return peerId.hashValue + case let .globalPeerId(peerId): + return peerId.hashValue + case let .messageId(messageId): + return messageId.hashValue + } + } +} + + +private enum ChatListSearchEntry: Comparable, Identifiable { + case localPeer(Peer, Int) + case globalPeer(Peer, Int) case message(Message) + + var stableId: ChatListSearchEntryStableId { + switch self { + case let .localPeer(peer, _): + return .localPeerId(peer.id) + case let .globalPeer(peer, _): + return .globalPeerId(peer.id) + case let .message(message): + return .messageId(message.id) + } + } + + static func ==(lhs: ChatListSearchEntry, rhs: ChatListSearchEntry) -> Bool { + switch lhs { + case let .localPeer(lhsPeer, lhsIndex): + if case let .localPeer(rhsPeer, rhsIndex) = rhs, lhsPeer.isEqual(rhsPeer) && lhsIndex == rhsIndex { + return true + } else { + return false + } + case let .globalPeer(lhsPeer, lhsIndex): + if case let .globalPeer(rhsPeer, rhsIndex) = rhs, lhsPeer.isEqual(rhsPeer) && lhsIndex == rhsIndex { + return true + } else { + return false + } + case let .message(lhsMessage): + if case let .message(rhsMessage) = rhs { + if lhsMessage.id != rhsMessage.id { + return false + } + if lhsMessage.stableVersion != rhsMessage.stableVersion { + return false + } + return true + } else { + return false + } + } + } + + static func <(lhs: ChatListSearchEntry, rhs: ChatListSearchEntry) -> Bool { + switch lhs { + case let .localPeer(lhsPeer, lhsIndex): + if case let .localPeer(rhsPeer, rhsIndex) = rhs { + return lhsIndex < rhsIndex + } else { + return true + } + case let .globalPeer(lhsPeer, lhsIndex): + switch rhs { + case .localPeer: + return false + case let .globalPeer(rhsPeer, rhsIndex): + return lhsIndex < rhsIndex + case .message: + return true + } + case let .message(lhsMessage): + if case let .message(rhsMessage) = rhs { + return MessageIndex(lhsMessage) < MessageIndex(rhsMessage) + } else { + return false + } + } + } + + func item(account: Account, openPeer: @escaping (Peer) -> Void, openMessage: @escaping (Message) -> Void) -> ListViewItem { + switch self { + case let .localPeer(peer, _): + return ContactsPeerItem(account: account, peer: peer, status: .none, index: nil, header: ChatListSearchItemHeader(type: .localPeers), action: { _ in + openPeer(peer) + }) + case let .globalPeer(peer, _): + return ContactsPeerItem(account: account, peer: peer, status: .addressName, index: nil, header: ChatListSearchItemHeader(type: .globalPeers), action: { _ in + openPeer(peer) + }) + case let .message(message): + return ChatListItem(account: account, message: message, combinedReadState: nil, notificationSettings: nil, embeddedState: nil, header: ChatListSearchItemHeader(type: .messages), action: { _ in + openMessage(message) + }) + } + } +} + +private struct ChatListSearchContainerTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] +} + +private func preparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], account: Account, openPeer: @escaping (Peer) -> Void, openMessage: @escaping (Message) -> Void) -> ChatListSearchContainerTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, openPeer: openPeer, openMessage: openMessage), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, openPeer: openPeer, openMessage: openMessage), directionHint: nil) } + + return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates) } final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { @@ -15,11 +157,13 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { private let recentPeersNode: ChatListSearchRecentPeersNode private let listNode: ListView + private var enqueuedTransitions: [(ChatListSearchContainerTransition, Bool)] = [] + private var hasValidLayout = false private let searchQuery = Promise() private let searchDisposable = MetaDisposable() - init(account: Account, openPeer: @escaping (PeerId) -> Void, openMessage: @escaping (Peer, MessageId) -> Void) { + init(account: Account, openPeer: @escaping (Peer) -> Void, openMessage: @escaping (Peer, MessageId) -> Void) { self.account = account self.openMessage = openMessage @@ -35,40 +179,73 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { self.listNode.isHidden = true - let searchItems = searchQuery.get() - |> mapToSignal { query -> Signal<[ChatListSearchEntry], NoError> in + let foundItems = searchQuery.get() + |> mapToSignal { query -> Signal<[ChatListSearchEntry]?, NoError> in if let query = query, !query.isEmpty { - return searchMessages(account: account, query: query) + let foundLocalPeers = account.postbox.searchPeers(query: query.lowercased()) + |> map { peers -> [ChatListSearchEntry] in + var entries: [ChatListSearchEntry] = [] + var index = 0 + for peer in peers { + entries.append(.localPeer(peer, index)) + index += 1 + } + return entries + } + + let foundRemotePeers: Signal<[ChatListSearchEntry], NoError> = .single([]) |> then(searchPeers(account: account, query: query) + |> delay(0.2, queue: Queue.concurrentDefaultQueue()) + |> map { peers -> [ChatListSearchEntry] in + var entries: [ChatListSearchEntry] = [] + var index = 0 + for peer in peers { + entries.append(.globalPeer(peer, index)) + index += 1 + } + return entries + }) + + let foundRemoteMessages: Signal<[ChatListSearchEntry], NoError> = .single([]) |> then(searchMessages(account: account, query: query) |> delay(0.2, queue: Queue.concurrentDefaultQueue()) |> map { messages -> [ChatListSearchEntry] in return messages.map({ .message($0) }) + }) + + return combineLatest(foundLocalPeers, foundRemotePeers, foundRemoteMessages) + |> map { localPeers, remotePeers, remoteMessages -> [ChatListSearchEntry]? in + return localPeers + remotePeers + remoteMessages } } else { - return .single([]) + return .single(nil) } } - let previousSearchItems = Atomic<[ChatListSearchEntry]>(value: []) + let previousSearchItems = Atomic<[ChatListSearchEntry]?>(value: nil) + let processingQueue = Queue() - self.searchDisposable.set((searchItems - |> deliverOnMainQueue).start(next: { [weak self] items in + self.searchDisposable.set((foundItems + |> deliverOnMainQueue).start(next: { [weak self] entries in if let strongSelf = self { - let previousItems = previousSearchItems.swap(items) + let previousEntries = previousSearchItems.swap(entries) - var listItems: [ListViewItem] = [] - for item in items { - switch item { - case let .message(message): - listItems.append(ChatListItem(account: account, message: message, combinedReadState: nil, notificationSettings: nil, embeddedState: nil, action: { [weak strongSelf] _ in - if let strongSelf = strongSelf, let peer = message.peers[message.id.peerId] { - strongSelf.listNode.clearHighlightAnimated(true) - strongSelf.openMessage(peer, message.id) - } - })) + let firstTime = previousEntries == nil + let transition = preparedTransition(from: previousEntries ?? [], to: entries ?? [], account: account, openPeer: { peer in + openPeer(peer) + self?.listNode.clearHighlightAnimated(true) + }, openMessage: { message in + if let peer = message.peers[message.id.peerId] { + openMessage(peer, message.id) } + self?.listNode.clearHighlightAnimated(true) + }) + strongSelf.enqueueTransition(transition, firstTime: firstTime) + if let _ = entries { + strongSelf.listNode.isHidden = false + strongSelf.recentPeersNode.isHidden = true + } else { + strongSelf.listNode.isHidden = true + strongSelf.recentPeersNode.isHidden = false } - - strongSelf.listNode.transaction(deleteIndices: (0 ..< previousItems.count).map({ ListViewDeleteItem(index: $0, directionHint: nil) }), insertIndicesAndItems: (0 ..< listItems.count).map({ ListViewInsertItem(index: $0, previousIndex: nil, item: listItems[$0], directionHint: .Down) }), updateIndicesAndItems: [], options: [], updateOpaqueState: nil) } })) } @@ -89,6 +266,33 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { } } + private func enqueueTransition(_ transition: ChatListSearchContainerTransition, firstTime: Bool) { + enqueuedTransitions.append((transition, firstTime)) + + if self.hasValidLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + private func dequeueTransition() { + if let (transition, firstTime) = self.enqueuedTransitions.first { + self.enqueuedTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + if firstTime { + } else { + //options.insert(.AnimateAlpha) + } + + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + if let strongSelf = self, firstTime { + } + }) + } + } + override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition) @@ -99,16 +303,16 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { var duration: Double = 0.0 var curve: UInt = 0 switch transition { - case .immediate: - break - case let .animated(animationDuration, animationCurve): - duration = animationDuration - switch animationCurve { - case .easeInOut: + case .immediate: break - case .spring: - curve = 7 - } + case let .animated(animationDuration, animationCurve): + duration = animationDuration + switch animationCurve { + case .easeInOut: + break + case .spring: + curve = 7 + } } @@ -121,5 +325,12 @@ final class ChatListSearchContainerNode: SearchDisplayControllerContentNode { self.listNode.frame = CGRect(origin: CGPoint(), size: layout.size) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: UIEdgeInsets(top: navigationBarHeight, left: 0.0, bottom: layout.insets(options: [.input]).bottom, right: 0.0), duration: duration, curve: listViewCurve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if !hasValidLayout { + hasValidLayout = true + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } } } diff --git a/TelegramUI/ChatListSearchItemHeader.swift b/TelegramUI/ChatListSearchItemHeader.swift new file mode 100644 index 0000000000..e20364f1c5 --- /dev/null +++ b/TelegramUI/ChatListSearchItemHeader.swift @@ -0,0 +1,54 @@ +import Foundation +import Display + +enum ChatListSearchItemHeaderType: Int32 { + case localPeers + case globalPeers + case messages +} + +final class ChatListSearchItemHeader: ListViewItemHeader { + let id: Int64 + let type: ChatListSearchItemHeaderType + let stickDirection: ListViewItemHeaderStickDirection = .top + + let height: CGFloat = 29.0 + + init(type: ChatListSearchItemHeaderType) { + self.type = type + self.id = Int64(self.type.rawValue) + } + + func node() -> ListViewItemHeaderNode { + return ChatListSearchItemHeaderNode(type: self.type) + } +} + +final class ChatListSearchItemHeaderNode: ListViewItemHeaderNode { + private let type: ChatListSearchItemHeaderType + + private let sectionHeaderNode: ListSectionHeaderNode + + init(type: ChatListSearchItemHeaderType) { + self.type = type + + self.sectionHeaderNode = ListSectionHeaderNode() + + super.init() + + switch type { + case .localPeers: + self.sectionHeaderNode.title = "CHATS AND CONTACTS" + case .globalPeers: + self.sectionHeaderNode.title = "GLOBAL SEARCH" + case .messages: + self.sectionHeaderNode.title = "MESSAGES" + } + + self.addSubnode(self.sectionHeaderNode) + } + + override func layout() { + self.sectionHeaderNode.frame = CGRect(origin: CGPoint(), size: self.bounds.size) + } +} diff --git a/TelegramUI/ChatListSearchRecentPeersNode.swift b/TelegramUI/ChatListSearchRecentPeersNode.swift index 6afde9817b..367360341a 100644 --- a/TelegramUI/ChatListSearchRecentPeersNode.swift +++ b/TelegramUI/ChatListSearchRecentPeersNode.swift @@ -11,7 +11,7 @@ final class ChatListSearchRecentPeersNode: ASDisplayNode { private let disposable = MetaDisposable() - init(account: Account, peerSelected: @escaping (PeerId) -> Void) { + init(account: Account, peerSelected: @escaping (Peer) -> Void) { self.sectionHeaderNode = ListSectionHeaderNode() self.sectionHeaderNode.title = "PEOPLE" diff --git a/TelegramUI/ChatMessageBubbleContentNode.swift b/TelegramUI/ChatMessageBubbleContentNode.swift index 3d745a43a0..6a74b6d6bd 100644 --- a/TelegramUI/ChatMessageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageBubbleContentNode.swift @@ -35,6 +35,7 @@ enum ChatMessageBubbleContentTapAction { case textMention(String) case peerMention(PeerId) case botCommand(String) + case instantPage } class ChatMessageBubbleContentNode: ASDisplayNode { diff --git a/TelegramUI/ChatMessageBubbleItemNode.swift b/TelegramUI/ChatMessageBubbleItemNode.swift index e2a708af59..007669932a 100644 --- a/TelegramUI/ChatMessageBubbleItemNode.swift +++ b/TelegramUI/ChatMessageBubbleItemNode.swift @@ -242,7 +242,7 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { switch tapAction { case .none: break - case .url, .peerMention, .textMention, .botCommand: + case .url, .peerMention, .textMention, .botCommand, .instantPage: return true } } @@ -821,6 +821,11 @@ class ChatMessageBubbleItemNode: ChatMessageItemView { if let item = self.item, let controllerInteraction = self.controllerInteraction { controllerInteraction.sendBotCommand(item.message.id, command) } + case .instantPage: + foundTapAction = true + if let item = self.item, let controllerInteraction = self.controllerInteraction { + controllerInteraction.openInstantPage(item.message.id) + } break loop } } diff --git a/TelegramUI/ChatMessageFileBubbleContentNode.swift b/TelegramUI/ChatMessageFileBubbleContentNode.swift index 428d7f7f88..9ec0298232 100644 --- a/TelegramUI/ChatMessageFileBubbleContentNode.swift +++ b/TelegramUI/ChatMessageFileBubbleContentNode.swift @@ -59,7 +59,7 @@ class ChatMessageFileBubbleContentNode: ChatMessageBubbleContentNode { statusType = nil } - let (initialWidth, refineLayout) = interactiveFileLayout(item.account, item.message, selectedFile!, item.message.flags.contains(.Incoming), statusType, CGSize(width: constrainedSize.width, height: constrainedSize.height)) + let (initialWidth, refineLayout) = interactiveFileLayout(item.account, item.message, selectedFile!, item.message.effectivelyIncoming, statusType, CGSize(width: constrainedSize.width, height: constrainedSize.height)) return (initialWidth + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, { constrainedSize in let (refinedWidth, finishLayout) = refineLayout(constrainedSize) diff --git a/TelegramUI/ChatMessageInteractiveFileNode.swift b/TelegramUI/ChatMessageInteractiveFileNode.swift index d1c7255dec..c7304e4a67 100644 --- a/TelegramUI/ChatMessageInteractiveFileNode.swift +++ b/TelegramUI/ChatMessageInteractiveFileNode.swift @@ -36,12 +36,13 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { private let statusDisposable = MetaDisposable() private let fetchControls = Atomic(value: nil) - private var fetchStatus: MediaResourceStatus? + private var resourceStatus: FileMediaResourceStatus? private let fetchDisposable = MetaDisposable() var activateLocalContent: () -> Void = { } - private var messageIdAndFlags: (MessageId, MessageFlags)? + private var account: Account? + private var message: Message? private var file: TelegramMediaFile? init() { @@ -75,29 +76,32 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { } @objc func progressPressed() { - if let fetchStatus = self.fetchStatus { - switch fetchStatus { - case .Fetching: - if let cancel = self.fetchControls.with({ return $0?.cancel }) { - cancel() + if let resourceStatus = self.resourceStatus { + switch resourceStatus { + case let .fetchStatus(fetchStatus): + switch fetchStatus { + case .Fetching: + if let cancel = self.fetchControls.with({ return $0?.cancel }) { + cancel() + } + case .Remote: + if let fetch = self.fetchControls.with({ return $0?.fetch }) { + fetch() + } + case .Local: + self.activateLocalContent() } - case .Remote: - if let fetch = self.fetchControls.with({ return $0?.fetch }) { - fetch() + case .playbackStatus: + if let account = self.account, let applicationContext = account.applicationContext as? TelegramApplicationContext { + applicationContext.mediaManager.playlistPlayerControl(.playback(.togglePlayPause)) } - case .Local: - break } } } @objc func fileTap(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { - if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { - self.activateLocalContent() - } else { - self.progressPressed() - } + self.progressPressed() } } @@ -106,13 +110,13 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { let titleAsyncLayout = TextNode.asyncLayout(self.titleNode) let descriptionAsyncLayout = TextNode.asyncLayout(self.descriptionNode) - let currentMessageIdAndFlags = self.messageIdAndFlags + let currentMessage = self.message let statusLayout = self.dateAndStatusNode.asyncLayout() return { account, message, file, incoming, dateAndStatusType, constrainedSize in return (CGFloat.greatestFiniteMagnitude, { constrainedSize in //var updateImageSignal: Signal DrawingContext, NoError>? - var updatedStatusSignal: Signal? + var updatedStatusSignal: Signal? var updatedFetchControls: FetchControls? var mediaUpdated = false @@ -123,7 +127,7 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { } var statusUpdated = mediaUpdated - if currentMessageIdAndFlags?.0 != message.id || currentMessageIdAndFlags?.1 != message.flags { + if currentMessage?.id != message.id || currentMessage?.flags != message.flags { statusUpdated = true } @@ -138,18 +142,7 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { } if statusUpdated { - if message.flags.contains(.Unsent) && !message.flags.contains(.Failed) { - updatedStatusSignal = combineLatest(chatMessageFileStatus(account: account, file: file), account.pendingMessageManager.pendingMessageStatus(message.id)) - |> map { resourceStatus, pendingStatus -> MediaResourceStatus in - if let pendingStatus = pendingStatus { - return .Fetching(progress: pendingStatus.progress) - } else { - return resourceStatus - } - } - } else { - updatedStatusSignal = chatMessageFileStatus(account: account, file: file) - } + updatedStatusSignal = fileMediaResourceStatus(account: account, file: file, message: message) } var statusSize: CGSize? @@ -193,8 +186,15 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { for attribute in file.attributes { if case let .Audio(voice, duration, title, performer, waveform) = attribute { isAudio = true - if let _ = updatedStatusSignal { - updatedStatusSignal = .single(.Local) + if let currentUpdatedStatusSignal = updatedStatusSignal { + updatedStatusSignal = currentUpdatedStatusSignal |> map { status in + switch status { + case .fetchStatus: + return .fetchStatus(.Local) + case .playbackStatus: + return status + } + } } audioDuration = Int32(duration) @@ -293,7 +293,8 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { return (fittedLayoutSize, { [weak self] in if let strongSelf = self { - strongSelf.messageIdAndFlags = (message.id, message.flags) + strongSelf.account = account + strongSelf.message = message strongSelf.file = file let _ = titleApply() @@ -331,7 +332,7 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { strongSelf.statusDisposable.set((updatedStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in displayLinkDispatcher.dispatch { if let strongSelf = strongSelf { - strongSelf.fetchStatus = status + strongSelf.resourceStatus = status if strongSelf.progressNode == nil { let progressNode = RadialProgressNode(theme: RadialProgressTheme(backgroundColor: UIColor(incoming ? 0x007ee5 : 0x3fc33b), foregroundColor: incoming ? UIColor.white : UIColor(0xe1ffc7), icon: incoming ? fileIconIncomingImage : fileIconOutgoingImage)) @@ -341,19 +342,29 @@ final class ChatMessageInteractiveFileNode: ASTransformNode { } switch status { - case let .Fetching(progress): - strongSelf.progressNode?.state = .Fetching(progress: progress) - case .Local: - if isAudio { - strongSelf.progressNode?.state = .Play - } else { - strongSelf.progressNode?.state = .Icon + case let .fetchStatus(fetchStatus): + switch fetchStatus { + case let .Fetching(progress): + strongSelf.progressNode?.state = .Fetching(progress: progress) + case .Local: + if isAudio { + strongSelf.progressNode?.state = .Play + } else { + strongSelf.progressNode?.state = .Icon + } + case .Remote: + if isAudio { + strongSelf.progressNode?.state = .Play + } else { + strongSelf.progressNode?.state = .Remote + } } - case .Remote: - if isAudio { - strongSelf.progressNode?.state = .Play - } else { - strongSelf.progressNode?.state = .Remote + case let .playbackStatus(playbackStatus): + switch playbackStatus { + case .playing: + strongSelf.progressNode?.state = .Pause + case .paused: + strongSelf.progressNode?.state = .Play } } } diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index 21520e7ba3..21c7a131f8 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -32,6 +32,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { private let statusNode: ChatMessageDateAndStatusNode + private var webPage: TelegramMediaWebpage? private var image: TelegramMediaImage? required init() { @@ -75,11 +76,13 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { return { item, layoutConstants, _, constrainedSize in let insets = UIEdgeInsets(top: 0.0, left: 9.0 + 8.0, bottom: 5.0, right: 8.0) - var webpage: TelegramMediaWebpageLoadedContent? + var webPage: TelegramMediaWebpage? + var webPageContent: TelegramMediaWebpageLoadedContent? for media in item.message.media { if let media = media as? TelegramMediaWebpage { + webPage = media if case let .Loaded(content) = media.content { - webpage = content + webPageContent = content } break } @@ -115,12 +118,12 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { var refineContentImageLayout: ((CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> ChatMessageInteractiveMediaNode)))? var refineContentFileLayout: ((CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> ChatMessageInteractiveFileNode)))? - if let webpage = webpage { + if let webpage = webPageContent { let string = NSMutableAttributedString() var notEmpty = false if let websiteName = webpage.websiteName, !websiteName.isEmpty { - string.append(NSAttributedString(string: websiteName, font: titleFont, textColor: item.message.flags.contains(.Incoming) ? incomingAccentColor : outgoingAccentColor)) + string.append(NSAttributedString(string: websiteName, font: titleFont, textColor: item.message.effectivelyIncoming ? incomingAccentColor : outgoingAccentColor)) notEmpty = true } @@ -148,7 +151,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { initialWidth = initialImageWidth + insets.left + insets.right refineContentImageLayout = refineLayout } else { - let (_, refineLayout) = contentFileLayout(item.account, item.message, file, item.message.flags.contains(.Incoming), nil, CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height)) + let (_, refineLayout) = contentFileLayout(item.account, item.message, file, item.message.effectivelyIncoming, nil, CGSize(width: constrainedSize.width - insets.left - insets.right, height: constrainedSize.height)) refineContentFileLayout = refineLayout } } else if let image = webpage.image { @@ -226,7 +229,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { textFrame = textFrame.offsetBy(dx: insets.left, dy: insets.top) - let lineImage = item.message.flags.contains(.Incoming) ? incomingLineImage : outgoingLineImage + let lineImage = item.message.effectivelyIncoming ? incomingLineImage : outgoingLineImage var boundingSize = textFrame.size if let statusFrame = statusFrame { @@ -305,6 +308,10 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { adjustedLineHeight += imageHeigthAddition + 4.0 } + if let _ = webPageContent?.instantPage { + adjustedBoundingSize.height += 4.0 + } + var adjustedStatusFrame: CGRect? if let statusFrame = statusFrame { adjustedStatusFrame = CGRect(origin: CGPoint(x: boundingWidth - statusFrame.size.width - insets.right, y: statusFrame.origin.y), size: statusFrame.size) @@ -333,7 +340,8 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.statusNode.removeFromSupernode() } - strongSelf.image = webpage?.image + strongSelf.webPage = webPage + strongSelf.image = webPageContent?.image if let imageFrame = imageFrame { if let updateImageSignal = updateInlineImageSignal { @@ -421,4 +429,15 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) self.inlineImageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } + + override func tapActionAtPoint(_ point: CGPoint) -> ChatMessageBubbleContentTapAction { + if self.bounds.contains(point) { + if let webPage = self.webPage, case let .Loaded(content) = webPage.content { + if content.instantPage != nil { + return .instantPage + } + } + } + return .none + } } diff --git a/TelegramUI/ChatTextInputAudioRecordingTimeNode.swift b/TelegramUI/ChatTextInputAudioRecordingTimeNode.swift index 3d5d31cc7b..e5903f9dee 100644 --- a/TelegramUI/ChatTextInputAudioRecordingTimeNode.swift +++ b/TelegramUI/ChatTextInputAudioRecordingTimeNode.swift @@ -22,7 +22,7 @@ final class ChatTextInputAudioRecordingTimeNode: ASDisplayNode { self.setNeedsDisplay() } } - private var stateDisposable = MetaDisposable() + private let stateDisposable = MetaDisposable() var audioRecorder: ManagedAudioRecorder? { didSet { @@ -67,7 +67,7 @@ final class ChatTextInputAudioRecordingTimeNode: ASDisplayNode { return ChatTextInputAudioRecordingTimeNodeParameters(timestamp: self.timestamp) } - @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! if !isRasterizing { diff --git a/TelegramUI/ContactsController.swift b/TelegramUI/ContactsController.swift index 30c591335e..7af356aa2c 100644 --- a/TelegramUI/ContactsController.swift +++ b/TelegramUI/ContactsController.swift @@ -79,7 +79,13 @@ private enum ContactsEntry: Comparable, Identifiable { interaction.openPeer(peer.id) }) case let .peer(peer, presence): - return ContactsPeerItem(account: account, peer: peer, presence: presence, index: nil, action: { _ in + let status: ContactsPeerItemStatus + if let presence = presence { + status = .presence(presence) + } else { + status = .none + } + return ContactsPeerItem(account: account, peer: peer, status: status, index: nil, header: nil, action: { _ in interaction.openPeer(peer.id) }) } diff --git a/TelegramUI/ContactsPeerItem.swift b/TelegramUI/ContactsPeerItem.swift index 1af8b8a800..6615411c5f 100644 --- a/TelegramUI/ContactsPeerItem.swift +++ b/TelegramUI/ContactsPeerItem.swift @@ -10,20 +10,29 @@ private let titleFont = Font.regular(17.0) private let titleBoldFont = Font.medium(17.0) private let statusFont = Font.regular(13.0) +enum ContactsPeerItemStatus { + case none + case presence(PeerPresence) + case addressName +} + class ContactsPeerItem: ListViewItem { let account: Account let peer: Peer? - let presence: PeerPresence? + let status: ContactsPeerItemStatus let action: (Peer) -> Void let selectable: Bool = true let headerAccessoryItem: ListViewAccessoryItem? - init(account: Account, peer: Peer?, presence: PeerPresence?, index: PeerNameIndex?, action: @escaping (Peer) -> Void) { + let header: ListViewItemHeader? + + init(account: Account, peer: Peer?, status: ContactsPeerItemStatus, index: PeerNameIndex?, header: ListViewItemHeader?, action: @escaping (Peer) -> Void) { self.account = account self.peer = peer - self.presence = presence + self.status = status self.action = action + self.header = header if let index = index { var letter: String = "#" @@ -61,20 +70,8 @@ class ContactsPeerItem: ListViewItem { async { let node = ContactsPeerItemNode() let makeLayout = node.asyncLayout() - var first = false - var last = false - if let headerAccessoryItem = self.headerAccessoryItem { - first = true - if let previousItem = previousItem, let previousHeaderItem = previousItem.headerAccessoryItem, previousHeaderItem.isEqualToItem(headerAccessoryItem) { - first = false - } - - last = true - if let nextItem = nextItem, let nextHeaderItem = nextItem.headerAccessoryItem, nextHeaderItem.isEqualToItem(headerAccessoryItem) { - last = false - } - } - let (nodeLayout, nodeApply) = makeLayout(self, width, first, last) + let (first, last, firstWithHeader) = ContactsPeerItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem) + let (nodeLayout, nodeApply) = makeLayout(self, width, first, last, firstWithHeader) node.contentSize = nodeLayout.contentSize node.insets = nodeLayout.insets @@ -89,21 +86,8 @@ class ContactsPeerItem: ListViewItem { Queue.mainQueue().async { let layout = node.asyncLayout() async { - var first = false - var last = false - if let headerAccessoryItem = self.headerAccessoryItem { - first = true - if let previousItem = previousItem, let previousHeaderItem = previousItem.headerAccessoryItem, previousHeaderItem.isEqualToItem(headerAccessoryItem) { - first = false - } - - last = true - if let nextItem = nextItem, let nextHeaderItem = nextItem.headerAccessoryItem, nextHeaderItem.isEqualToItem(headerAccessoryItem) { - last = false - } - } - - let (nodeLayout, apply) = layout(self, width, first, last) + let (first, last, firstWithHeader) = ContactsPeerItem.mergeType(item: self, previousItem: previousItem, nextItem: nextItem) + let (nodeLayout, apply) = layout(self, width, first, last, firstWithHeader) Queue.mainQueue().async { completion(nodeLayout, { apply() @@ -119,6 +103,36 @@ class ContactsPeerItem: ListViewItem { self.action(peer) } } + + static func mergeType(item: ContactsPeerItem, previousItem: ListViewItem?, nextItem: ListViewItem?) -> (first: Bool, last: Bool, firstWithHeader: Bool) { + var first = false + var last = false + var firstWithHeader = false + if let previousItem = previousItem { + if let header = item.header { + if let previousItem = previousItem as? ContactsPeerItem { + firstWithHeader = header.id != previousItem.header?.id + } else { + firstWithHeader = true + } + } + } else { + first = true + firstWithHeader = item.header != nil + } + if let nextItem = nextItem { + if let header = item.header { + if let nextItem = nextItem as? ContactsPeerItem { + last = header.id != nextItem.header?.id + } else { + last = true + } + } + } else { + last = true + } + return (first, last, firstWithHeader) + } } private let separatorHeight = 1.0 / UIScreen.main.scale @@ -135,7 +149,7 @@ class ContactsPeerItemNode: ListViewItemNode { private var avatarState: (Account, Peer?)? private var peerPresenceManager: PeerPresenceStatusManager? - private var layoutParams: (ContactsPeerItem, CGFloat, Bool, Bool)? + private var layoutParams: (ContactsPeerItem, CGFloat, Bool, Bool, Bool)? required init() { self.backgroundNode = ASDisplayNode() @@ -166,17 +180,18 @@ class ContactsPeerItemNode: ListViewItemNode { self.peerPresenceManager = PeerPresenceStatusManager(update: { [weak self] in if let strongSelf = self, let layoutParams = strongSelf.layoutParams { - let (_, apply) = strongSelf.asyncLayout()(layoutParams.0, layoutParams.1, layoutParams.2, layoutParams.3) + let (_, apply) = strongSelf.asyncLayout()(layoutParams.0, layoutParams.1, layoutParams.2, layoutParams.3, layoutParams.4) apply() } }) } override func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { - if let (item, _, _, _) = self.layoutParams { - self.layoutParams = (item, width, previousItem != nil, nextItem != nil) + if let (item, _, _, _, _) = self.layoutParams { + let (first, last, firstWithHeader) = ContactsPeerItem.mergeType(item: item as! ContactsPeerItem, previousItem: previousItem, nextItem: nextItem) + self.layoutParams = (item, width, first, last, firstWithHeader) let makeLayout = self.asyncLayout() - let (nodeLayout, nodeApply) = makeLayout(item, width, previousItem != nil, nextItem != nil) + let (nodeLayout, nodeApply) = makeLayout(item, width, first, last, firstWithHeader) self.contentSize = nodeLayout.contentSize self.insets = nodeLayout.insets nodeApply() @@ -209,16 +224,17 @@ class ContactsPeerItemNode: ListViewItemNode { } } - func asyncLayout() -> (_ item: ContactsPeerItem, _ width: CGFloat, _ first: Bool, _ last: Bool) -> (ListViewItemNodeLayout, () -> Void) { + func asyncLayout() -> (_ item: ContactsPeerItem, _ width: CGFloat, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool) -> (ListViewItemNodeLayout, () -> Void) { let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeStatusLayout = TextNode.asyncLayout(self.statusNode) - return { [weak self] item, width, first, last in + return { [weak self] item, width, first, last, firstWithHeader in let leftInset: CGFloat = 65.0 let rightInset: CGFloat = 10.0 var titleAttributedString: NSAttributedString? var statusAttributedString: NSAttributedString? + var userPresence: TelegramUserPresence? if let peer = item.peer { if let user = peer as? TelegramUser { @@ -235,24 +251,34 @@ class ContactsPeerItemNode: ListViewItemNode { } else { titleAttributedString = NSAttributedString(string: "Deleted User", font: titleBoldFont, textColor: UIColor(0xa6a6a6)) } - - if let presence = item.presence as? TelegramUserPresence { - let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 - let (string, activity) = stringAndActivityForUserPresence(presence, relativeTo: Int32(timestamp)) - statusAttributedString = NSAttributedString(string: string, font: statusFont, textColor: activity ? UIColor(0x007ee5) : UIColor(0xa6a6a6)) - } } else if let group = peer as? TelegramGroup { titleAttributedString = NSAttributedString(string: group.title, font: titleBoldFont, textColor: UIColor.black) } else if let channel = peer as? TelegramChannel { titleAttributedString = NSAttributedString(string: channel.title, font: titleBoldFont, textColor: UIColor.black) } + + switch item.status { + case .none: + break + case let .presence(presence): + if let presence = presence as? TelegramUserPresence { + userPresence = presence + let timestamp = CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970 + let (string, activity) = stringAndActivityForUserPresence(presence, relativeTo: Int32(timestamp)) + statusAttributedString = NSAttributedString(string: string, font: statusFont, textColor: activity ? UIColor(0x007ee5) : UIColor(0xa6a6a6)) + } + case .addressName: + if let addressName = peer.addressName { + statusAttributedString = NSAttributedString(string: "@" + addressName, font: statusFont, textColor: UIColor(0xa6a6a6)) + } + } } let (titleLayout, titleApply) = makeTitleLayout(titleAttributedString, nil, 1, .end, CGSize(width: max(0.0, width - leftInset - rightInset), height: CGFloat.infinity), nil) let (statusLayout, statusApply) = makeStatusLayout(statusAttributedString, nil, 1, .end, CGSize(width: max(0.0, width - leftInset - rightInset), height: CGFloat.infinity), nil) - let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 48.0), insets: UIEdgeInsets(top: first ? 29.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0)) + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 48.0), insets: UIEdgeInsets(top: firstWithHeader ? 29.0 : 0.0, left: 0.0, bottom: 0.0, right: 0.0)) let titleFrame: CGRect if statusAttributedString != nil { @@ -263,7 +289,7 @@ class ContactsPeerItemNode: ListViewItemNode { return (nodeLayout, { [weak self] in if let strongSelf = self { - strongSelf.layoutParams = (item, width, first, last) + strongSelf.layoutParams = (item, width, first, last, firstWithHeader) if let peer = item.peer { strongSelf.avatarNode.setPeer(account: item.account, peer: peer) } @@ -282,8 +308,8 @@ class ContactsPeerItemNode: ListViewItemNode { strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: 65.0, y: nodeLayout.contentSize.height - separatorHeight), size: CGSize(width: max(0.0, nodeLayout.size.width - 65.0), height: separatorHeight)) strongSelf.separatorNode.isHidden = last - if let presence = item.presence as? TelegramUserPresence { - strongSelf.peerPresenceManager?.reset(presence: presence) + if let userPresence = userPresence { + strongSelf.peerPresenceManager?.reset(presence: userPresence) } } }) @@ -302,4 +328,12 @@ class ContactsPeerItemNode: ListViewItemNode { override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.5, removeOnCompletion: false) } + + override public func header() -> ListViewItemHeader? { + if let (item, _, _, _, _) = self.layoutParams { + return item.header + } else { + return nil + } + } } diff --git a/TelegramUI/ContactsSearchContainerNode.swift b/TelegramUI/ContactsSearchContainerNode.swift index 1affbc5331..781bb1bb18 100644 --- a/TelegramUI/ContactsSearchContainerNode.swift +++ b/TelegramUI/ContactsSearchContainerNode.swift @@ -55,7 +55,7 @@ final class ContactsSearchContainerNode: SearchDisplayControllerContentNode { for item in items { switch item { case let .peer(peer): - listItems.append(ContactsPeerItem(account: account, peer: peer, presence: nil, index: nil, action: { [weak self] peer in + listItems.append(ContactsPeerItem(account: account, peer: peer, status: .none, index: nil, header: nil, action: { [weak self] peer in if let openPeer = self?.openPeer { self?.listNode.clearHighlightAnimated(true) openPeer(peer.id) diff --git a/TelegramUI/FFMpegMediaFrameSource.swift b/TelegramUI/FFMpegMediaFrameSource.swift index 0963646611..da7423b473 100644 --- a/TelegramUI/FFMpegMediaFrameSource.swift +++ b/TelegramUI/FFMpegMediaFrameSource.swift @@ -142,7 +142,7 @@ final class FFMpegMediaFrameSource: NSObject, MediaFrameSource { self.performWithContext { [weak self] context in context.initializeState(postbox: postbox, resource: resource) - let frames = context.takeFrames(until: timestamp) + let (frames, endOfStream) = context.takeFrames(until: timestamp) queue.async { [weak self] in if let strongSelf = self { @@ -150,6 +150,9 @@ final class FFMpegMediaFrameSource: NSObject, MediaFrameSource { for sink in strongSelf.eventSinkBag.copyItems() { sink(.frames(frames)) + if endOfStream { + sink(.endOfStream) + } } if strongSelf.requestedFrameGenerationTimestamp != nil && !strongSelf.requestedFrameGenerationTimestamp!.isEqual(to: timestamp) { diff --git a/TelegramUI/FFMpegMediaFrameSourceContext.swift b/TelegramUI/FFMpegMediaFrameSourceContext.swift index 503aecd881..84e55ba13a 100644 --- a/TelegramUI/FFMpegMediaFrameSourceContext.swift +++ b/TelegramUI/FFMpegMediaFrameSourceContext.swift @@ -260,13 +260,13 @@ final class FFMpegMediaFrameSourceContext: NSObject { } } - func takeFrames(until: Double) -> [MediaTrackDecodableFrame] { + func takeFrames(until: Double) -> (frames: [MediaTrackDecodableFrame], endOfStream: Bool) { if self.readingError { - return [] + return ([], true) } guard let initializedState = self.initializedState else { - return [] + return ([], true) } var videoTimestamp: Double? @@ -280,6 +280,7 @@ final class FFMpegMediaFrameSourceContext: NSObject { } var frames: [MediaTrackDecodableFrame] = [] + var endOfStream = false while !self.readingError && ((videoTimestamp == nil || videoTimestamp!.isLess(than: until)) || (audioTimestamp == nil || audioTimestamp!.isLess(than: until))) { @@ -332,11 +333,12 @@ final class FFMpegMediaFrameSourceContext: NSObject { } } } else { + endOfStream = true break } } - return frames + return (frames, endOfStream) } func contextInfo() -> FFMpegMediaFrameSourceContextInfo? { diff --git a/TelegramUI/FileMediaResourceStatus.swift b/TelegramUI/FileMediaResourceStatus.swift new file mode 100644 index 0000000000..b1aea73bb7 --- /dev/null +++ b/TelegramUI/FileMediaResourceStatus.swift @@ -0,0 +1,74 @@ +import Foundation +import TelegramCore +import Postbox +import SwiftSignalKit + +enum FileMediaResourcePlaybackStatus { + case playing + case paused +} + +enum FileMediaResourceStatus { + case fetchStatus(MediaResourceStatus) + case playbackStatus(FileMediaResourcePlaybackStatus) +} + +func fileMediaResourceStatus(account: Account, file: TelegramMediaFile, message: Message) -> Signal { + let playbackStatus: Signal + if let applicationContext = account.applicationContext as? TelegramApplicationContext, let (playlistId, itemId) = peerMessageAudioPlaylistAndItemIds(message) { + playbackStatus = applicationContext.mediaManager.filteredPlaylistPlayerStateAndStatus(playlistId: playlistId, itemId: itemId) + |> mapToSignal { status -> Signal in + if let status = status, let playbackStatus = status.status { + return playbackStatus + |> map { playbackStatus -> MediaPlayerPlaybackStatus? in + return playbackStatus.status + } + |> distinctUntilChanged(isEqual: { lhs, rhs in + return lhs == rhs + }) + } else { + return .single(nil) + } + } + } else { + playbackStatus = .single(nil) + } + + if message.flags.contains(.Unsent) && !message.flags.contains(.Failed) { + return combineLatest(chatMessageFileStatus(account: account, file: file), account.pendingMessageManager.pendingMessageStatus(message.id), playbackStatus) + |> map { resourceStatus, pendingStatus, playbackStatus -> FileMediaResourceStatus in + if let playbackStatus = playbackStatus { + switch playbackStatus { + case .playing: + return .playbackStatus(.playing) + case .paused: + return .playbackStatus(.paused) + case let .buffering(whilePlaying): + if whilePlaying { + return .playbackStatus(.playing) + } else { + return .playbackStatus(.paused) + } + } + } else if let pendingStatus = pendingStatus { + return .fetchStatus(.Fetching(progress: pendingStatus.progress)) + } else { + return .fetchStatus(resourceStatus) + } + } + } else { + return combineLatest(chatMessageFileStatus(account: account, file: file), playbackStatus) + |> map { resourceStatus, playbackStatus -> FileMediaResourceStatus in + if let playbackStatus = playbackStatus { + switch playbackStatus { + case .playing: + return .playbackStatus(.playing) + case .paused, .buffering: + return .playbackStatus(.paused) + } + } else { + return .fetchStatus(resourceStatus) + } + } + } +} diff --git a/TelegramUI/HorizontalPeerItem.swift b/TelegramUI/HorizontalPeerItem.swift index a841daef8e..daa61e5d96 100644 --- a/TelegramUI/HorizontalPeerItem.swift +++ b/TelegramUI/HorizontalPeerItem.swift @@ -7,9 +7,9 @@ import TelegramCore final class HorizontalPeerItem: ListViewItem { let account: Account let peer: Peer - let action: (PeerId) -> Void + let action: (Peer) -> Void - init(account: Account, peer: Peer, action: @escaping (PeerId) -> Void) { + init(account: Account, peer: Peer, action: @escaping (Peer) -> Void) { self.account = account self.peer = peer self.action = action @@ -37,7 +37,7 @@ private final class HorizontalPeerItemNode: ListViewItemNode { private let avatarNode: AvatarNode private let titleNode: ASTextNode private var peer: Peer? - fileprivate var action: ((PeerId) -> Void)? + fileprivate var action: ((Peer) -> Void)? init() { self.avatarNode = AvatarNode(font: Font.regular(14.0)) @@ -72,7 +72,7 @@ private final class HorizontalPeerItemNode: ListViewItemNode { @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { if let peer = self.peer, let action = self.action { - action(peer.id) + action(peer) } } } diff --git a/TelegramUI/InstantPageAnchorItem.swift b/TelegramUI/InstantPageAnchorItem.swift new file mode 100644 index 0000000000..6feba4fce6 --- /dev/null +++ b/TelegramUI/InstantPageAnchorItem.swift @@ -0,0 +1,43 @@ +import Foundation +import TelegramCore + +final class InstantPageAnchorItem: InstantPageItem { + let hasLinks: Bool = false + let wantsNode: Bool = false + let medias: [InstantPageMedia] = [] + + let anchor: String + var frame: CGRect + + init(frame: CGRect, anchor: String) { + self.frame = frame + self.anchor = anchor + } + + func matchesAnchor(_ anchor: String) -> Bool { + return anchor == self.anchor + } + + func drawInTile(context: CGContext) { + } + + func node(account: Account) -> InstantPageNode? { + return nil + } + + func matchesNode(_ node: InstantPageNode) -> Bool { + return false + } + + func linkSelectionViews() -> [InstantPageLinkSelectionView] { + return [] + } + + func distanceThresholdGroup() -> Int? { + return nil + } + + func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat { + return 0.0 + } +} diff --git a/TelegramUI/InstantPageController.swift b/TelegramUI/InstantPageController.swift new file mode 100644 index 0000000000..e60dfa194c --- /dev/null +++ b/TelegramUI/InstantPageController.swift @@ -0,0 +1,40 @@ +import Foundation +import TelegramCore +import Display + +final class InstantPageController: ViewController { + private let account: Account + private let webPage: TelegramMediaWebpage + + var controllerNode: InstantPageControllerNode { + return self.displayNode as! InstantPageControllerNode + } + + init(account: Account, webPage: TelegramMediaWebpage) { + self.account = account + self.webPage = webPage + + super.init() + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func loadDisplayNode() { + self.displayNode = InstantPageControllerNode(account: self.account) + + self.navigationBar.isHidden = true + self.statusBar.alpha = 0.0 + + self.displayNodeDidLoad() + + self.controllerNode.updateWebPage(self.webPage) + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + + self.controllerNode.containerLayoutUpdated(layout, navigationBarHeight: self.navigationHeight, transition: transition) + } +} diff --git a/TelegramUI/InstantPageControllerNode.swift b/TelegramUI/InstantPageControllerNode.swift new file mode 100644 index 0000000000..f53aa701e3 --- /dev/null +++ b/TelegramUI/InstantPageControllerNode.swift @@ -0,0 +1,287 @@ +import Foundation +import TelegramCore +import AsyncDisplayKit +import Display + +final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate { + private let account: Account + + private var webPage: TelegramMediaWebpage? + + private var containerLayout: ContainerViewLayout? + private let scrollNode: ASScrollNode + + var currentLayout: InstantPageLayout? + var currentLayoutTiles: [InstantPageTile] = [] + var currentLayoutItemsWithViews: [InstantPageItem] = [] + var currentLayoutItemsWithLinks: [InstantPageItem] = [] + var distanceThresholdGroupCount: [Int: Int] = [:] + + var visibleTiles: [Int: InstantPageTileNode] = [:] + var visibleItemsWithViews: [Int: InstantPageNode] = [:] + var visibleLinkSelectionViews: [Int: InstantPageLinkSelectionView] = [:] + + var previousContentOffset: CGPoint? + var isDeceleratingBecauseOfDragging = false + + init(account: Account) { + self.account = account + + self.scrollNode = ASScrollNode() + + super.init(viewBlock: { + return UITracingLayerView() + }, didLoad: nil) + + self.backgroundColor = .white + self.addSubnode(self.scrollNode) + self.scrollNode.view.delegate = self + } + + func updateWebPage(_ webPage: TelegramMediaWebpage?) { + if self.webPage != webPage { + self.webPage = webPage + + self.currentLayout = nil + self.updateLayout() + + self.scrollNode.frame = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0) + if let containerLayout = self.containerLayout { + self.containerLayoutUpdated(containerLayout, navigationBarHeight: 0.0, transition: .immediate) + } + } + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.containerLayout = layout + + if self.scrollNode.bounds.size != layout.size { + if !self.scrollNode.bounds.size.width.isEqual(to: layout.size.width) { + self.updateLayout() + } + self.scrollNode.frame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) + //_scrollViewHeader.frame = CGRectMake(0.0f, -2000.0f, bounds.size.width, 2000.0f); + //self.scrollView.contentInset = UIEdgeInsetsMake(_statusBarHeight + 44.0f, 0.0f, 0.0f, 0.0f); + if self.visibleItemsWithViews.isEmpty && self.visibleTiles.isEmpty { + self.scrollNode.view.contentOffset = CGPoint(x: 0.0, y: 0.0) + } + self.updateVisibleItems() + self.updateNavigationBar() + } + } + + private func updateLayout() { + guard let containerLayout = self.containerLayout, let webPage = self.webPage else { + return + } + + let currentLayout = instantPageLayoutForWebPage(webPage, boundingWidth: containerLayout.size.width) + + for (_, tileNode) in self.visibleTiles { + tileNode.removeFromSupernode() + } + self.visibleTiles.removeAll() + + for (_, linkView) in self.visibleLinkSelectionViews { + linkView.removeFromSuperview() + } + self.visibleLinkSelectionViews.removeAll() + + let currentLayoutTiles = instantPageTilesFromLayout(currentLayout, boundingWidth: containerLayout.size.width) + + var currentLayoutItemsWithViews: [InstantPageItem] = [] + var currentLayoutItemsWithLinks: [InstantPageItem] = [] + var distanceThresholdGroupCount: [Int: Int] = [:] + + for item in currentLayout.items { + if item.wantsNode { + currentLayoutItemsWithViews.append(item) + if let group = item.distanceThresholdGroup() { + let count: Int + if let currentCount = distanceThresholdGroupCount[Int(group)] { + count = currentCount + } else { + count = 0 + } + distanceThresholdGroupCount[Int(group)] = count + 1 + } + } + if item.hasLinks { + currentLayoutItemsWithLinks.append(item) + } + } + + self.currentLayout = currentLayout + self.currentLayoutTiles = currentLayoutTiles + self.currentLayoutItemsWithViews = currentLayoutItemsWithViews + self.currentLayoutItemsWithLinks = currentLayoutItemsWithLinks + self.distanceThresholdGroupCount = distanceThresholdGroupCount + + self.scrollNode.view.contentSize = currentLayout.contentSize + } + + func updateVisibleItems() { + var visibleTileIndices = Set() + var visibleItemIndices = Set() + var visibleItemLinkIndices = Set() + + var visibleBounds = self.scrollNode.view.bounds + + var topNode: ASDisplayNode? + for node in self.scrollNode.subnodes.reversed() { + if let node = node as? InstantPageTileNode { + topNode = node + break + } + } + + var tileIndex = -1 + for tile in self.currentLayoutTiles { + tileIndex += 1 + var tileVisibleFrame = tile.frame + tileVisibleFrame.origin.y -= 400.0 + tileVisibleFrame.size.height += 400.0 * 2.0 + if tileVisibleFrame.intersects(visibleBounds) { + visibleTileIndices.insert(tileIndex) + + if visibleTiles[tileIndex] == nil { + let tileNode = InstantPageTileNode(tile: tile) + tileNode.frame = tile.frame + if let topNode = topNode { + self.scrollNode.insertSubnode(tileNode, aboveSubnode: topNode) + } else { + self.scrollNode.insertSubnode(tileNode, at: 0) + } + topNode = tileNode + self.visibleTiles[tileIndex] = tileNode + } + } + } + + var itemIndex = -1 + for item in self.currentLayoutItemsWithViews { + itemIndex += 1 + var itemThreshold: CGFloat = 0.0 + if let group = item.distanceThresholdGroup() { + var count: Int = 0 + if let currentCount = self.distanceThresholdGroupCount[group] { + count = currentCount + } + itemThreshold = item.distanceThresholdWithGroupCount(count) + } + var itemFrame = item.frame + itemFrame.origin.y -= itemThreshold + itemFrame.size.height += itemThreshold * 2.0 + if visibleBounds.intersects(itemFrame) { + visibleItemIndices.insert(itemIndex) + + var itemNode = self.visibleItemsWithViews[itemIndex] + if let currentItemNode = itemNode { + if !item.matchesNode(currentItemNode) { + (currentItemNode as! ASDisplayNode).removeFromSupernode() + self.visibleItemsWithViews.removeValue(forKey: itemIndex) + itemNode = nil + } + } + + if itemNode == nil { + if let itemNode = item.node(account: self.account) { + (itemNode as! ASDisplayNode).frame = item.frame + if let topNode = topNode { + self.scrollNode.insertSubnode(itemNode as! ASDisplayNode, aboveSubnode: topNode) + } else { + self.scrollNode.insertSubnode(itemNode as! ASDisplayNode, at: 0) + } + topNode = itemNode as! ASDisplayNode + self.visibleItemsWithViews[itemIndex] = itemNode + } + } else { + if (itemNode as! ASDisplayNode).frame != item.frame { + (itemNode as! ASDisplayNode).frame = item.frame + } + } + } + } + + var removeTileIndices: [Int] = [] + for (index, tileNode) in self.visibleTiles { + if !visibleTileIndices.contains(index) { + removeTileIndices.append(index) + tileNode.removeFromSupernode() + } + } + for index in removeTileIndices { + self.visibleTiles.removeValue(forKey: index) + } + + var removeItemIndices: [Int] = [] + for (index, itemNode) in self.visibleItemsWithViews { + if !visibleItemIndices.contains(index) { + removeItemIndices.append(index) + (itemNode as! ASDisplayNode).removeFromSupernode() + } else { + var itemFrame = (itemNode as! ASDisplayNode).frame + let itemThreshold: CGFloat = 200.0 + itemFrame.origin.y -= itemThreshold + itemFrame.size.height += itemThreshold * 2.0 + itemNode.updateIsVisible(visibleBounds.intersects(itemFrame)) + } + } + for index in removeItemIndices { + self.visibleItemsWithViews.removeValue(forKey: index) + } + + /* + itemIndex = -1; + for (id item in _currentLayoutItemsWithLinks) { + itemIndex++; + CGRect itemFrame = item.frame; + if (CGRectIntersectsRect(itemFrame, visibleBounds)) { + [visibleItemLinkIndices addObject:@(itemIndex)]; + + if (_visibleLinkSelectionViews[@(itemIndex)] == nil) { + NSArray *linkViews = [item linkSelectionViews]; + for (TGInstantPageLinkSelectionView *linkView in linkViews) { + linkView.itemTapped = _urlItemTapped; + + [_scrollView addSubview:linkView]; + } + _visibleLinkSelectionViews[@(itemIndex)] = linkViews; + } + } + } + + NSMutableArray *removeItemLinkIndices = [[NSMutableArray alloc] init]; + [_visibleLinkSelectionViews enumerateKeysAndObjectsUsingBlock:^(NSNumber *nIndex, NSArray *linkViews, __unused BOOL *stop) { + if (![visibleItemLinkIndices containsObject:nIndex]) { + for (UIView *linkView in linkViews) { + [linkView removeFromSuperview]; + } + [removeItemLinkIndices addObject:nIndex]; + } + }]; + [_visibleLinkSelectionViews removeObjectsForKeys:removeItemLinkIndices];*/ + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + self.updateVisibleItems() + self.updateNavigationBar() + self.previousContentOffset = self.scrollNode.view.contentOffset + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + self.isDeceleratingBecauseOfDragging = decelerate + if !decelerate { + self.updateNavigationBar(forceState: true) + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.updateNavigationBar(forceState: true) + self.isDeceleratingBecauseOfDragging = false + } + + func updateNavigationBar(forceState: Bool = false) { + + } +} diff --git a/TelegramUI/InstantPageItem.swift b/TelegramUI/InstantPageItem.swift new file mode 100644 index 0000000000..f92e9eb933 --- /dev/null +++ b/TelegramUI/InstantPageItem.swift @@ -0,0 +1,18 @@ +import Foundation +import TelegramCore + +protocol InstantPageItem { + var frame: CGRect { get set } + var hasLinks: Bool { get } + var wantsNode: Bool { get } + var medias: [InstantPageMedia] { get } + + func matchesAnchor(_ anchor: String) -> Bool + func drawInTile(context: CGContext) + func node(account: Account) -> InstantPageNode? + func matchesNode(_ node: InstantPageNode) -> Bool + func linkSelectionViews() -> [InstantPageLinkSelectionView] + + func distanceThresholdGroup() -> Int? + func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat +} diff --git a/TelegramUI/InstantPageLayout.swift b/TelegramUI/InstantPageLayout.swift new file mode 100644 index 0000000000..210741e1b0 --- /dev/null +++ b/TelegramUI/InstantPageLayout.swift @@ -0,0 +1,588 @@ +import Foundation +import TelegramCore +import Postbox +import Display + +final class InstantPageLayout { + let origin: CGPoint + let contentSize: CGSize + let items: [InstantPageItem] + + init(origin: CGPoint, contentSize: CGSize, items: [InstantPageItem]) { + self.origin = origin + self.contentSize = contentSize + self.items = items + } + + func flattenedItemsWithOrigin(_ origin: CGPoint) -> [InstantPageItem] { + return self.items.map({ item in + var item = item + item.frame = item.frame.offsetBy(dx: origin.x, dy: origin.y) + return item + }) + } +} + +func layoutInstantPageBlock(_ block: InstantPageBlock, boundingWidth: CGFloat, horizontalInset: CGFloat, isCover: Bool, fillToWidthAndHeight: Bool, media: [MediaId: Media], mediaIndexCounter: inout Int) -> InstantPageLayout { + switch block { + case let .cover(block): + return layoutInstantPageBlock(block, boundingWidth: boundingWidth, horizontalInset: horizontalInset, isCover: true, fillToWidthAndHeight: fillToWidthAndHeight, media: media, mediaIndexCounter: &mediaIndexCounter) + case let .title(text): + let styleStack = InstantPageTextStyleStack() + styleStack.push(.fontSize(28.0)) + styleStack.push(.fontSerif(true)) + styleStack.push(.lineSpacingFactor(0.685)) + let item = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0) + item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) + case let .subtitle(text): + let styleStack = InstantPageTextStyleStack() + styleStack.push(.fontSize(17.0)) + let item = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0) + item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) + case let .header(text): + let styleStack = InstantPageTextStyleStack() + styleStack.push(.fontSize(24.0)) + styleStack.push(.fontSerif(true)) + styleStack.push(.lineSpacingFactor(0.685)) + let item = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0) + item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) + case let .subheader(text): + let styleStack = InstantPageTextStyleStack() + styleStack.push(.fontSize(19.0)) + styleStack.push(.fontSerif(true)) + styleStack.push(.lineSpacingFactor(0.685)) + let item = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0) + item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) + case let .paragraph(text): + let styleStack = InstantPageTextStyleStack() + styleStack.push(.fontSize(17.0)) + let item = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0) + item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) + case let .preformatted(text): + let styleStack = InstantPageTextStyleStack() + styleStack.push(.fontSize(16.0)) + styleStack.push(.fontFixed(true)) + styleStack.push(.lineSpacingFactor(0.685)) + let backgroundInset: CGFloat = 14.0 + let item = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0 - backgroundInset * 2.0) + item.frame = item.frame.offsetBy(dx: horizontalInset, dy: backgroundInset) + let backgroundItem = InstantPageShapeItem(frame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: item.frame.size.height + backgroundInset * 2.0)), shapeFrame: CGRect(origin: CGPoint(), size: CGSize(width: boundingWidth, height: item.frame.size.height + backgroundInset * 2.0)), shape: .rect, color: UIColor(0xF5F8FC)) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [backgroundItem, item]) + case let .authorDate(author: author, date: date): + let styleStack = InstantPageTextStyleStack() + styleStack.push(.fontSize(15.0)) + styleStack.push(.textColor(UIColor(0x79828b))) + var text: RichText? + if case .empty = author { + if date != 0 { + let dateStringPlain = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(date)), dateStyle: .long, timeStyle: .none) + text = RichText.plain(dateStringPlain) + } + } else { + let dateStringPlain = DateFormatter.localizedString(from: Date(timeIntervalSince1970: Double(date)), dateStyle: .long, timeStyle: .none) + let dateText = RichText.plain(dateStringPlain) + + if date != 0 { + let formatString = NSLocalizedString("InstantPage.AuthorAndDateTitle", comment: "") + let authorRange = formatString.range(of: "%1$@")! + let dateRange = formatString.range(of: "%2$@")! + + if authorRange.lowerBound < dateRange.lowerBound { + let byPart = formatString.substring(to: authorRange.lowerBound) + let middlePart = formatString.substring(with: authorRange.upperBound ..< dateRange.lowerBound) + let endPart = formatString.substring(from: dateRange.upperBound) + + text = .concat([.plain(byPart), author, .plain(middlePart), dateText, .plain(endPart)]) + } else { + let beforePart = formatString.substring(to: dateRange.lowerBound) + let middlePart = formatString.substring(with: dateRange.upperBound ..< authorRange.lowerBound) + let endPart = formatString.substring(from: authorRange.upperBound) + + text = .concat([.plain(beforePart), dateText, .plain(middlePart), author, .plain(endPart)]) + } + } else { + text = author + } + } + if let text = text { + let item = layoutTextItemWithString(attributedStringForRichText(text, styleStack: styleStack), boundingWidth: boundingWidth - horizontalInset * 2.0) + item.frame = item.frame.offsetBy(dx: horizontalInset, dy: 0.0) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) + } else { + return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) + } + case let .image(id, caption): + if let image = media[id] as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) { + let imageSize = largest.dimensions + let filledSize = imageSize.aspectFitted(CGSize(width: boundingWidth, height: 1200.0)) + let mediaIndex = mediaIndexCounter + mediaIndexCounter += 1 + + let mediaItem = InstantPageMediaItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - filledSize.width) / 2.0), y: 0.0), size: filledSize), media: InstantPageMedia(index: mediaIndex, media: image, caption: nil), arguments: InstantPageMediaArguments.image(interactive: true, roundCorners: false, fit: false)) + return InstantPageLayout(origin: CGPoint(), contentSize: mediaItem.frame.size, items: [mediaItem]) + } else { + return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) + } + case let .webEmbed(url, html, dimensions, caption, stretchToWidth, allowScrolling): + var embedBoundingWidth = boundingWidth - horizontalInset * 2.0 + if stretchToWidth { + embedBoundingWidth = boundingWidth + } + let size: CGSize + if dimensions.width.isLessThanOrEqualTo(0.0) { + size = CGSize(width: embedBoundingWidth, height: dimensions.height) + } else { + size = dimensions.aspectFitted(CGSize(width: embedBoundingWidth, height: embedBoundingWidth)) + } + let item = InstantPageWebEmbedItem(frame: CGRect(origin: CGPoint(x: floor((boundingWidth - size.width) / 2.0), y: 0.0), size: size), url: url, html: html, enableScrolling: allowScrolling) + return InstantPageLayout(origin: CGPoint(), contentSize: item.frame.size, items: [item]) + default: + return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) + } +} + + +/* + + if ([block isKindOfClass:[TGInstantPageBlockPhoto class]]) { + TGInstantPageBlockPhoto *photoBlock = (TGInstantPageBlockPhoto *)block; + TGImageMediaAttachment *imageMedia = images[@(photoBlock.photoId)]; + if (imageMedia != nil) { + CGSize imageSize = CGSizeZero; + if ([imageMedia.imageInfo imageUrlForLargestSize:&imageSize] != nil) { + CGSize filledSize = TGFitSize(imageSize, CGSizeMake(boundingWidth, 1200.0)); + if (fillToWidthAndHeight) { + filledSize = CGSizeMake(boundingWidth, boundingWidth); + } else if (isCover) { + filledSize = TGScaleToFill(imageSize, CGSizeMake(boundingWidth, 1.0f)); + if (filledSize.height > FLT_EPSILON) { + filledSize = TGCropSize(filledSize, CGSizeMake(boundingWidth, CGFloor(boundingWidth * 3.0f / 5.0f))); + } + } + + NSMutableArray *items = [[NSMutableArray alloc] init]; + + NSInteger mediaIndex = *mediaIndexCounter; + (*mediaIndexCounter)++; + + CGSize contentSize = CGSizeMake(boundingWidth, 0.0f); + TGImageMediaAttachment *mediaWithCaption = [imageMedia copy]; + mediaWithCaption.caption = richPlainText(photoBlock.caption); + TGInstantPageMediaItem *mediaItem = [[TGInstantPageMediaItem alloc] initWithFrame:CGRectMake(CGFloor((boundingWidth - filledSize.width) / 2.0f), 0.0f, filledSize.width, filledSize.height) media:[[TGInstantPageMedia alloc] initWithIndex:mediaIndex media:mediaWithCaption] arguments:[[TGInstantPageImageMediaArguments alloc] initWithInteractive:true roundCorners:false fit:false]]; + [items addObject:mediaItem]; + contentSize.height += filledSize.height; + + if (photoBlock.caption != nil) { + contentSize.height += 10.0f; + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:15.0]]; + [styleStack pushItem:[[TGInstantPageStyleTextColorItem alloc] initWithColor:UIColorRGB(0x79828B)]]; + TGInstantPageTextItem *captionItem = [self layoutTextItemWithString:[self attributedStringForRichText:photoBlock.caption styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset]; + if (filledSize.width >= boundingWidth - FLT_EPSILON) { + captionItem.alignment = NSTextAlignmentCenter; + captionItem.frame = CGRectOffset(captionItem.frame, horizontalInset, contentSize.height); + } else { + captionItem.alignment = NSTextAlignmentCenter; + captionItem.frame = CGRectOffset(captionItem.frame, CGFloor((boundingWidth - captionItem.frame.size.width) / 2.0), contentSize.height); + } + contentSize.height += captionItem.frame.size.height; + [items addObject:captionItem]; + } + + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:contentSize items:items]; + } + } + } + + */ + + + + + + + + +/*+ + else if ([block isKindOfClass:[TGInstantPageBlockFooter class]]) { + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:15.0]]; + [styleStack pushItem:[[TGInstantPageStyleTextColorItem alloc] initWithColor:UIColorRGB(0x79828B)]]; + TGInstantPageTextItem *item = [self layoutTextItemWithString:[self attributedStringForRichText:((TGInstantPageBlockFooter *)block).text styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset]; + item.frame = CGRectOffset(item.frame, horizontalInset, 0.0f); + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:item.frame.size items:@[item]]; + + } else if ([block isKindOfClass:[TGInstantPageBlockDivider class]]) { + CGFloat lineWidth = CGFloor(boundingWidth / 2.0f); + TGInstantPageShapeItem *shapeItem = [[TGInstantPageShapeItem alloc] initWithFrame:CGRectMake(CGFloor((boundingWidth - lineWidth) / 2.0f), 0.0f, lineWidth, 1.0f) shapeFrame:CGRectMake(0.0f, 0.0f, lineWidth, 1.0f) shape:TGInstantPageShapeRect color:UIColorRGBA(0x79828B, 0.4f)]; + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:shapeItem.frame.size items:@[shapeItem]]; + } else if ([block isKindOfClass:[TGInstantPageBlockList class]]) { + TGInstantPageBlockList *listBlock = (TGInstantPageBlockList *)block; + CGSize contentSize = CGSizeMake(boundingWidth, 0.0f); + CGFloat maxIndexWidth = 0.0f; + NSMutableArray> *listItems = [[NSMutableArray alloc] init]; + NSMutableArray> *indexItems = [[NSMutableArray alloc] init]; + for (NSUInteger i = 0; i < listBlock.items.count; i++) { + if (listBlock.ordered) { + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:17.0]]; + + TGInstantPageTextItem *textItem = [self layoutTextItemWithString:[self attributedStringForRichText:[[TGRichTextPlain alloc] initWithText:[NSString stringWithFormat:@"%d.", (int)i + 1]] styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset]; + maxIndexWidth = MAX(textItem->_lines.firstObject.frame.size.width, maxIndexWidth); + [indexItems addObject:textItem]; + } else { + TGInstantPageShapeItem *shapeItem = [[TGInstantPageShapeItem alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 6.0f, 12.0f) shapeFrame:CGRectMake(0.0f, 3.0f, 6.0f, 6.0f) shape:TGInstantPageShapeEllipse color:[UIColor blackColor]]; + [indexItems addObject:shapeItem]; + } + } + NSInteger index = -1; + CGFloat indexSpacing = listBlock.ordered ? 7.0f : 20.0f; + for (TGRichText *text in listBlock.items) { + index++; + if (index != 0) { + contentSize.height += 20.0f; + } + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:17.0]]; + TGInstantPageTextItem *textItem = [self layoutTextItemWithString:[self attributedStringForRichText:text styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset - indexSpacing - maxIndexWidth]; + textItem.frame = CGRectOffset(textItem.frame, horizontalInset + indexSpacing + maxIndexWidth, contentSize.height); + + contentSize.height += textItem.frame.size.height; + id indexItem = indexItems[index]; + indexItem.frame = CGRectOffset(indexItem.frame, horizontalInset, textItem.frame.origin.y); + [listItems addObject:indexItem]; + [listItems addObject:textItem]; + } + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:contentSize items:listItems]; + } else if ([block isKindOfClass:[TGInstantPageBlockBlockQuote class]]) { + TGInstantPageBlockBlockQuote *quoteBlock = (TGInstantPageBlockBlockQuote *)block; + CGFloat lineInset = 20.0f; + CGFloat verticalInset = 4.0f; + CGSize contentSize = CGSizeMake(boundingWidth, verticalInset); + + NSMutableArray> *items = [[NSMutableArray alloc] init]; + + { + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:17.0]]; + [styleStack pushItem:[[TGInstantPageStyleFontSerifItem alloc] initWithSerif:true]]; + [styleStack pushItem:[[TGInstantPageStyleItalicItem alloc] init]]; + + TGInstantPageTextItem *textItem = [self layoutTextItemWithString:[self attributedStringForRichText:quoteBlock.text styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset - lineInset]; + textItem.frame = CGRectOffset(textItem.frame, horizontalInset + lineInset, contentSize.height); + + contentSize.height += textItem.frame.size.height; + [items addObject:textItem]; + } + if (quoteBlock.caption != nil) { + contentSize.height += 14.0f; + { + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:15.0]]; + [styleStack pushItem:[[TGInstantPageStyleTextColorItem alloc] initWithColor:UIColorRGB(0x79828B)]]; + + TGInstantPageTextItem *captionItem = [self layoutTextItemWithString:[self attributedStringForRichText:quoteBlock.caption styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset - lineInset]; + captionItem.frame = CGRectOffset(captionItem.frame, horizontalInset + lineInset, contentSize.height); + + contentSize.height += captionItem.frame.size.height; + [items addObject:captionItem]; + } + } + contentSize.height += verticalInset; + [items addObject:[[TGInstantPageShapeItem alloc] initWithFrame:CGRectMake(horizontalInset, 0.0f, 3.0f, contentSize.height) shapeFrame:CGRectMake(0.0f, 0.0f, 3.0f, contentSize.height) shape:TGInstantPageShapeRoundLine color:[UIColor blackColor]]]; + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:contentSize items:items]; + } else if ([block isKindOfClass:[TGInstantPageBlockPullQuote class]]) { + TGInstantPageBlockPullQuote *quoteBlock = (TGInstantPageBlockPullQuote *)block; + CGFloat verticalInset = 4.0f; + CGSize contentSize = CGSizeMake(boundingWidth, verticalInset); + + NSMutableArray> *items = [[NSMutableArray alloc] init]; + + { + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:17.0]]; + [styleStack pushItem:[[TGInstantPageStyleFontSerifItem alloc] initWithSerif:true]]; + [styleStack pushItem:[[TGInstantPageStyleItalicItem alloc] init]]; + + TGInstantPageTextItem *textItem = [self layoutTextItemWithString:[self attributedStringForRichText:quoteBlock.text styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset]; + textItem.frame = CGRectOffset(textItem.frame, CGFloor((boundingWidth - textItem.frame.size.width) / 2.0), contentSize.height); + textItem.alignment = NSTextAlignmentCenter; + + contentSize.height += textItem.frame.size.height; + [items addObject:textItem]; + } + contentSize.height += 14.0f; + { + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:15.0]]; + [styleStack pushItem:[[TGInstantPageStyleTextColorItem alloc] initWithColor:UIColorRGB(0x79828B)]]; + + TGInstantPageTextItem *captionItem = [self layoutTextItemWithString:[self attributedStringForRichText:quoteBlock.caption styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset]; + captionItem.frame = CGRectOffset(captionItem.frame, CGFloor((boundingWidth - captionItem.frame.size.width) / 2.0), contentSize.height); + captionItem.alignment = NSTextAlignmentCenter; + + contentSize.height += captionItem.frame.size.height; + [items addObject:captionItem]; + } + contentSize.height += verticalInset; + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:contentSize items:items]; + } else else if ([block isKindOfClass:[TGInstantPageBlockVideo class]]) { + TGInstantPageBlockVideo *videoBlock = (TGInstantPageBlockVideo *)block; + TGVideoMediaAttachment *videoMedia = videos[@(videoBlock.videoId)]; + if (videoMedia != nil) { + CGSize imageSize = [videoMedia dimensions]; + if (imageSize.width > FLT_EPSILON && imageSize.height > FLT_EPSILON) { + CGSize filledSize = TGFitSize(imageSize, CGSizeMake(boundingWidth, 1200.0)); + if (fillToWidthAndHeight) { + filledSize = CGSizeMake(boundingWidth, boundingWidth); + } else if (isCover) { + filledSize = TGScaleToFill(imageSize, CGSizeMake(boundingWidth, 1.0f)); + if (filledSize.height > FLT_EPSILON) { + filledSize = TGCropSize(filledSize, CGSizeMake(boundingWidth, CGFloor(boundingWidth * 3.0f / 5.0f))); + } + } + + NSMutableArray *items = [[NSMutableArray alloc] init]; + + NSInteger mediaIndex = *mediaIndexCounter; + (*mediaIndexCounter)++; + + CGSize contentSize = CGSizeMake(boundingWidth, 0.0f); + TGVideoMediaAttachment *videoWithCaption = [videoMedia copy]; + videoWithCaption.caption = richPlainText(videoBlock.caption); + videoWithCaption.loopVideo = videoBlock.loop; + TGInstantPageMediaItem *mediaItem = [[TGInstantPageMediaItem alloc] initWithFrame:CGRectMake(CGFloor((boundingWidth - filledSize.width) / 2.0f), 0.0f, filledSize.width, filledSize.height) media:[[TGInstantPageMedia alloc] initWithIndex:mediaIndex media:videoWithCaption] arguments:[[TGInstantPageVideoMediaArguments alloc] initWithInteractive:true autoplay:videoBlock.autoplay || videoBlock.loop]]; + [items addObject:mediaItem]; + contentSize.height += filledSize.height; + + if (videoBlock.caption != nil) { + contentSize.height += 10.0f; + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:15.0]]; + [styleStack pushItem:[[TGInstantPageStyleTextColorItem alloc] initWithColor:UIColorRGB(0x79828B)]]; + TGInstantPageTextItem *captionItem = [self layoutTextItemWithString:[self attributedStringForRichText:videoBlock.caption styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset]; + if (filledSize.width >= boundingWidth - FLT_EPSILON) { + captionItem.alignment = NSTextAlignmentCenter; + captionItem.frame = CGRectOffset(captionItem.frame, horizontalInset, contentSize.height); + } else { + captionItem.alignment = NSTextAlignmentCenter; + captionItem.frame = CGRectOffset(captionItem.frame, CGFloor((boundingWidth - captionItem.frame.size.width) / 2.0), contentSize.height); + } + contentSize.height += captionItem.frame.size.height; + [items addObject:captionItem]; + } + + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:contentSize items:items]; + } + } + } else else if ([block isKindOfClass:[TGInstantPageBlockSlideshow class]]) { + TGInstantPageBlockSlideshow *slideshowBlock = (TGInstantPageBlockSlideshow *)block; + NSMutableArray *medias = [[NSMutableArray alloc] init]; + CGSize contentSize = CGSizeMake(boundingWidth, 0.0f); + for (TGInstantPageBlock *subBlock in slideshowBlock.items) { + if ([subBlock isKindOfClass:[TGInstantPageBlockPhoto class]]) { + TGInstantPageBlockPhoto *photoBlock = (TGInstantPageBlockPhoto *)subBlock; + TGImageMediaAttachment *imageMedia = images[@(photoBlock.photoId)]; + if (imageMedia != nil) { + CGSize imageSize = CGSizeZero; + if ([imageMedia.imageInfo imageUrlForLargestSize:&imageSize] != nil) { + TGImageMediaAttachment *mediaWithCaption = [imageMedia copy]; + mediaWithCaption.caption = richPlainText(photoBlock.caption); + NSInteger mediaIndex = *mediaIndexCounter; + (*mediaIndexCounter)++; + + CGSize filledSize = TGFitSize(imageSize, CGSizeMake(boundingWidth, 1200.0f)); + contentSize.height = MAX(contentSize.height, filledSize.height); + [medias addObject:[[TGInstantPageMedia alloc] initWithIndex:mediaIndex media:imageMedia]]; + } + } + } + } + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:contentSize items:@[[[TGInstantPageSlideshowItem alloc] initWithFrame:CGRectMake(0.0f, 0.0f, boundingWidth, contentSize.height) medias:medias]]]; + } else if ([block isKindOfClass:[TGInstantPageBlockCollage class]]) { + TGInstantPageBlockCollage *collageBlock = (TGInstantPageBlockCollage *)block; + CGFloat spacing = 2.0f; + int itemsPerRow = 3; + CGFloat itemSize = (boundingWidth - spacing * MAX(0, itemsPerRow - 1)) / itemsPerRow; + + NSMutableArray *items = [[NSMutableArray alloc] init]; + + CGPoint nextItemOrigin = CGPointMake(0.0f, 0.0f); + for (TGInstantPageBlock *subBlock in collageBlock.items) { + if (nextItemOrigin.x + itemSize > boundingWidth) { + nextItemOrigin.x = 0.0f; + nextItemOrigin.y += itemSize + spacing; + } + TGInstantPageLayout *subLayout = [self layoutBlock:subBlock boundingWidth:itemSize horizontalInset:0.0f isCover:false fillToWidthAndHeight:true images:images videos:videos mediaIndexCounter:mediaIndexCounter]; + [items addObjectsFromArray:[subLayout flattenedItemsWithOrigin:nextItemOrigin]]; + nextItemOrigin.x += itemSize + spacing; + } + + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:CGSizeMake(boundingWidth, nextItemOrigin.y + itemSize) items:items]; + } else if ([block isKindOfClass:[TGInstantPageBlockEmbedPost class]]) { + TGInstantPageBlockEmbedPost *postBlock = (TGInstantPageBlockEmbedPost *)block; + + CGSize contentSize = CGSizeMake(boundingWidth, 0.0f); + CGFloat lineInset = 20.0f; + CGFloat verticalInset = 4.0f; + CGFloat itemSpacing = 10.0f; + CGFloat avatarInset = 0.0f; + CGFloat avatarVerticalInset = 0.0f; + + contentSize.height += verticalInset; + + NSMutableArray *items = [[NSMutableArray alloc] init]; + + if (postBlock.author.length != 0) { + TGImageMediaAttachment *avatar = postBlock.authorPhotoId == 0 ? nil : images[@(postBlock.authorPhotoId)]; + if (avatar != nil) { + TGInstantPageMediaItem *avatarItem = [[TGInstantPageMediaItem alloc] initWithFrame:CGRectMake(horizontalInset + lineInset + 1.0f, contentSize.height - 2.0f, 50.0f, 50.0f) media:[[TGInstantPageMedia alloc] initWithIndex:-1 media:avatar] arguments:[[TGInstantPageImageMediaArguments alloc] initWithInteractive:false roundCorners:true fit:false]]; + [items addObject:avatarItem]; + avatarInset += 62.0f; + avatarVerticalInset += 6.0f; + if (postBlock.date == 0) { + avatarVerticalInset += 11.0f; + } + } + + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:17.0]]; + [styleStack pushItem:[[TGInstantPageStyleBoldItem alloc] init]]; + + TGInstantPageTextItem *textItem = [self layoutTextItemWithString:[self attributedStringForRichText:[[TGRichTextPlain alloc] initWithText:postBlock.author] styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset - lineInset - avatarInset]; + textItem.frame = CGRectOffset(textItem.frame, horizontalInset + lineInset + avatarInset, contentSize.height + avatarVerticalInset); + + contentSize.height += textItem.frame.size.height + avatarVerticalInset; + [items addObject:textItem]; + } + if (postBlock.date != 0) { + if (items.count != 0) { + contentSize.height += itemSpacing; + } + NSString *dateString = [NSDateFormatter localizedStringFromDate:[NSDate dateWithTimeIntervalSince1970:postBlock.date] dateStyle:NSDateFormatterLongStyle timeStyle:NSDateFormatterNoStyle]; + + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:15.0]]; + [styleStack pushItem:[[TGInstantPageStyleTextColorItem alloc] initWithColor:UIColorRGB(0x838C96)]]; + TGInstantPageTextItem *textItem = [self layoutTextItemWithString:[self attributedStringForRichText:[[TGRichTextPlain alloc] initWithText:dateString] styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset - lineInset - avatarInset]; + textItem.frame = CGRectOffset(textItem.frame, horizontalInset + lineInset + avatarInset, contentSize.height); + contentSize.height += textItem.frame.size.height; + if (textItem != nil) { + [items addObject:textItem]; + } + } + + if (true) { + if (items.count != 0) { + contentSize.height += itemSpacing; + } + + TGInstantPageBlock *previousBlock = nil; + for (TGInstantPageBlock *subBlock in postBlock.blocks) { + TGInstantPageLayout *subLayout = [self layoutBlock:subBlock boundingWidth:boundingWidth - horizontalInset - horizontalInset - lineInset horizontalInset:0.0f isCover:false fillToWidthAndHeight:false images:images videos:videos mediaIndexCounter:mediaIndexCounter]; + CGFloat spacing = spacingBetweenBlocks(previousBlock, subBlock); + NSArray *blockItems = [subLayout flattenedItemsWithOrigin:CGPointMake(horizontalInset + lineInset, contentSize.height + spacing)]; + [items addObjectsFromArray:blockItems]; + contentSize.height += subLayout.contentSize.height + spacing; + previousBlock = subBlock; + } + } + + contentSize.height += verticalInset; + + [items addObject:[[TGInstantPageShapeItem alloc] initWithFrame:CGRectMake(horizontalInset, 0.0f, 3.0f, contentSize.height) shapeFrame:CGRectMake(0.0f, 0.0f, 3.0f, contentSize.height) shape:TGInstantPageShapeRoundLine color:[UIColor blackColor]]]; + + TGRichText *postCaption = postBlock.caption; + + if (postCaption != nil) { + contentSize.height += 14.0f; + TGInstantPageStyleStack *styleStack = [[TGInstantPageStyleStack alloc] init]; + [styleStack pushItem:[[TGInstantPageStyleFontSizeItem alloc] initWithSize:15.0]]; + [styleStack pushItem:[[TGInstantPageStyleTextColorItem alloc] initWithColor:UIColorRGB(0x79828B)]]; + TGInstantPageTextItem *captionItem = [self layoutTextItemWithString:[self attributedStringForRichText:postCaption styleStack:styleStack] boundingWidth:boundingWidth - horizontalInset - horizontalInset]; + captionItem.frame = CGRectOffset(captionItem.frame, horizontalInset, contentSize.height); + contentSize.height += captionItem.frame.size.height; + [items addObject:captionItem]; + } + + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:contentSize items:items]; + } else if ([block isKindOfClass:[TGInstantPageBlockAnchor class]]) { + TGInstantPageBlockAnchor *anchorBlock = (TGInstantPageBlockAnchor *)block; + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:CGSizeMake(0.0f, 0.0f) items:@[[[TGInstantPageAnchorItem alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 0.0f, 0.0f) anchor:anchorBlock.name]]]; + } + + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointMake(0.0f, 0.0f) contentSize:CGSizeMake(0.0f, 0.0f) items:@[]]; +}*/ + +func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, boundingWidth: CGFloat) -> InstantPageLayout { + var maybeLoadedContent: TelegramMediaWebpageLoadedContent? + if case let .Loaded(content) = webPage.content { + maybeLoadedContent = content + } + + guard let loadedContent = maybeLoadedContent, let instantPage = loadedContent.instantPage else { + return InstantPageLayout(origin: CGPoint(), contentSize: CGSize(), items: []) + } + + let pageBlocks = instantPage.blocks + var contentSize = CGSize(width: boundingWidth, height: 0.0) + var items: [InstantPageItem] = [] + + var media = instantPage.media + if let image = loadedContent.image, let id = image.id { + media[id] = image + } + + var mediaIndexCounter: Int = 0 + + var previousBlock: InstantPageBlock? + for block in pageBlocks { + let blockLayout = layoutInstantPageBlock(block, boundingWidth: boundingWidth, horizontalInset: 17.0, isCover: false, fillToWidthAndHeight: false, media: media, mediaIndexCounter: &mediaIndexCounter) + let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block) + let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing)) + items.append(contentsOf: blockItems) + if CGFloat(0.0).isLess(than: blockLayout.contentSize.height) { + contentSize.height += blockLayout.contentSize.height + spacing + previousBlock = block + } + } + + let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil) + contentSize.height += closingSpacing + + return InstantPageLayout(origin: CGPoint(), contentSize: contentSize, items: items) +} + +/*+ (TGInstantPageLayout *)makeLayoutForWebPage:(TGWebPageMediaAttachment *)webPage boundingWidth:(CGFloat)boundingWidth { + + NSInteger mediaIndexCounter = 0; + + TGInstantPageBlock *previousBlock = nil; + for (TGInstantPageBlock *block in pageBlocks) { + TGInstantPageLayout *blockLayout = [self layoutBlock:block boundingWidth:boundingWidth horizontalInset:17.0f isCover:false fillToWidthAndHeight:false images:images videos:webPage.instantPage.videos mediaIndexCounter:&mediaIndexCounter]; + CGFloat spacing = spacingBetweenBlocks(previousBlock, block); + NSArray *blockItems = [blockLayout flattenedItemsWithOrigin:CGPointMake(0.0f, contentSize.height + spacing)]; + [items addObjectsFromArray:blockItems]; + contentSize.height += blockLayout.contentSize.height + spacing; + previousBlock = block; + } + CGFloat closingSpacing = spacingBetweenBlocks(previousBlock, nil); + contentSize.height += closingSpacing; + + { + CGFloat height = CGCeil([TGInstantPageFooterButtonView heightForWidth:boundingWidth]); + + TGInstantPageFooterButtonItem *item = [[TGInstantPageFooterButtonItem alloc] initWithFrame:CGRectMake(0.0f, contentSize.height, boundingWidth, height)]; + [items addObject:item]; + contentSize.height += item.frame.size.height; + } + + return [[TGInstantPageLayout alloc] initWithOrigin:CGPointZero contentSize:contentSize items:items]; +}*/ + + diff --git a/TelegramUI/InstantPageLayoutSpacings.swift b/TelegramUI/InstantPageLayoutSpacings.swift new file mode 100644 index 0000000000..9b72c62046 --- /dev/null +++ b/TelegramUI/InstantPageLayoutSpacings.swift @@ -0,0 +1,58 @@ +import Foundation +import TelegramCore + +func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?) -> CGFloat { + if let upper = upper, let lower = lower { + switch (upper, lower) { + case (_, .cover): + return 0.0 + case (.divider, _), (_, .divider): + return 25.0 + case (_, .blockQuote), (.blockQuote, _), (_, .pullQuote), (.pullQuote, _): + return 27.0 + case (_, .title): + return 20.0 + case (.title, .authorDate): + return 26.0 + case (_, .authorDate): + return 20.0 + case (.title, .paragraph), (.authorDate, .paragraph): + return 34.0 + case (.header, .paragraph), (.subheader, .paragraph): + return 25.0 + case (.list, .paragraph): + return 31.0 + case (.preformatted, .paragraph): + return 19.0 + case (_, .paragraph): + return 20.0 + case (.title, .list), (.authorDate, .list): + return 34.0 + case (.header, .list), (.subheader, .list): + return 31.0 + case (.preformatted, .list): + return 19.0 + case (_, .list): + return 20.0 + case (.paragraph, .preformatted): + return 19.0 + case (_, .preformatted): + return 20.0 + case (_, .header): + return 32.0 + case (_, .subheader): + return 32.0 + default: + return 20.0 + } + } else if let lower = lower { + switch lower { + case .cover: + return 0.0 + default: + return 24.0 + } + } else { + return 24.0 + } +} diff --git a/TelegramUI/InstantPageLinkSelectionView.swift b/TelegramUI/InstantPageLinkSelectionView.swift new file mode 100644 index 0000000000..c6992bee48 --- /dev/null +++ b/TelegramUI/InstantPageLinkSelectionView.swift @@ -0,0 +1,6 @@ +import Foundation +import UIKit + +final class InstantPageLinkSelectionView: UIView { + +} diff --git a/TelegramUI/InstantPageMedia.swift b/TelegramUI/InstantPageMedia.swift new file mode 100644 index 0000000000..60698f60de --- /dev/null +++ b/TelegramUI/InstantPageMedia.swift @@ -0,0 +1,13 @@ +import Foundation +import Postbox +import TelegramCore + +struct InstantPageMedia: Equatable { + let index: Int + let media: Media + let caption: String? + + static func ==(lhs: InstantPageMedia, rhs: InstantPageMedia) -> Bool { + return lhs.index == rhs.index && lhs.media.isEqual(rhs.media) && lhs.caption == rhs.caption + } +} diff --git a/TelegramUI/InstantPageMediaItem.swift b/TelegramUI/InstantPageMediaItem.swift new file mode 100644 index 0000000000..aa24c324d3 --- /dev/null +++ b/TelegramUI/InstantPageMediaItem.swift @@ -0,0 +1,62 @@ +import Foundation +import TelegramCore + +enum InstantPageMediaArguments { + case image(interactive: Bool, roundCorners: Bool, fit: Bool) + case video(interactive: Bool, autoplay: Bool) +} + +final class InstantPageMediaItem: InstantPageItem { + var frame: CGRect + + let media: InstantPageMedia + var medias: [InstantPageMedia] { + return [self.media] + } + + let arguments: InstantPageMediaArguments + + let wantsNode: Bool = true + let hasLinks: Bool = false + + init(frame: CGRect, media: InstantPageMedia, arguments: InstantPageMediaArguments) { + self.frame = frame + self.media = media + self.arguments = arguments + } + + func node(account: Account) -> InstantPageNode? { + return InstantPageMediaNode(account: account, media: self.media, arguments: self.arguments) + } + + func matchesAnchor(_ anchor: String) -> Bool { + return false + } + + func matchesNode(_ node: InstantPageNode) -> Bool { + if let node = node as? InstantPageMediaNode { + return node.media == self.media + } else { + return false + } + } + + func distanceThresholdGroup() -> Int? { + return 1 + } + + func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat { + if count > 3 { + return 400.0 + } else { + return CGFloat.greatestFiniteMagnitude + } + } + + func drawInTile(context: CGContext) { + } + + func linkSelectionViews() -> [InstantPageLinkSelectionView] { + return [] + } +} diff --git a/TelegramUI/InstantPageMediaNode.swift b/TelegramUI/InstantPageMediaNode.swift new file mode 100644 index 0000000000..bc617dfb4f --- /dev/null +++ b/TelegramUI/InstantPageMediaNode.swift @@ -0,0 +1,110 @@ +import Foundation +import AsyncDisplayKit +import Display +import Postbox +import TelegramCore +import SwiftSignalKit + +final class InstantPageMediaNode: ASDisplayNode, InstantPageNode { + private let account: Account + let media: InstantPageMedia + private let arguments: InstantPageMediaArguments + + private let imageNode: TransformImageNode + + private var currentSize: CGSize? + + private var fetchedDisposable = MetaDisposable() + + init(account: Account, media: InstantPageMedia, arguments: InstantPageMediaArguments) { + self.account = account + self.media = media + self.arguments = arguments + + self.imageNode = TransformImageNode() + + super.init() + + self.imageNode.alphaTransitionOnFirstUpdate = true + self.addSubnode(self.imageNode) + + if let image = media.media as? TelegramMediaImage { + self.imageNode.setSignal(account: account, signal: chatMessagePhoto(account: account, photo: image)) + self.fetchedDisposable.set(chatMessagePhotoInteractiveFetched(account: account, photo: image).start()) + } + } + + deinit { + self.fetchedDisposable.dispose() + } + + func updateIsVisible(_ isVisible: Bool) { + + } + + override func layout() { + super.layout() + + let size = self.bounds.size + + if self.currentSize != size { + self.currentSize = size + + self.imageNode.frame = CGRect(origin: CGPoint(), size: size) + + if let image = self.media.media as? TelegramMediaImage, let largest = largestImageRepresentation(image.representations) { + let imageSize = largest.dimensions.aspectFilled(size) + let boundingSize = size + var radius: CGFloat = 0.0 + + switch arguments { + case let .image(_, roundCorners, fit): + radius = roundCorners ? floor(min(size.width, size.height) / 2.0) : 0.0 + default: + break + } + let makeLayout = self.imageNode.asyncLayout() + let apply = makeLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())) + apply() + } + } + } +} + +/*- (void)layoutSubviews { + [super layoutSubviews]; + + CGSize size = self.bounds.size; + _button.frame = self.bounds; + CGSize overlaySize = _overlayView.bounds.size; + _overlayView.frame = CGRectMake(CGFloor((size.width - overlaySize.width) / 2.0f), CGFloor((size.height - overlaySize.height) / 2.0f), overlaySize.width, overlaySize.height); + _imageView.frame = self.bounds; + + _videoView.frame = self.bounds; + + if (!CGSizeEqualToSize(_currentSize, size)) { + _currentSize = size; + + if ([_media.media isKindOfClass:[TGImageMediaAttachment class]]) { + TGImageMediaAttachment *image = _media.media; + CGSize imageSize = TGFillSize([image dimensions], size); + CGSize boundingSize = size; + + CGFloat radius = 0.0f; + if ([_arguments isKindOfClass:[TGInstantPageImageMediaArguments class]]) { + TGInstantPageImageMediaArguments *imageArguments = (TGInstantPageImageMediaArguments *)_arguments; + if (imageArguments.fit) { + _imageView.contentMode = UIViewContentModeScaleAspectFit; + imageSize = TGFitSize([image dimensions], size); + boundingSize = imageSize; + } + radius = imageArguments.roundCorners ? CGFloor(MIN(size.width, size.height) / 2.0f) : 0.0f; + } + [_imageView setArguments:[[TransformImageArguments alloc] initWithImageSize:imageSize boundingSize:boundingSize cornerRadius:radius]]; + } else if ([_media.media isKindOfClass:[TGVideoMediaAttachment class]]) { + TGVideoMediaAttachment *video = _media.media; + CGSize imageSize = TGFillSize([video dimensions], size); + [_imageView setArguments:[[TransformImageArguments alloc] initWithImageSize:imageSize boundingSize:size cornerRadius:0.0f]]; + } + } +}*/ diff --git a/TelegramUI/InstantPageNode.swift b/TelegramUI/InstantPageNode.swift new file mode 100644 index 0000000000..a765a32968 --- /dev/null +++ b/TelegramUI/InstantPageNode.swift @@ -0,0 +1,21 @@ +import Foundation +import AsyncDisplayKit + +protocol InstantPageNode { + func updateIsVisible(_ isVisible: Bool) +} + +/*@class TGInstantPageMedia; + +@protocol TGInstantPageDisplayView + +- (void)setIsVisible:(bool)isVisible; + +@optional + +- (void)setOpenMedia:(void (^)(id))openMedia; +- (void)setOpenFeedback:(void (^)())openFeedback; +- (UIView *)transitionViewForMedia:(TGInstantPageMedia *)media; +- (void)updateHiddenMedia:(TGInstantPageMedia *)media; + +@end*/ diff --git a/TelegramUI/InstantPageShapeItem.swift b/TelegramUI/InstantPageShapeItem.swift new file mode 100644 index 0000000000..472534a801 --- /dev/null +++ b/TelegramUI/InstantPageShapeItem.swift @@ -0,0 +1,73 @@ +import Foundation +import TelegramCore + +enum InstantPageShape { + case rect + case ellipse + case roundLine +} + +final class InstantPageShapeItem: InstantPageItem { + var frame: CGRect + let shapeFrame: CGRect + let shape: InstantPageShape + let color: UIColor + + let medias: [InstantPageMedia] = [] + let wantsNode: Bool = false + let hasLinks: Bool = false + + init(frame: CGRect, shapeFrame: CGRect, shape: InstantPageShape, color: UIColor) { + self.frame = frame + self.shapeFrame = shapeFrame + self.shape = shape + self.color = color + } + + func drawInTile(context: CGContext) { + context.setFillColor(self.color.cgColor) + + switch self.shape { + case .rect: + context.fill(self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY)) + case .ellipse: + context.fillEllipse(in: self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY)) + case .roundLine: + if self.shapeFrame.size.width < self.shapeFrame.size.height { + let radius = self.shapeFrame.size.width / 2.0 + var shapeFrame = self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY) + shapeFrame.origin.y += radius + shapeFrame.size.height -= radius + radius + context.fill(shapeFrame) + context.fillEllipse(in: CGRect(x: shapeFrame.minX, y: shapeFrame.minY - radius, width: radius + radius, height: radius + radius)) + context.fillEllipse(in: CGRect(x: shapeFrame.minX, y: shapeFrame.maxY - radius, width: radius + radius, height: radius + radius)) + } else { + context.fill(self.shapeFrame.offsetBy(dx: self.frame.minX, dy: self.frame.minY)) + } + } + } + + func matchesAnchor(_ anchor: String) -> Bool { + return false + } + + func matchesNode(_ node: InstantPageNode) -> Bool { + return false + } + + func node(account: Account) -> InstantPageNode? { + return nil + } + + func linkSelectionViews() -> [InstantPageLinkSelectionView] { + return [] + } + + func distanceThresholdGroup() -> Int? { + return nil + } + + func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat { + return 0.0 + } +} diff --git a/TelegramUI/InstantPageTextItem.swift b/TelegramUI/InstantPageTextItem.swift new file mode 100644 index 0000000000..5c12cccafc --- /dev/null +++ b/TelegramUI/InstantPageTextItem.swift @@ -0,0 +1,320 @@ +import Foundation +import TelegramCore + +struct InstantPageTextUrlItem { + let frame: CGRect + let item: AnyObject +} + +struct InstantPageTextStrikethroughItem { + let frame: CGRect +} + +final class InstantPageTextLine { + let line: CTLine + let frame: CGRect + let urlItems: [InstantPageTextUrlItem] + let strikethroughItems: [InstantPageTextStrikethroughItem] + + init(line: CTLine, frame: CGRect, urlItems: [InstantPageTextUrlItem], strikethroughItems: [InstantPageTextStrikethroughItem]) { + self.line = line + self.frame = frame + self.urlItems = urlItems + self.strikethroughItems = strikethroughItems + } +} + +final class InstantPageTextItem: InstantPageItem { + let lines: [InstantPageTextLine] + let hasLinks: Bool + var frame: CGRect + var alignment: NSTextAlignment = .left + let medias: [InstantPageMedia] = [] + let wantsNode: Bool = false + + init(frame: CGRect, lines: [InstantPageTextLine]) { + self.frame = frame + self.lines = lines + var hasLinks = false + for line in lines { + if !line.urlItems.isEmpty { + hasLinks = true + } + } + self.hasLinks = hasLinks + } + + func drawInTile(context: CGContext) { + context.saveGState() + context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0) + context.translateBy(x: self.frame.minX, y: self.frame.minY) + + let clipRect = context.boundingBoxOfClipPath + + let upperOriginBound = clipRect.minY - 10.0 + let lowerOriginBound = clipRect.maxY + 10.0 + let boundsWidth = self.frame.size.width + + for line in self.lines { + let lineFrame = line.frame + if lineFrame.maxY < upperOriginBound || lineFrame.minY > lowerOriginBound { + continue + } + + var lineOrigin = lineFrame.origin + if self.alignment == .center { + lineOrigin.x = floor((boundsWidth - lineFrame.size.width) / 2.0) + } + + context.textPosition = CGPoint(x: lineOrigin.x, y: lineOrigin.y + lineFrame.size.height) + CTLineDraw(line.line, context) + + if !line.strikethroughItems.isEmpty { + for item in line.strikethroughItems { + context.fill(CGRect(x: item.frame.minX, y: item.frame.minY + floor((lineFrame.size.height / 2.0) + 1.0), width: item.frame.size.width, height: 1.0)) + } + } + } + + context.restoreGState() + } + + func linkSelectionViews() -> [InstantPageLinkSelectionView] { + return [] + } + + func matchesAnchor(_ anchor: String) -> Bool { + return false + } + + func node(account: Account) -> InstantPageNode? { + return nil + } + + func matchesNode(_ node: InstantPageNode) -> Bool { + return false + } + + func distanceThresholdGroup() -> Int? { + return nil + } + + func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat { + return 0.0 + } +} + +/* + + +static TGInstantPageLinkSelectionView *selectionViewFromFrames(NSArray *frames, CGPoint origin, id urlItem) { + CGRect frame = CGRectMake(0.0f, 0.0f, 0.0f, 0.0f); + bool first = true; + for (NSValue *rectValue in frames) { + CGRect rect = [rectValue CGRectValue]; + if (first) { + first = false; + frame = rect; + } else { + frame = CGRectUnion(rect, frame); + } + } + NSMutableArray *adjustedFrames = [[NSMutableArray alloc] init]; + for (NSValue *rectValue in frames) { + CGRect rect = [rectValue CGRectValue]; + rect.origin.x -= frame.origin.x; + rect.origin.y -= frame.origin.y; + [adjustedFrames addObject:[NSValue valueWithCGRect:rect]]; + } + return [[TGInstantPageLinkSelectionView alloc] initWithFrame:CGRectOffset(frame, origin.x, origin.y) rects:adjustedFrames urlItem:urlItem]; + } + + - (NSArray *)linkSelectionViews { + if (_hasLinks) { + NSMutableArray *views = [[NSMutableArray alloc] init]; + NSMutableArray *currentLinkFrames = [[NSMutableArray alloc] init]; + id currentUrlItem = nil; + for (TGInstantPageTextLine *line in _lines) { + if (line.urlItems != nil) { + for (TGInstantPageTextUrlItem *urlItem in line.urlItems) { + if (currentUrlItem == urlItem.item) { + } else { + if (currentLinkFrames.count != 0) { + [views addObject:selectionViewFromFrames(currentLinkFrames, self.frame.origin, currentUrlItem)]; + } + [currentLinkFrames removeAllObjects]; + currentUrlItem = urlItem.item; + } + CGPoint lineOrigin = line.frame.origin; + if (_alignment == NSTextAlignmentCenter) { + lineOrigin.x = CGFloor((self.frame.size.width - line.frame.size.width) / 2.0f); + } + [currentLinkFrames addObject:[NSValue valueWithCGRect:CGRectOffset(urlItem.frame, lineOrigin.x, 0.0)]]; + } + } else if (currentUrlItem != nil) { + if (currentLinkFrames.count != 0) { + [views addObject:selectionViewFromFrames(currentLinkFrames, self.frame.origin, currentUrlItem)]; + } + [currentLinkFrames removeAllObjects]; + currentUrlItem = nil; + } + } + if (currentLinkFrames.count != 0 && currentUrlItem != nil) { + [views addObject:selectionViewFromFrames(currentLinkFrames, self.frame.origin, currentUrlItem)]; + } + return views; + } + return nil; +} + +@end*/ + +func attributedStringForRichText(_ text: RichText, styleStack: InstantPageTextStyleStack) -> NSAttributedString { + switch text { + case .empty: + return NSAttributedString(string: "", attributes: styleStack.textAttributes()) + case let .plain(string): + return NSAttributedString(string: string, attributes: styleStack.textAttributes()) + case let .bold(text): + styleStack.push(.bold) + let result = attributedStringForRichText(text, styleStack: styleStack) + styleStack.pop() + return result + case let .italic(text): + styleStack.push(.italic) + let result = attributedStringForRichText(text, styleStack: styleStack) + styleStack.pop() + return result + case let .underline(text): + styleStack.push(.underline) + let result = attributedStringForRichText(text, styleStack: styleStack) + styleStack.pop() + return result + case let .strikethrough(text): + styleStack.push(.strikethrough) + let result = attributedStringForRichText(text, styleStack: styleStack) + styleStack.pop() + return result + case let .fixed(text): + styleStack.push(.fontFixed(true)) + let result = attributedStringForRichText(text, styleStack: styleStack) + styleStack.pop() + return result + case let .url(text, url, _): + styleStack.push(.textColor(UIColor(0x007BE8))) + let result = attributedStringForRichText(text, styleStack: styleStack) + styleStack.pop() + styleStack.pop() + return result + case let .email(text, _): + styleStack.push(.bold) + styleStack.push(.textColor(UIColor(0x007BE8))) + let result = attributedStringForRichText(text, styleStack: styleStack) + styleStack.pop() + styleStack.pop() + return result + case let .concat(texts): + let string = NSMutableAttributedString() + for text in texts { + let substring = attributedStringForRichText(text, styleStack: styleStack) + string.append(substring) + } + return string + } +} + +func layoutTextItemWithString(_ string: NSAttributedString, boundingWidth: CGFloat) -> InstantPageTextItem { + if string.length == 0 { + return InstantPageTextItem(frame: CGRect(), lines: []) + } + + var lines: [InstantPageTextLine] = [] + guard let font = string.attribute(NSFontAttributeName, at: 0, effectiveRange: nil) as? UIFont else { + return InstantPageTextItem(frame: CGRect(), lines: []) + } + + var lineSpacingFactor: CGFloat = 1.12 + if let lineSpacingFactorAttribute = string.attribute(InstantPageLineSpacingFactorAttribute, at: 0, effectiveRange: nil) { + lineSpacingFactor = CGFloat((lineSpacingFactorAttribute as! NSNumber).floatValue) + } + + let typesetter = CTTypesetterCreateWithAttributedString(string) + let fontAscent = font.ascender + let fontDescent = font.descender + + let fontLineHeight = floor(fontAscent + fontDescent) + let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) + + var lastIndex: CFIndex = 0 + var currentLineOrigin = CGPoint() + + while true { + let currentMaxWidth = boundingWidth - currentLineOrigin.x + let currentLineInset: CGFloat = 0.0 + let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastIndex, Double(currentMaxWidth)) + + if lineCharacterCount > 0 { + let line = CTTypesetterCreateLineWithOffset(typesetter, CFRangeMake(lastIndex, lineCharacterCount), 100.0) + + if line != nil { + let trailingWhitespace = CGFloat(CTLineGetTrailingWhitespaceWidth(line)) + let lineWidth = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil) + Double(currentLineInset)) + + var urlItems: [InstantPageTextUrlItem] = [] + var strikethroughItems: [InstantPageTextStrikethroughItem] = [] + + string.enumerateAttribute(NSStrikethroughStyleAttributeName, in: NSMakeRange(lastIndex, lineCharacterCount), options: [], using: { item, range, _ in + if let item = item { + let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil)) + let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil)) + + strikethroughItems.append(InstantPageTextStrikethroughItem(frame: CGRect(x: currentLineOrigin.x + lowerX, y: currentLineOrigin.y, width: upperX - lowerX, height: fontLineHeight))) + } + }) + + /*__block NSMutableArray *urlItems = nil; + [string enumerateAttribute:(NSString *)TGUrlAttribute inRange:NSMakeRange(lastIndex, lineCharacterCount) options:0 usingBlock:^(id item, NSRange range, __unused BOOL *stop) { + if (item != nil) { + if (urlItems == nil) { + urlItems = [[NSMutableArray alloc] init]; + } + CGFloat lowerX = CGFloor(CTLineGetOffsetForStringIndex(line, range.location, NULL)); + CGFloat upperX = CGCeil(CTLineGetOffsetForStringIndex(line, range.location + range.length, NULL)); + [urlItems addObject:[[TGInstantPageTextUrlItem alloc] initWithFrame:CGRectMake(currentLineOrigin.x + lowerX, currentLineOrigin.y, upperX - lowerX, fontLineHeight) item:item]]; + } + }];*/ + + let textLine = InstantPageTextLine(line: line, frame: CGRect(x: currentLineOrigin.x, y: currentLineOrigin.y, width: lineWidth, height: fontLineHeight), urlItems: urlItems, strikethroughItems: strikethroughItems) + + lines.append(textLine) + + var rightAligned = false + + /*let glyphRuns = CTLineGetGlyphRuns(line) + if CFArrayGetCount(glyphRuns) != 0 { + if (CTRunGetStatus(CFArrayGetValueAtIndex(glyphRuns, 0) as! CTRun).rawValue & CTRunStatus.rightToLeft.rawValue) != 0 { + rightAligned = true + } + }*/ + + //hadRTL |= rightAligned; + + currentLineOrigin.x = 0.0; + currentLineOrigin.y += fontLineHeight + fontLineSpacing + + lastIndex += lineCharacterCount + } else { + break; + } + } else { + break; + } + } + + var height: CGFloat = 0.0 + if !lines.isEmpty { + height = lines.last!.frame.maxY + } + + return InstantPageTextItem(frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: height), lines: lines) +} diff --git a/TelegramUI/InstantPageTextStyleStack.swift b/TelegramUI/InstantPageTextStyleStack.swift new file mode 100644 index 0000000000..67a534a034 --- /dev/null +++ b/TelegramUI/InstantPageTextStyleStack.swift @@ -0,0 +1,147 @@ +import Foundation +import TelegramCore +import Display + +enum InstantPageTextStyle { + case fontSize(CGFloat) + case lineSpacingFactor(CGFloat) + case fontSerif(Bool) + case fontFixed(Bool) + case bold + case italic + case underline + case strikethrough + case textColor(UIColor) +} + +let InstantPageLineSpacingFactorAttribute = "LineSpacingFactorAttribute" + +final class InstantPageTextStyleStack { + private var items: [InstantPageTextStyle] = [] + + func push(_ item: InstantPageTextStyle) { + items.append(item) + } + + func pop() { + if !items.isEmpty { + items.removeLast() + } + } + + func textAttributes() -> [String: Any] { + var fontSize: CGFloat? + var fontSerif: Bool? + var fontFixed: Bool? + var bold: Bool? + var italic: Bool? + var strikethrough: Bool? + var underline: Bool? + var color: UIColor? + var lineSpacingFactor: CGFloat? + + for item in self.items.reversed() { + switch item { + case let .fontSize(value): + if fontSize == nil { + fontSize = value + } + case let .fontSerif(value): + if fontSerif == nil { + fontSerif = value + } + case let .fontFixed(value): + if fontFixed == nil { + fontFixed = value + } + case .bold: + if bold == nil { + bold = true + } + case .italic: + if italic == nil { + italic = true + } + case .strikethrough: + if strikethrough == nil { + strikethrough = true + } + case .underline: + if underline == nil { + underline = true + } + case let .textColor(value): + if color == nil { + color = value + } + case let .lineSpacingFactor(value): + if lineSpacingFactor == nil { + lineSpacingFactor = value + } + } + } + + var attributes: [String: Any] = [:] + + var parsedFontSize: CGFloat + if let fontSize = fontSize { + parsedFontSize = fontSize + } else { + parsedFontSize = 16.0 + } + + if (bold != nil && bold!) && (italic != nil && italic!) { + if fontSerif != nil && fontSerif! { + attributes[NSFontAttributeName] = UIFont(name: "Georgia-BoldItalic", size: parsedFontSize) + } else if fontFixed != nil && fontFixed! { + attributes[NSFontAttributeName] = UIFont(name: "Menlo-BoldItalic", size: parsedFontSize) + } else { + attributes[NSFontAttributeName] = Font.bold(parsedFontSize) + } + } else if bold != nil && bold! { + if fontSerif != nil && fontSerif! { + attributes[NSFontAttributeName] = UIFont(name: "Georgia-Bold", size: parsedFontSize) + } else if fontFixed != nil && fontFixed! { + attributes[NSFontAttributeName] = UIFont(name: "Menlo-Bold", size: parsedFontSize) + } else { + attributes[NSFontAttributeName] = Font.bold(parsedFontSize) + } + } else if italic != nil && italic! { + if fontSerif != nil && fontSerif! { + attributes[NSFontAttributeName] = UIFont(name: "Georgia-Italic", size: parsedFontSize) + } else if fontFixed != nil && fontFixed! { + attributes[NSFontAttributeName] = UIFont(name: "Menlo-Italic", size: parsedFontSize) + } else { + attributes[NSFontAttributeName] = Font.italic(parsedFontSize) + } + } else { + if fontSerif != nil && fontSerif! { + attributes[NSFontAttributeName] = UIFont(name: "Georgia", size: parsedFontSize) + } else if fontFixed != nil && fontFixed! { + attributes[NSFontAttributeName] = UIFont(name: "Menlo", size: parsedFontSize) + } else { + attributes[NSFontAttributeName] = Font.regular(parsedFontSize) + } + } + + if strikethrough != nil && strikethrough! { + attributes[NSStrikethroughStyleAttributeName] = (NSUnderlineStyle.styleSingle.rawValue | NSUnderlineStyle.patternSolid.rawValue) as NSNumber + } + + if underline != nil && underline! { + attributes[NSUnderlineStyleAttributeName] = NSUnderlineStyle.styleSingle.rawValue as NSNumber + } + + if let color = color { + attributes[NSForegroundColorAttributeName] = color + } else { + attributes[NSForegroundColorAttributeName] = UIColor.black + } + + if let lineSpacingFactor = lineSpacingFactor { + attributes[InstantPageLineSpacingFactorAttribute] = lineSpacingFactor as NSNumber + } + + return attributes + } +} diff --git a/TelegramUI/InstantPageTile.swift b/TelegramUI/InstantPageTile.swift new file mode 100644 index 0000000000..f27a6d5aa3 --- /dev/null +++ b/TelegramUI/InstantPageTile.swift @@ -0,0 +1,44 @@ +import Foundation + +final class InstantPageTile { + let frame: CGRect + var items: [InstantPageItem] = [] + + init(frame: CGRect) { + self.frame = frame + } + + func draw(context: CGContext) { + context.translateBy(x: -self.frame.minX, y: -self.frame.minY) + for item in self.items { + item.drawInTile(context: context) + } + context.translateBy(x: self.frame.minX, y: self.frame.minY) + } +} + +func instantPageTilesFromLayout(_ layout: InstantPageLayout, boundingWidth: CGFloat) -> [InstantPageTile] { + var tileByOrigin: [Int: InstantPageTile] = [:] + let tileHeight: CGFloat = 256.0 + + for item in layout.items { + if !item.wantsNode { + let topTileIndex = max(0, Int(floor(item.frame.minY - 10.0) / tileHeight)) + let bottomTileIndex = max(topTileIndex, Int(floor(item.frame.maxY + 10.0) / tileHeight)) + for i in topTileIndex ... bottomTileIndex { + let tile: InstantPageTile + if let current = tileByOrigin[i] { + tile = current + } else { + tile = InstantPageTile(frame: CGRect(x: 0.0, y: CGFloat(i) * tileHeight, width: boundingWidth, height: tileHeight)) + tileByOrigin[i] = tile + } + tile.items.append(item) + } + } + } + + return tileByOrigin.values.sorted(by: { lhs, rhs in + return lhs.frame.minY < rhs.frame.minY + }) +} diff --git a/TelegramUI/InstantPageTileNode.swift b/TelegramUI/InstantPageTileNode.swift new file mode 100644 index 0000000000..3b3ca9c742 --- /dev/null +++ b/TelegramUI/InstantPageTileNode.swift @@ -0,0 +1,45 @@ +import Foundation +import AsyncDisplayKit + +private final class InstantPageTileNodeParameters: NSObject { + let tile: InstantPageTile + + init(tile: InstantPageTile) { + self.tile = tile + + super.init() + } +} + +final class InstantPageTileNode: ASDisplayNode { + private let tile: InstantPageTile + + init(tile: InstantPageTile) { + self.tile = tile + + super.init() + + self.isLayerBacked = true + self.isOpaque = true + self.backgroundColor = UIColor.white + } + + override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { + return InstantPageTileNodeParameters(tile: self.tile) + } + + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { + + let context = UIGraphicsGetCurrentContext()! + + if !isRasterizing { + context.setBlendMode(.copy) + context.setFillColor(UIColor.white.cgColor) + context.fill(bounds) + } + + if let parameters = parameters as? InstantPageTileNodeParameters { + parameters.tile.draw(context: context) + } + } +} diff --git a/TelegramUI/InstantPageWebEmbedItem.swift b/TelegramUI/InstantPageWebEmbedItem.swift new file mode 100644 index 0000000000..d942b186f2 --- /dev/null +++ b/TelegramUI/InstantPageWebEmbedItem.swift @@ -0,0 +1,55 @@ +import Foundation +import TelegramCore + +final class InstantPageWebEmbedItem: InstantPageItem { + var frame: CGRect + let hasLinks: Bool = false + let wantsNode: Bool = true + let medias: [InstantPageMedia] = [] + + let url: String? + let html: String? + let enableScrolling: Bool + + init(frame: CGRect, url: String?, html: String?, enableScrolling: Bool) { + self.frame = frame + self.url = url + self.html = html + self.enableScrolling = enableScrolling + } + + func node(account: Account) -> InstantPageNode? { + return instantPageWebEmbedNode(frame: self.frame, url: self.url, html: self.html, enableScrolling: self.enableScrolling) + } + + func matchesAnchor(_ anchor: String) -> Bool { + return false + } + + func matchesNode(_ node: InstantPageNode) -> Bool { + if let node = node as? instantPageWebEmbedNode { + return self.url == node.url && self.html == node.html + } else { + return false + } + } + + func distanceThresholdGroup() -> Int? { + return 3 + } + + func distanceThresholdWithGroupCount(_ count: Int) -> CGFloat { + if count > 3 { + return 1000.0 + } else { + return CGFloat.greatestFiniteMagnitude + } + } + + func linkSelectionViews() -> [InstantPageLinkSelectionView] { + return [] + } + + func drawInTile(context: CGContext) { + } +} diff --git a/TelegramUI/InstantPageWebEmbedNode.swift b/TelegramUI/InstantPageWebEmbedNode.swift new file mode 100644 index 0000000000..1e93ea2e35 --- /dev/null +++ b/TelegramUI/InstantPageWebEmbedNode.swift @@ -0,0 +1,38 @@ +import Foundation +import TelegramCore +import WebKit +import AsyncDisplayKit + +final class instantPageWebEmbedNode: ASDisplayNode, InstantPageNode { + let url: String? + let html: String? + + private let webView: WKWebView + + init(frame: CGRect, url: String?, html: String?, enableScrolling: Bool) { + self.url = url + self.html = html + + self.webView = WKWebView(frame: CGRect(origin: CGPoint(), size: frame.size)) + + super.init() + + if let html = html { + self.webView.loadHTMLString(html, baseURL: nil) + } else if let url = url, let parsedUrl = URL(string: url) { + var request = URLRequest(url: parsedUrl) + let referrer = "\(parsedUrl.scheme)://\(parsedUrl.host)" + request.setValue(referrer, forHTTPHeaderField: "Referer") + self.webView.load(request) + } + } + + override func layout() { + super.layout() + + self.webView.frame = self.bounds + } + + func updateIsVisible(_ isVisible: Bool) { + } +} diff --git a/TelegramUI/ListMessageFileItemNode.swift b/TelegramUI/ListMessageFileItemNode.swift index 7c6255877a..746cda15c1 100644 --- a/TelegramUI/ListMessageFileItemNode.swift +++ b/TelegramUI/ListMessageFileItemNode.swift @@ -138,11 +138,16 @@ final class ListMessageFileItemNode: ListMessageNode { private let statusDisposable = MetaDisposable() private let fetchControls = Atomic(value: nil) - private var fetchStatus: MediaResourceStatus? + private var resourceStatus: FileMediaResourceStatus? private let fetchDisposable = MetaDisposable() private var downloadStatusIconNode: ASImageNode - private var progressNode: ASDisplayNode + private var linearProgressNode: ASDisplayNode + + private let progressNode: RadialProgressNode + + private var account: Account? + private (set) var message: Message? public required init() { self.separatorNode = ASDisplayNode() @@ -176,10 +181,13 @@ final class ListMessageFileItemNode: ListMessageNode { self.downloadStatusIconNode.displaysAsynchronously = false self.downloadStatusIconNode.displayWithoutProcessing = true - self.progressNode = ASDisplayNode() - self.progressNode.backgroundColor = UIColor(0x007ee5) + self.progressNode = RadialProgressNode(theme: RadialProgressTheme(backgroundColor: UIColor(0x007ee5), foregroundColor: UIColor.white, icon: nil)) self.progressNode.isLayerBacked = true + self.linearProgressNode = ASDisplayNode() + self.linearProgressNode.backgroundColor = UIColor(0x007ee5) + self.linearProgressNode.isLayerBacked = true + super.init() self.addSubnode(self.separatorNode) @@ -228,53 +236,79 @@ final class ListMessageFileItemNode: ListMessageNode { let iconImageLayout = self.iconImageNode.asyncLayout() let currentMedia = self.currentMedia + let currentMessage = self.message let currentIconImageRepresentation = self.currentIconImageRepresentation - return { [weak self] item, width, _, _, _ in + return { [weak self] item, width, mergedTop, _, _ in let leftInset: CGFloat = 65.0 var extensionIconImage: UIImage? - var title: NSAttributedString? + var titleText: NSAttributedString? var descriptionText: NSAttributedString? var extensionText: NSAttributedString? var iconImageRepresentation: TelegramMediaImageRepresentation? var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? - var updatedStatusSignal: Signal? + var updatedStatusSignal: Signal? var updatedFetchControls: FetchControls? + var isAudio = false + + let message = item.message + var selectedMedia: TelegramMediaFile? - for media in item.message.media { + for media in message.media { if let file = media as? TelegramMediaFile { selectedMedia = file - let fileName: String = file.fileName ?? "" - title = NSAttributedString(string: fileName, font: titleFont, textColor: UIColor.black) - - var fileExtension: String? - if let range = fileName.range(of: ".", options: [.backwards]) { - fileExtension = fileName.substring(from: range.upperBound).lowercased() - } - extensionIconImage = extensionImage(fileExtension: fileExtension) - if let fileExtension = fileExtension { - extensionText = NSAttributedString(string: fileExtension, font: extensionFont, textColor: UIColor.white) + for attribute in file.attributes { + if case let .Audio(voice, duration, title, performer, waveform) = attribute { + isAudio = true + + titleText = NSAttributedString(string: title ?? "Unknown Track", font: titleFont, textColor: UIColor.black) + + let descriptionString: String + if let performer = performer { + descriptionString = performer + } else if let size = file.size { + descriptionString = dataSizeString(size) + } else { + descriptionString = "" + } + + descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: UIColor(0xa8a8a8)) + } } - iconImageRepresentation = smallestImageRepresentation(file.previewRepresentations) + if !isAudio { + let fileName: String = file.fileName ?? "" + titleText = NSAttributedString(string: fileName, font: titleFont, textColor: UIColor.black) + + var fileExtension: String? + if let range = fileName.range(of: ".", options: [.backwards]) { + fileExtension = fileName.substring(from: range.upperBound).lowercased() + } + extensionIconImage = extensionImage(fileExtension: fileExtension) + if let fileExtension = fileExtension { + extensionText = NSAttributedString(string: fileExtension, font: extensionFont, textColor: UIColor.white) + } + + iconImageRepresentation = smallestImageRepresentation(file.previewRepresentations) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d, yyyy 'at' h a" + + let dateString = dateFormatter.string(from: Date(timeIntervalSince1970: Double(item.message.timestamp))) + + let descriptionString: String + if let size = file.size { + descriptionString = "\(dataSizeString(size)) • \(dateString)" + } else { + descriptionString = "\(dateString)" + } - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MMM d, yyyy 'at' h a" - - let dateString = dateFormatter.string(from: Date(timeIntervalSince1970: Double(item.message.timestamp))) - - let descriptionString: String - if let size = file.size { - descriptionString = "\(dataSizeString(size)) • \(dateString)" - } else { - descriptionString = "\(dateString)" + descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: UIColor(0xa8a8a8)) } - - descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: UIColor(0xa8a8a8)) break } @@ -291,19 +325,42 @@ final class ListMessageFileItemNode: ListMessageNode { mediaUpdated = selectedMedia != nil } - if let selectedMedia = selectedMedia, mediaUpdated { - let account = item.account - updatedStatusSignal = chatMessageFileStatus(account: account, file: selectedMedia) - updatedFetchControls = FetchControls(fetch: { [weak self] in - if let strongSelf = self { - strongSelf.fetchDisposable.set(chatMessageFileInteractiveFetched(account: account, file: selectedMedia).start()) - } - }, cancel: { - chatMessageFileCancelInteractiveFetch(account: account, file: selectedMedia) - }) + var statusUpdated = mediaUpdated + if currentMessage?.id != message.id || currentMessage?.flags != message.flags { + statusUpdated = true } - let (titleNodeLayout, titleNodeApply) = titleNodeMakeLayout(title, nil, 1, .middle, CGSize(width: width - leftInset - 8.0, height: CGFloat.infinity), nil) + if let selectedMedia = selectedMedia { + if mediaUpdated { + let account = item.account + updatedFetchControls = FetchControls(fetch: { [weak self] in + if let strongSelf = self { + strongSelf.fetchDisposable.set(chatMessageFileInteractiveFetched(account: account, file: selectedMedia).start()) + } + }, cancel: { + chatMessageFileCancelInteractiveFetch(account: account, file: selectedMedia) + }) + } + + if statusUpdated { + updatedStatusSignal = fileMediaResourceStatus(account: item.account, file: selectedMedia, message: message) + + if isAudio { + if let currentUpdatedStatusSignal = updatedStatusSignal { + updatedStatusSignal = currentUpdatedStatusSignal |> map { status in + switch status { + case .fetchStatus: + return .fetchStatus(.Local) + case .playbackStatus: + return status + } + } + } + } + } + } + + let (titleNodeLayout, titleNodeApply) = titleNodeMakeLayout(titleText, nil, 1, .middle, CGSize(width: width - leftInset - 8.0, height: CGFloat.infinity), nil) let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(descriptionText, nil, 1, .end, CGSize(width: width - leftInset - 8.0 - 12.0, height: CGFloat.infinity), nil) @@ -326,21 +383,42 @@ final class ListMessageFileItemNode: ListMessageNode { } } - return (ListViewItemNodeLayout(contentSize: CGSize(width: width, height: 52.0), insets: UIEdgeInsets()), { _ in + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: isAudio ? 54.0 : 52.0), insets: UIEdgeInsets(top: mergedTop ? 0.0 : 2.0, left: 0.0, bottom: 0.0, right: 0.0)) + + return (nodeLayout, { _ in if let strongSelf = self { - strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 52.0 - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) - strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel), size: CGSize(width: width, height: 52.0 + UIScreenPixel)) + strongSelf.currentMedia = selectedMedia + strongSelf.message = message + strongSelf.account = item.account + + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: leftInset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: width - leftInset, height: UIScreenPixel)) + strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel - nodeLayout.insets.top), size: CGSize(width: width, height: nodeLayout.size.height + UIScreenPixel)) + + if isAudio { + if strongSelf.progressNode.supernode == nil { + strongSelf.addSubnode(strongSelf.progressNode) + strongSelf.progressNode.state = .Play + } + strongSelf.progressNode.frame = CGRect(origin: CGPoint(x: 10.0, y: 6.0), size: CGSize(width: 42.0, height: 42.0)) + } else if strongSelf.progressNode.supernode != nil { + strongSelf.progressNode.removeFromSupernode() + } strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: leftInset, y: 9.0), size: titleNodeLayout.size) let _ = titleNodeApply() var descriptionOffset: CGFloat = 0.0 - if let fetchStatus = strongSelf.fetchStatus { - switch fetchStatus { - case .Remote, .Fetching: - descriptionOffset = 14.0 - case .Local: + if let resourceStatus = strongSelf.resourceStatus { + switch resourceStatus { + case .playbackStatus: break + case let .fetchStatus(fetchStatus): + switch fetchStatus { + case .Remote, .Fetching: + descriptionOffset = 14.0 + case .Local: + break + } } } @@ -389,9 +467,38 @@ final class ListMessageFileItemNode: ListMessageNode { strongSelf.statusDisposable.set((updatedStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in displayLinkDispatcher.dispatch { if let strongSelf = strongSelf { - strongSelf.fetchStatus = status + strongSelf.resourceStatus = status - strongSelf.updateProgressFrame(size: strongSelf.bounds.size) + if !isAudio { + strongSelf.updateProgressFrame(size: strongSelf.bounds.size) + } else { + switch status { + case let .fetchStatus(fetchStatus): + switch fetchStatus { + case let .Fetching(progress): + strongSelf.progressNode.state = .Fetching(progress: progress) + case .Local: + if isAudio { + strongSelf.progressNode.state = .Play + } else { + strongSelf.progressNode.state = .Icon + } + case .Remote: + if isAudio { + strongSelf.progressNode.state = .Play + } else { + strongSelf.progressNode.state = .Remote + } + } + case let .playbackStatus(playbackStatus): + switch playbackStatus { + case .playing: + strongSelf.progressNode.state = .Pause + case .paused: + strongSelf.progressNode.state = .Play + } + } + } } } })) @@ -455,38 +562,45 @@ final class ListMessageFileItemNode: ListMessageNode { private func updateProgressFrame(size: CGSize) { var descriptionOffset: CGFloat = 0.0 - if let fetchStatus = self.fetchStatus { - switch fetchStatus { - case .Remote, .Fetching: - descriptionOffset = 14.0 - case .Local: + if let resourceStatus = self.resourceStatus { + var maybeFetchStatus: MediaResourceStatus = .Local + switch resourceStatus { + case .playbackStatus: break + case let .fetchStatus(fetchStatus): + maybeFetchStatus = fetchStatus + switch fetchStatus { + case .Remote, .Fetching: + descriptionOffset = 14.0 + case .Local: + break + } } - switch fetchStatus { + switch maybeFetchStatus { case let .Fetching(progress): let progressFrame = CGRect(x: 65.0, y: size.height - 2.0, width: floor((size.width - 65.0) * CGFloat(progress)), height: 2.0) - if self.progressNode.supernode == nil { - self.addSubnode(self.progressNode) + if self.linearProgressNode.supernode == nil { + self.addSubnode(self.linearProgressNode) } - if !self.progressNode.frame.equalTo(progressFrame) { - self.progressNode.frame = progressFrame + if !self.linearProgressNode.frame.equalTo(progressFrame) { + self.linearProgressNode.frame = progressFrame } if self.downloadStatusIconNode.supernode == nil { self.addSubnode(self.downloadStatusIconNode) } self.downloadStatusIconNode.image = downloadFilePauseIcon case .Local: - if self.progressNode.supernode != nil { - self.progressNode.removeFromSupernode() + if self.linearProgressNode.supernode != nil { + self.linearProgressNode.removeFromSupernode() } if self.downloadStatusIconNode.supernode != nil { self.downloadStatusIconNode.removeFromSupernode() } self.downloadStatusIconNode.image = nil case .Remote: - if self.progressNode.supernode != nil { - self.progressNode.removeFromSupernode() + if self.linearProgressNode.supernode != nil { + self.linearProgressNode.removeFromSupernode() } if self.downloadStatusIconNode.supernode == nil { self.addSubnode(self.downloadStatusIconNode) @@ -494,8 +608,8 @@ final class ListMessageFileItemNode: ListMessageNode { self.downloadStatusIconNode.image = downloadFileStartIcon } } else { - if self.progressNode.supernode != nil { - self.progressNode.removeFromSupernode() + if self.linearProgressNode.supernode != nil { + self.linearProgressNode.removeFromSupernode() } if self.downloadStatusIconNode.supernode != nil { self.downloadStatusIconNode.removeFromSupernode() @@ -510,18 +624,14 @@ final class ListMessageFileItemNode: ListMessageNode { } func activateMedia() { - if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { - if let item = self.item, let controllerInteraction = self.controllerInteraction { - controllerInteraction.openMessage(item.message.id) - } - } else { - self.progressPressed() - } + self.progressPressed() } func progressPressed() { - if let fetchStatus = self.fetchStatus { - switch fetchStatus { + if let resourceStatus = self.resourceStatus { + switch resourceStatus { + case let .fetchStatus(fetchStatus): + switch fetchStatus { case .Fetching: if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() @@ -531,7 +641,14 @@ final class ListMessageFileItemNode: ListMessageNode { fetch() } case .Local: - break + if let item = self.item, let controllerInteraction = self.controllerInteraction { + controllerInteraction.openMessage(item.message.id) + } + } + case .playbackStatus: + if let account = self.account, let applicationContext = account.applicationContext as? TelegramApplicationContext { + applicationContext.mediaManager.playlistPlayerControl(.playback(.togglePlayPause)) + } } } } diff --git a/TelegramUI/ListMessageItem.swift b/TelegramUI/ListMessageItem.swift index c34ea33d2e..925b2179f5 100644 --- a/TelegramUI/ListMessageItem.swift +++ b/TelegramUI/ListMessageItem.swift @@ -36,7 +36,7 @@ final class ListMessageItem: ListViewItem { node.setupItem(self) let nodeLayout = node.asyncLayout() - let (top, bottom, dateAtBottom) = (false, false, false) //self.mergedWithItems(top: previousItem, bottom: nextItem) + let (top, bottom, dateAtBottom) = (previousItem != nil, nextItem != nil, false) //self.mergedWithItems(top: previousItem, bottom: nextItem) let (layout, apply) = nodeLayout(self, width, top, bottom, dateAtBottom) node.updateSelectionState(animated: false) @@ -67,7 +67,7 @@ final class ListMessageItem: ListViewItem { let nodeLayout = node.asyncLayout() async { - let (top, bottom, dateAtBottom) = (false, false, false) //self.mergedWithItems(top: previousItem, bottom: nextItem) + let (top, bottom, dateAtBottom) = (previousItem != nil, nextItem != nil, false) //self.mergedWithItems(top: previousItem, bottom: nextItem) let (layout, apply) = nodeLayout(self, width, top, bottom, dateAtBottom) Queue.mainQueue().async { diff --git a/TelegramUI/ManagedAudioPlaylistPlayer.swift b/TelegramUI/ManagedAudioPlaylistPlayer.swift index dfa3f5fc32..427d92e339 100644 --- a/TelegramUI/ManagedAudioPlaylistPlayer.swift +++ b/TelegramUI/ManagedAudioPlaylistPlayer.swift @@ -3,19 +3,46 @@ import Postbox import TelegramCore import SwiftSignalKit -enum AudioPlaylistItemLabelInfo { +enum AudioPlaylistItemLabelInfo: Equatable { case music(title: String?, performer: String?) case voice + + static func ==(lhs: AudioPlaylistItemLabelInfo, rhs: AudioPlaylistItemLabelInfo) -> Bool { + switch lhs { + case let .music(lhsTitle, lhsPerformer): + if case let .music(rhsTitle, rhsPerformer) = rhs, lhsTitle == rhsTitle, lhsPerformer == rhsPerformer { + return true + } else { + return false + } + case .voice: + if case .voice = rhs { + return true + } else { + return false + } + } + } } -struct AudioPlaylistItemInfo { +struct AudioPlaylistItemInfo: Equatable { let duration: Double let labelInfo: AudioPlaylistItemLabelInfo + + static func ==(lhs: AudioPlaylistItemInfo, rhs: AudioPlaylistItemInfo) -> Bool { + if !lhs.duration.isEqual(to: rhs.duration) { + return false + } + if lhs.labelInfo != rhs.labelInfo { + return false + } + return true + } } protocol AudioPlaylistItemId { var hashValue: Int { get } - func isEqual(other: AudioPlaylistItemId) -> Bool + func isEqual(to: AudioPlaylistItemId) -> Bool } protocol AudioPlaylistItem { @@ -23,7 +50,7 @@ protocol AudioPlaylistItem { var resource: MediaResource? { get } var info: AudioPlaylistItemInfo? { get } - func isEqual(other: AudioPlaylistItem) -> Bool + func isEqual(to: AudioPlaylistItem) -> Bool } enum AudioPlaylistNavigation { @@ -34,6 +61,8 @@ enum AudioPlaylistNavigation { enum AudioPlaylistPlayback { case play case pause + case togglePlayPause + case seek(Double) } enum AudioPlaylistControl { @@ -42,7 +71,7 @@ enum AudioPlaylistControl { } protocol AudioPlaylistId { - func isEqual(other: AudioPlaylistId) -> Bool + func isEqual(to: AudioPlaylistId) -> Bool } struct AudioPlaylist { @@ -55,12 +84,12 @@ struct AudioPlaylistState: Equatable { let item: AudioPlaylistItem? static func ==(lhs: AudioPlaylistState, rhs: AudioPlaylistState) -> Bool { - if !lhs.playlistId.isEqual(other: rhs.playlistId) { + if !lhs.playlistId.isEqual(to: rhs.playlistId) { return false } if let lhsItem = lhs.item, let rhsItem = rhs.item { - if !lhsItem.isEqual(other: rhsItem) { + if !lhsItem.isEqual(to: rhsItem) { return false } } else if (lhs.item != nil) != (rhs.item != nil) { @@ -70,6 +99,16 @@ struct AudioPlaylistState: Equatable { } } +struct AudioPlaylistStateAndStatus: Equatable { + let state: AudioPlaylistState + let playbackId: Int32 + let status: Signal? + + static func ==(lhs: AudioPlaylistStateAndStatus, rhs: AudioPlaylistStateAndStatus) -> Bool { + return lhs.state == rhs.state && lhs.playbackId == rhs.playbackId + } +} + private final class AudioPlaylistItemState { let item: AudioPlaylistItem let player: MediaPlayer? @@ -83,6 +122,7 @@ private final class AudioPlaylistItemState { private final class AudioPlaylistInternalState { var currentItem: AudioPlaylistItemState? let navigationDisposable = MetaDisposable() + var nextPlaybackId: Int32 = 0 } final class ManagedAudioPlaylistPlayer { @@ -90,10 +130,10 @@ final class ManagedAudioPlaylistPlayer { let playlist: AudioPlaylist private let currentState = Atomic(value: AudioPlaylistInternalState()) - private let currentStateValue = Promise() + private let currentStateAndStatusValue = Promise() - var state: Signal { - return self.currentStateValue.get() + var stateAndStatus: Signal { + return self.currentStateAndStatusValue.get() } init(postbox: Postbox, playlist: AudioPlaylist) { @@ -117,6 +157,10 @@ final class ManagedAudioPlaylistPlayer { item.player?.play() case .pause: item.player?.pause() + case .togglePlayPause: + item.player?.togglePlayPause() + case let .seek(timestamp): + item.player?.seek(timestamp: timestamp) } } } @@ -125,24 +169,34 @@ final class ManagedAudioPlaylistPlayer { var currentItem: AudioPlaylistItem? self.currentState.with { state -> Void in state.navigationDisposable.set(disposable) + currentItem = state.currentItem?.item } disposable.set(self.playlist.navigate(currentItem, navigation).start(next: { [weak self] item in if let strongSelf = self { - let updatedState = strongSelf.currentState.with { state -> AudioPlaylistState in + let updatedStateAndStatus = strongSelf.currentState.with { state -> AudioPlaylistStateAndStatus in if let item = item { - var player: MediaPlayer? if let resource = item.resource { - player = MediaPlayer(postbox: strongSelf.postbox, resource: resource) + let player = MediaPlayer(postbox: strongSelf.postbox, resource: resource) + player.actionAtEnd = .action({ + if let strongSelf = self { + strongSelf.control(.navigation(.next)) + } + }) + state.currentItem = AudioPlaylistItemState(item: item, player: player) + player.play() + let playbackId = state.nextPlaybackId + state.nextPlaybackId += 1 + return AudioPlaylistStateAndStatus(state: AudioPlaylistState(playlistId: strongSelf.playlist.id, item: item), playbackId: playbackId, status: player.status) + } else { + state.currentItem = AudioPlaylistItemState(item: item, player: nil) + return AudioPlaylistStateAndStatus(state: AudioPlaylistState(playlistId: strongSelf.playlist.id, item: item), playbackId: 0, status: nil) } - state.currentItem = AudioPlaylistItemState(item: item, player: player) - player?.play() - return AudioPlaylistState(playlistId: strongSelf.playlist.id, item: item) } else { state.currentItem = nil - return AudioPlaylistState(playlistId: strongSelf.playlist.id, item: nil) + return AudioPlaylistStateAndStatus(state: AudioPlaylistState(playlistId: strongSelf.playlist.id, item: nil), playbackId: 0, status: nil) } } - strongSelf.currentStateValue.set(.single(updatedState)) + strongSelf.currentStateAndStatusValue.set(.single(updatedStateAndStatus)) } })) } diff --git a/TelegramUI/MediaFrameSource.swift b/TelegramUI/MediaFrameSource.swift index 413eacdd6c..d31c3a2b98 100644 --- a/TelegramUI/MediaFrameSource.swift +++ b/TelegramUI/MediaFrameSource.swift @@ -4,6 +4,7 @@ import CoreMedia enum MediaTrackEvent { case frames([MediaTrackDecodableFrame]) + case endOfStream } struct MediaFrameSourceSeekResult { diff --git a/TelegramUI/MediaManager.swift b/TelegramUI/MediaManager.swift index 82c6bf29a7..524d26f0ff 100644 --- a/TelegramUI/MediaManager.swift +++ b/TelegramUI/MediaManager.swift @@ -5,8 +5,45 @@ import AVFoundation import MobileCoreServices import TelegramCore +private struct WrappedAudioPlaylistItemId: Hashable, Equatable { + let playlistId: AudioPlaylistId + let itemId: AudioPlaylistItemId + + static func ==(lhs: WrappedAudioPlaylistItemId, rhs: WrappedAudioPlaylistItemId) -> Bool { + return lhs.itemId.isEqual(to: rhs.itemId) && lhs.playlistId.isEqual(to: rhs.playlistId) + } + + var hashValue: Int { + return self.itemId.hashValue + } +} + private final class ManagedAudioPlaylistPlayerStatusesContext { - let subscribers + private var subscribers: [WrappedAudioPlaylistItemId: Bag<(AudioPlaylistState?) -> Void>] = [:] + + func addSubscriber(id: WrappedAudioPlaylistItemId, _ f: @escaping (AudioPlaylistState?) -> Void) -> Int { + let bag: Bag<(AudioPlaylistState?) -> Void> + if let currentBag = self.subscribers[id] { + bag = currentBag + } else { + bag = Bag() + self.subscribers[id] = bag + } + return bag.add(f) + } + + func removeSubscriber(id: WrappedAudioPlaylistItemId, index: Int) { + if let bag = subscribers[id] { + bag.remove(index) + if bag.isEmpty { + self.subscribers.removeValue(forKey: id) + } + } + } + + func subscribersForId(_ id: WrappedAudioPlaylistItemId) -> [(AudioPlaylistState) -> Void]? { + return self.subscribers[id]?.copyItems() + } } private struct WrappedManagedMediaId: Hashable { @@ -46,11 +83,12 @@ final class MediaManager { let audioSession = ManagedAudioSession() private let playlistPlayer = Atomic(value: nil) - private let playlistPlayerStateValue = Promise(nil) - var playlistPlayerState: Signal { - return self.playlistPlayerStateValue.get() + private let playlistPlayerStateAndStatusValue = Promise(nil) + var playlistPlayerStateAndStatus: Signal { + return self.playlistPlayerStateAndStatusValue.get() } private var playlistPlayerStateValueDisposable: Disposable? + private let playlistPlayerStatusesContext = Atomic(value: ManagedAudioPlaylistPlayerStatusesContext()) private var managedVideoContexts: [WrappedManagedMediaId: ActiveManagedVideoContext] = [:] @@ -144,20 +182,49 @@ final class MediaManager { if updatedPlayer { if let player = player { - self.playlistPlayerStateValue.set(player.state) + self.playlistPlayerStateAndStatusValue.set(player.stateAndStatus) } else { - self.playlistPlayerStateValue.set(.single(nil)) + self.playlistPlayerStateAndStatusValue.set(.single(nil)) } } } - func playlistPlayerState(playlistId: AudioPlaylistId, itemId: AudioPlaylistItemId) -> Signal { - return Signal { subscriber in - - - return ActionDisposable { - - } + func playlistPlayerControl(_ control: AudioPlaylistControl) { + var player: ManagedAudioPlaylistPlayer? + self.playlistPlayer.with { currentPlayer -> Void in + player = currentPlayer + } + + if let player = player { + player.control(control) } } + + func filteredPlaylistPlayerStateAndStatus(playlistId: AudioPlaylistId, itemId: AudioPlaylistItemId) -> Signal { + return self.playlistPlayerStateAndStatusValue.get() + |> map { state -> AudioPlaylistStateAndStatus? in + if let state = state, let item = state.state.item, state.state.playlistId.isEqual(to: playlistId), item.id.isEqual(to: itemId) { + return state + } + return nil + } + /*return Signal { subscriber in + let id = WrappedAudioPlaylistItemId(playlistId: playlistId, itemId: itemId) + let index = self.playlistPlayerStatusesContext.with { context -> Int in + context.addSubscriber(id: id, { state in + subscriber.putNext(state) + }) + } + + + + return ActionDisposable { [weak self] in + if let strongSelf = self { + strongSelf.playlistPlayerStatusesContext.with { context -> Void in + context.removeSubscriber(id: id, index: index) + } + } + } + }*/ + } } diff --git a/TelegramUI/MediaNavigationAccessoryContainerNode.swift b/TelegramUI/MediaNavigationAccessoryContainerNode.swift index 188866c4fe..b67e210584 100644 --- a/TelegramUI/MediaNavigationAccessoryContainerNode.swift +++ b/TelegramUI/MediaNavigationAccessoryContainerNode.swift @@ -1,29 +1,139 @@ import Foundation import AsyncDisplayKit import Display +import TelegramCore -final class MediaNavigationAccessoryContainerNode: ASDisplayNode { - private let separatorNode: ASDisplayNode +final class MediaNavigationAccessoryContainerNode: ASDisplayNode, UIGestureRecognizerDelegate { + let backgroundNode: ASDisplayNode let headerNode: MediaNavigationAccessoryHeaderNode + let itemListNode: MediaNavigationAccessoryItemListNode - override init() { - self.separatorNode = ASDisplayNode() - self.separatorNode.isLayerBacked = true - self.separatorNode.backgroundColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) - + private var currentHeaderHeight: CGFloat = MediaNavigationAccessoryHeaderNode.minimizedHeight + private var draggingHeaderHeight: CGFloat? + private var effectiveHeaderHeight: CGFloat { + if let draggingHeaderHeight = self.draggingHeaderHeight { + return draggingHeaderHeight + } else { + return self.currentHeaderHeight + } + } + + init(account: Account) { + self.backgroundNode = ASDisplayNode() self.headerNode = MediaNavigationAccessoryHeaderNode() + self.itemListNode = MediaNavigationAccessoryItemListNode(account: account) super.init() - self.backgroundColor = UIColor(red: 0.968626451, green: 0.968626451, blue: 0.968626451, alpha: 1.0) + self.backgroundNode.backgroundColor = UIColor(red: 0.968626451, green: 0.968626451, blue: 0.968626451, alpha: 1.0) + self.addSubnode(self.backgroundNode) + self.addSubnode(self.itemListNode) self.addSubnode(self.headerNode) - self.addSubnode(self.separatorNode) + + self.headerNode.expand = { [weak self] in + if let strongSelf = self, strongSelf.draggingHeaderHeight == nil { + let middleHeight = MediaNavigationAccessoryHeaderNode.maximizedHeight + MediaNavigationAccessoryItemListNode.minimizedPanelHeight + strongSelf.currentHeaderHeight = middleHeight + strongSelf.updateLayout(size: strongSelf.bounds.size, transition: .animated(duration: 0.3, curve: .spring)) + } + } + + self.itemListNode.collapse = { [weak self] in + if let strongSelf = self, strongSelf.draggingHeaderHeight == nil { + let middleHeight = MediaNavigationAccessoryHeaderNode.maximizedHeight + MediaNavigationAccessoryItemListNode.minimizedPanelHeight + if middleHeight.isLess(than: strongSelf.currentHeaderHeight) { + strongSelf.currentHeaderHeight = middleHeight + } else { + strongSelf.currentHeaderHeight = strongSelf.bounds.size.height + } + strongSelf.updateLayout(size: strongSelf.bounds.size, transition: .animated(duration: 0.3, curve: .spring)) + } + } + } + + override func didLoad() { + super.didLoad() + + let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) + panRecognizer.cancelsTouchesInView = true + panRecognizer.delegate = self + self.view.addGestureRecognizer(panRecognizer) } func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { - transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 36.0 - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel))) - transition.updateFrame(node: self.headerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: 36.0))) - self.headerNode.updateLayout(size: size, transition: transition) + transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: self.effectiveHeaderHeight))) + + let headerHeight = max(MediaNavigationAccessoryHeaderNode.minimizedHeight, min(MediaNavigationAccessoryHeaderNode.maximizedHeight, self.effectiveHeaderHeight)) + transition.updateFrame(node: self.headerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: headerHeight))) + self.headerNode.updateLayout(size: CGSize(width: size.width, height: headerHeight), transition: transition) + + let itemListHeight = max(0.0, self.effectiveHeaderHeight - headerHeight) + transition.updateFrame(node: self.itemListNode, frame: CGRect(origin: CGPoint(x: 0.0, y: headerHeight), size: CGSize(width: size.width, height: itemListHeight))) + self.itemListNode.updateLayout(size: CGSize(width: size.width, height: itemListHeight), maximizedHeight: max(10.0, size.height - MediaNavigationAccessoryHeaderNode.maximizedHeight), transition: transition) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if let result = self.hitTest(touch.location(in: self.view), with: nil) { + if result.disablesInteractiveTransitionGestureRecognizer { + return false + } + } + return true + } + + @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { + let middleHeight = MediaNavigationAccessoryHeaderNode.maximizedHeight + MediaNavigationAccessoryItemListNode.minimizedPanelHeight + switch recognizer.state { + case .began: + self.draggingHeaderHeight = self.currentHeaderHeight + case .changed: + if let draggingHeaderHeight = self.draggingHeaderHeight { + let translation = recognizer.translation(in: self.view).y + self.draggingHeaderHeight = max(MediaNavigationAccessoryHeaderNode.minimizedHeight, self.currentHeaderHeight + translation) + self.updateLayout(size: self.bounds.size, transition: .immediate) + } + case .ended: + if let draggingHeaderHeight = self.draggingHeaderHeight { + self.draggingHeaderHeight = nil + let velocity = recognizer.velocity(in: self.view).y + if abs(velocity) > 500.0 { + if draggingHeaderHeight <= middleHeight { + if velocity < 0.0 { + self.currentHeaderHeight = MediaNavigationAccessoryHeaderNode.minimizedHeight + } else { + self.currentHeaderHeight = middleHeight + } + } else { + if velocity < 0.0 { + self.currentHeaderHeight = middleHeight + } else { + self.currentHeaderHeight = self.bounds.size.height + } + } + } else { + if draggingHeaderHeight < MediaNavigationAccessoryHeaderNode.maximizedHeight * 2.0 / 3.0 { + self.currentHeaderHeight = MediaNavigationAccessoryHeaderNode.minimizedHeight + } else if draggingHeaderHeight <= middleHeight + 100.0 { + self.currentHeaderHeight = middleHeight + } else { + self.currentHeaderHeight = self.bounds.size.height + } + } + self.updateLayout(size: self.bounds.size, transition: .animated(duration: 0.3, curve: .spring)) + } + case .cancelled: + self.draggingHeaderHeight = nil + self.updateLayout(size: self.bounds.size, transition: .animated(duration: 0.3, curve: .spring)) + default: + break + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if !self.headerNode.frame.contains(point) && !self.itemListNode.frame.contains(point) { + return nil + } + return super.hitTest(point, with: event) } } diff --git a/TelegramUI/MediaNavigationAccessoryHeaderNode.swift b/TelegramUI/MediaNavigationAccessoryHeaderNode.swift index 49e2acc0ef..e73893443f 100644 --- a/TelegramUI/MediaNavigationAccessoryHeaderNode.swift +++ b/TelegramUI/MediaNavigationAccessoryHeaderNode.swift @@ -15,35 +15,351 @@ private let closeButtonImage = generateImage(CGSize(width: 12.0, height: 12.0), context.strokePath() }) +private let titleFont = Font.regular(12.0) +private let subtitleFont = Font.regular(10.0) +private let maximizedTitleFont = Font.bold(17.0) +private let maximizedSubtitleFont = Font.regular(12.0) + +private let titleColor = UIColor.black +private let subtitleColor = UIColor(0x8b8b8b) + +private let playIcon = UIImage(bundleImageName: "GlobalMusicPlayer/MinimizedPlay")?.precomposed() +private let pauseIcon = UIImage(bundleImageName: "GlobalMusicPlayer/MinimizedPause")?.precomposed() +private let maximizedPlayIcon = UIImage(bundleImageName: "GlobalMusicPlayer/Play")?.precomposed() +private let maximizedPauseIcon = UIImage(bundleImageName: "GlobalMusicPlayer/Pause")?.precomposed() +private let maximizedPreviousIcon = UIImage(bundleImageName: "GlobalMusicPlayer/Previous")?.precomposed() +private let maximizedNextIcon = UIImage(bundleImageName: "GlobalMusicPlayer/Next")?.precomposed() +private let maximizedShuffleIcon = UIImage(bundleImageName: "GlobalMusicPlayer/Shuffle")?.precomposed() +private let maximizedRepeatIcon = UIImage(bundleImageName: "GlobalMusicPlayer/Repeat")?.precomposed() + final class MediaNavigationAccessoryHeaderNode: ASDisplayNode { + static let minimizedHeight: CGFloat = 37.0 + static let maximizedHeight: CGFloat = 166.0 + private let titleNode: TextNode private let subtitleNode: TextNode + private let maximizedTitleNode: TextNode + private let maximizedSubtitleNode: TextNode private let closeButton: HighlightableButtonNode + private let actionButton: HighlightTrackingButtonNode + private let actionPauseNode: ASImageNode + private let actionPlayNode: ASImageNode + + private let maximizedLeftTimestampNode: MediaPlayerTimeTextNode + private let maximizedRightTimestampNode: MediaPlayerTimeTextNode + private let maximizedActionButton: HighlightableButtonNode + private let maximizedActionPauseNode: ASImageNode + private let maximizedActionPlayNode: ASImageNode + private let maximizedPreviousButton: HighlightableButtonNode + private let maximizedNextButton: HighlightableButtonNode + private let maximizedShuffleButton: HighlightableButtonNode + private let maximizedRepeatButton: HighlightableButtonNode + + private let scrubbingNode: MediaPlayerScrubbingNode + private let maximizedScrubbingNode: MediaPlayerScrubbingNode + + private var tapRecognizer: UITapGestureRecognizer? + + var expand: (() -> Void)? var close: (() -> Void)? + var togglePlayPause: (() -> Void)? + var previous: (() -> Void)? + var next: (() -> Void)? + var seek: ((Double) -> Void)? + + var stateAndStatus: AudioPlaylistStateAndStatus? { + didSet { + if self.stateAndStatus != oldValue { + self.updateLayout(size: self.bounds.size, transition: .immediate) + self.scrubbingNode.status = stateAndStatus?.status + self.maximizedScrubbingNode.status = stateAndStatus?.status + self.maximizedLeftTimestampNode.status = stateAndStatus?.status + self.maximizedRightTimestampNode.status = stateAndStatus?.status + } + } + } override init() { self.titleNode = TextNode() + self.titleNode.isLayerBacked = true self.subtitleNode = TextNode() + self.subtitleNode.isLayerBacked = true + + self.maximizedTitleNode = TextNode() + self.maximizedTitleNode.isLayerBacked = true + self.maximizedSubtitleNode = TextNode() + self.maximizedSubtitleNode.isLayerBacked = true self.closeButton = HighlightableButtonNode() self.closeButton.setImage(closeButtonImage, for: []) self.closeButton.hitTestSlop = UIEdgeInsetsMake(-8.0, -8.0, -8.0, -8.0) self.closeButton.displaysAsynchronously = false + self.actionButton = HighlightTrackingButtonNode() + self.actionButton.hitTestSlop = UIEdgeInsetsMake(-8.0, -8.0, -8.0, -8.0) + self.actionButton.displaysAsynchronously = false + + self.actionPauseNode = ASImageNode() + self.actionPauseNode.contentMode = .center + self.actionPauseNode.isLayerBacked = true + self.actionPauseNode.displaysAsynchronously = false + self.actionPauseNode.displayWithoutProcessing = true + self.actionPauseNode.image = pauseIcon + + self.actionPlayNode = ASImageNode() + self.actionPlayNode.contentMode = .center + self.actionPlayNode.isLayerBacked = true + self.actionPlayNode.displaysAsynchronously = false + self.actionPlayNode.displayWithoutProcessing = true + self.actionPlayNode.image = playIcon + self.actionPlayNode.isHidden = true + + self.maximizedLeftTimestampNode = MediaPlayerTimeTextNode() + self.maximizedRightTimestampNode = MediaPlayerTimeTextNode() + self.maximizedLeftTimestampNode.alignment = .right + self.maximizedRightTimestampNode.mode = .reversed + + self.maximizedActionButton = HighlightableButtonNode() + self.maximizedActionButton.hitTestSlop = UIEdgeInsetsMake(-8.0, -8.0, -8.0, -8.0) + self.maximizedActionButton.displaysAsynchronously = false + + self.maximizedActionPauseNode = ASImageNode() + self.maximizedActionPauseNode.isLayerBacked = true + self.maximizedActionPauseNode.displaysAsynchronously = false + self.maximizedActionPauseNode.displayWithoutProcessing = true + self.maximizedActionPauseNode.image = maximizedPauseIcon + + self.maximizedActionPlayNode = ASImageNode() + self.maximizedActionPlayNode.isLayerBacked = true + self.maximizedActionPlayNode.displaysAsynchronously = false + self.maximizedActionPlayNode.displayWithoutProcessing = true + self.maximizedActionPlayNode.image = maximizedPlayIcon + self.maximizedActionPlayNode.isHidden = true + + let maximizedActionButtonSize = CGSize(width: 66.0, height: 50.0) + self.maximizedActionButton.frame = CGRect(origin: CGPoint(), size: maximizedActionButtonSize) + if let maximizedPauseIcon = maximizedPauseIcon { + self.maximizedActionPauseNode.frame = CGRect(origin: CGPoint(x: floor((maximizedActionButtonSize.width - maximizedPauseIcon.size.width) / 2.0), y: floor((maximizedActionButtonSize.height - maximizedPauseIcon.size.height) / 2.0)), size: maximizedPauseIcon.size) + } + if let maximizedPlayIcon = maximizedPlayIcon { + self.maximizedActionPlayNode.frame = CGRect(origin: CGPoint(x: floor((maximizedActionButtonSize.width - maximizedPlayIcon.size.width) / 2.0) + 2.0, y: floor((maximizedActionButtonSize.height - maximizedPlayIcon.size.height) / 2.0)), size: maximizedPlayIcon.size) + } + + self.maximizedPreviousButton = HighlightableButtonNode() + self.maximizedPreviousButton.setImage(maximizedPreviousIcon, for: []) + self.maximizedPreviousButton.hitTestSlop = UIEdgeInsetsMake(-8.0, -8.0, -8.0, -8.0) + self.maximizedPreviousButton.displaysAsynchronously = false + + self.maximizedNextButton = HighlightableButtonNode() + self.maximizedNextButton.setImage(maximizedNextIcon, for: []) + self.maximizedNextButton.hitTestSlop = UIEdgeInsetsMake(-8.0, -8.0, -8.0, -8.0) + self.maximizedNextButton.displaysAsynchronously = false + + self.maximizedShuffleButton = HighlightableButtonNode() + self.maximizedShuffleButton.setImage(maximizedShuffleIcon, for: []) + self.maximizedShuffleButton.hitTestSlop = UIEdgeInsetsMake(-8.0, -8.0, -8.0, -8.0) + self.maximizedShuffleButton.displaysAsynchronously = false + + self.maximizedRepeatButton = HighlightableButtonNode() + self.maximizedRepeatButton.setImage(maximizedRepeatIcon, for: []) + self.maximizedRepeatButton.hitTestSlop = UIEdgeInsetsMake(-8.0, -8.0, -8.0, -8.0) + self.maximizedRepeatButton.displaysAsynchronously = false + + self.scrubbingNode = MediaPlayerScrubbingNode(lineHeight: 2.0, lineCap: .square, scrubberHandle: false, backgroundColor: .clear, foregroundColor: UIColor(0x007ee5)) + self.maximizedScrubbingNode = MediaPlayerScrubbingNode(lineHeight: 3.0, lineCap: .round, scrubberHandle: true, backgroundColor: UIColor(0xcfcccf), foregroundColor: UIColor(0x007ee5)) + + super.init() + self.clipsToBounds = true + self.addSubnode(self.titleNode) self.addSubnode(self.subtitleNode) + self.addSubnode(self.maximizedTitleNode) + self.addSubnode(self.maximizedSubtitleNode) + self.addSubnode(self.closeButton) + self.actionButton.addSubnode(self.actionPauseNode) + self.actionButton.addSubnode(self.actionPlayNode) + self.addSubnode(self.actionButton) + + self.addSubnode(self.maximizedLeftTimestampNode) + self.addSubnode(self.maximizedRightTimestampNode) + + self.maximizedActionButton.addSubnode(self.maximizedActionPauseNode) + self.maximizedActionButton.addSubnode(self.maximizedActionPlayNode) + self.addSubnode(self.maximizedActionButton) + self.addSubnode(self.maximizedPreviousButton) + self.addSubnode(self.maximizedNextButton) + self.addSubnode(self.maximizedShuffleButton) + self.addSubnode(self.maximizedRepeatButton) + self.closeButton.addTarget(self, action: #selector(self.closeButtonPressed), forControlEvents: .touchUpInside) + self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside) + self.maximizedActionButton.addTarget(self, action: #selector(self.actionButtonPressed), forControlEvents: .touchUpInside) + self.maximizedPreviousButton.addTarget(self, action: #selector(self.previousButtonPressed), forControlEvents: .touchUpInside) + self.maximizedNextButton.addTarget(self, action: #selector(self.nextButtonPressed), forControlEvents: .touchUpInside) + self.maximizedShuffleButton.addTarget(self, action: #selector(self.shuffleButtonPressed), forControlEvents: .touchUpInside) + self.maximizedRepeatButton.addTarget(self, action: #selector(self.repeatButtonPressed), forControlEvents: .touchUpInside) + + self.addSubnode(self.maximizedScrubbingNode) + self.addSubnode(self.scrubbingNode) + + self.actionButton.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.actionButton.layer.removeAnimation(forKey: "opacity") + strongSelf.actionButton.alpha = 0.4 + } else { + strongSelf.actionButton.alpha = 1.0 + strongSelf.actionButton.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + + self.scrubbingNode.playbackStatusUpdated = { [weak self] status in + if let strongSelf = self { + let paused: Bool + if let status = status { + switch status { + case .paused: + paused = true + case let .buffering(whilePlaying): + paused = !whilePlaying + case .playing: + paused = false + } + } else { + paused = true + } + strongSelf.actionPlayNode.isHidden = !paused + strongSelf.actionPauseNode.isHidden = paused + strongSelf.maximizedActionPlayNode.isHidden = !paused + strongSelf.maximizedActionPauseNode.isHidden = paused + } + } + + self.maximizedScrubbingNode.seek = { [weak self] timestamp in + self?.seek?(timestamp) + } + } + + override func didLoad() { + super.didLoad() + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.tapRecognizer = tapRecognizer + self.view.addGestureRecognizer(tapRecognizer) } func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + let minHeight = MediaNavigationAccessoryHeaderNode.minimizedHeight + let maxHeight = MediaNavigationAccessoryHeaderNode.maximizedHeight + let maximizationFactor = (size.height - minHeight) / (maxHeight - minHeight) + + let enableExpandTap = maximizationFactor.isEqual(to: 0.0) + if let tapRecognizer = self.tapRecognizer, tapRecognizer.isEnabled != enableExpandTap { + tapRecognizer.isEnabled = enableExpandTap + } + + var titleString: NSAttributedString? + var subtitleString: NSAttributedString? + var maximizedTitleString: NSAttributedString? + var maximizedSubtitleString: NSAttributedString? + if let stateAndStatus = self.stateAndStatus, let item = stateAndStatus.state.item, let info = item.info { + switch info.labelInfo { + case let .music(title, performer): + let titleText: String = title ?? "Unknown Track" + let subtitleText: String = performer ?? "Unknown Artist" + + titleString = NSAttributedString(string: titleText, font: titleFont, textColor: titleColor) + subtitleString = NSAttributedString(string: subtitleText, font: subtitleFont, textColor: subtitleColor) + + maximizedTitleString = NSAttributedString(string: titleText, font: maximizedTitleFont, textColor: titleColor) + maximizedSubtitleString = NSAttributedString(string: subtitleText, font: maximizedSubtitleFont, textColor: subtitleColor) + case .voice: + let titleText: String = "Voice Message" + titleString = NSAttributedString(string: titleText, font: titleFont, textColor: titleColor) + + maximizedTitleString = NSAttributedString(string: titleText, font: maximizedTitleFont, textColor: titleColor) + } + } + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + let makeSubtitleLayout = TextNode.asyncLayout(self.subtitleNode) + let makeMaximizedTitleLayout = TextNode.asyncLayout(self.maximizedTitleNode) + let makeMaximizedSubtitleLayout = TextNode.asyncLayout(self.maximizedSubtitleNode) + + let (titleLayout, titleApply) = makeTitleLayout(titleString, nil, 1, .middle, CGSize(width: size.width - 80.0, height: 100.0), nil) + let (subtitleLayout, subtitleApply) = makeSubtitleLayout(subtitleString, nil, 1, .middle, CGSize(width: size.width - 80.0, height: 100.0), nil) + + let (maximizedTitleLayout, maximizedTitleApply) = makeMaximizedTitleLayout(maximizedTitleString, nil, 1, .middle, CGSize(width: size.width - 80.0, height: 100.0), nil) + let (maximizedSubtitleLayout, maximizedSubtitleApply) = makeMaximizedSubtitleLayout(maximizedSubtitleString, nil, 1, .middle, CGSize(width: size.width - 80.0, height: 100.0), nil) + + titleApply() + subtitleApply() + maximizedTitleApply() + maximizedSubtitleApply() + + let minimizedTitleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleLayout.size.width) / 2.0), y: 4.0), size: titleLayout.size) + let minimizedSubtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleLayout.size.width) / 2.0), y: 20.0), size: subtitleLayout.size) + + let maximizedTitleFrame = CGRect(origin: CGPoint(x: floor((size.width - maximizedTitleLayout.size.width) / 2.0), y: 57.0), size: maximizedTitleLayout.size) + let maximizedSubtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - maximizedSubtitleLayout.size.width) / 2.0), y: 80.0), size: maximizedSubtitleLayout.size) + + let maximizedTitleDistance = maximizedTitleFrame.midY - minimizedTitleFrame.midY + let maximizedSubtitleDistance = maximizedSubtitleFrame.midY - minimizedSubtitleFrame.midY + + let updatedMinimizedTitleFrame = minimizedTitleFrame.offsetBy(dx: 0.0, dy: maximizedTitleDistance * maximizationFactor) + let updatedMaximizedTitleFrame = maximizedTitleFrame.offsetBy(dx: 0.0, dy: -maximizedTitleDistance * (1.0 - maximizationFactor)) + + transition.updateFrame(node: self.titleNode, frame: updatedMinimizedTitleFrame) + transition.updateFrame(node: self.subtitleNode, frame: minimizedSubtitleFrame.offsetBy(dx: 0.0, dy: maximizedSubtitleDistance * maximizationFactor)) + + transition.updateFrame(node: self.maximizedTitleNode, frame: updatedMaximizedTitleFrame) + transition.updateFrame(node: self.maximizedSubtitleNode, frame: maximizedSubtitleFrame.offsetBy(dx: 0.0, dy: -maximizedSubtitleDistance * (1.0 - maximizationFactor))) + let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0)) - transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: bounds.size.width - 18.0 - closeButtonSize.width, y: 12.0), size: closeButtonSize)) + transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: bounds.size.width - 18.0 - closeButtonSize.width, y: updatedMinimizedTitleFrame.minY + 8.0), size: closeButtonSize)) + transition.updateFrame(node: self.actionPlayNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 40.0, height: 37.0))) + transition.updateFrame(node: self.actionPauseNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 40.0, height: 37.0))) + transition.updateFrame(node: self.actionButton, frame: CGRect(origin: CGPoint(x: 0.0, y: updatedMinimizedTitleFrame.minY - 4.0), size: CGSize(width: 40.0, height: 37.0))) + transition.updateFrame(node: self.scrubbingNode, frame: CGRect(origin: CGPoint(x: 0.0, y: (37.0 + (maxHeight - minHeight) * maximizationFactor) - 2.0), size: CGSize(width: size.width, height: 2.0))) + transition.updateFrame(node: self.maximizedScrubbingNode, frame: CGRect(origin: CGPoint(x: 57.0, y: updatedMaximizedTitleFrame.minY - 38.0), size: CGSize(width: size.width - 114.0, height: 15.0))) + + transition.updateFrame(node: self.maximizedLeftTimestampNode, frame: CGRect(origin: CGPoint(x: 0.0, y: updatedMaximizedTitleFrame.minY - 39.0), size: CGSize(width: 57.0 - 13.0, height: 20.0))) + transition.updateFrame(node: self.maximizedRightTimestampNode, frame: CGRect(origin: CGPoint(x: size.width - 57.0 + 13.0, y: updatedMaximizedTitleFrame.minY - 39.0), size: CGSize(width: 57.0 - 13.0, height: 20.0))) + + let maximizedActionButtonSize = self.maximizedActionButton.bounds.size + let maximizedActionButtonFrame = CGRect(origin: CGPoint(x: floor((size.width - maximizedActionButtonSize.width) / 2.0), y: updatedMaximizedTitleFrame.maxY + 26.0), size: maximizedActionButtonSize) + transition.updateFrame(node: self.maximizedActionButton, frame: maximizedActionButtonFrame) + + let actionButtonSpacing: CGFloat = 10.0 + transition.updateFrame(node: self.maximizedPreviousButton, frame: CGRect(origin: CGPoint(x: maximizedActionButtonFrame.minX - maximizedActionButtonSize.width - actionButtonSpacing, y: maximizedActionButtonFrame.minY), size: maximizedActionButtonSize)) + transition.updateFrame(node: self.maximizedNextButton, frame: CGRect(origin: CGPoint(x: maximizedActionButtonFrame.maxX + actionButtonSpacing, y: maximizedActionButtonFrame.minY), size: maximizedActionButtonSize)) + transition.updateFrame(node: self.maximizedShuffleButton, frame: CGRect(origin: CGPoint(x: 0.0, y: maximizedActionButtonFrame.minY), size: CGSize(width: 56.0, height: 50.0))) + transition.updateFrame(node: self.maximizedRepeatButton, frame: CGRect(origin: CGPoint(x: size.width - 56.0, y: maximizedActionButtonFrame.minY), size: CGSize(width: 56.0, height: 50.0))) + + transition.updateAlpha(node: self.actionButton, alpha: 1.0 - maximizationFactor) + transition.updateAlpha(node: self.closeButton, alpha: 1.0 - maximizationFactor) + + transition.updateAlpha(node: self.maximizedActionButton, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedPreviousButton, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedNextButton, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedPreviousButton, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedShuffleButton, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedRepeatButton, alpha: maximizationFactor) + + transition.updateAlpha(node: self.titleNode, alpha: 1.0 - maximizationFactor) + transition.updateAlpha(node: self.subtitleNode, alpha: 1.0 - maximizationFactor) + transition.updateAlpha(node: self.scrubbingNode, alpha: 1.0 - maximizationFactor) + transition.updateAlpha(node: self.maximizedScrubbingNode, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedTitleNode, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedSubtitleNode, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedLeftTimestampNode, alpha: maximizationFactor) + transition.updateAlpha(node: self.maximizedRightTimestampNode, alpha: maximizationFactor) } @objc func closeButtonPressed() { @@ -51,4 +367,36 @@ final class MediaNavigationAccessoryHeaderNode: ASDisplayNode { close() } } + + @objc func actionButtonPressed() { + if let togglePlayPause = self.togglePlayPause { + togglePlayPause() + } + } + + @objc func previousButtonPressed() { + if let previous = self.previous { + previous() + } + } + + @objc func nextButtonPressed() { + if let next = self.next { + next() + } + } + + @objc func shuffleButtonPressed() { + + } + + @objc func repeatButtonPressed() { + + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.expand?() + } + } } diff --git a/TelegramUI/MediaNavigationAccessoryItemListNode.swift b/TelegramUI/MediaNavigationAccessoryItemListNode.swift new file mode 100644 index 0000000000..c561aad1a2 --- /dev/null +++ b/TelegramUI/MediaNavigationAccessoryItemListNode.swift @@ -0,0 +1,201 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import Postbox + +private let handleImage = generateStretchableFilledCircleImage(diameter: 7.0, color: UIColor(0xbab7ba)) + +final class MediaNavigationAccessoryItemListNode: ASDisplayNode { + static let minimizedPanelHeight: CGFloat = 31.0 + + var collapse: (() -> Void)? + + private var previousMaximizedHeight: CGFloat? + + private let account: Account + + private let topSeparatorNode: ASDisplayNode + private let bottomSeparatorNode: ASDisplayNode + private let separatorNode: ASDisplayNode + private let panelNode: HighlightTrackingButtonNode + private let panelHandleNode: ASImageNode + private let contentNode: ASDisplayNode + private var listNode: ChatHistoryListNode? + + var stateAndStatus: AudioPlaylistStateAndStatus? { + didSet { + if self.stateAndStatus != oldValue { + let previousPlaylistPeerId = (oldValue?.state.playlistId as? PeerMessageHistoryAudioPlaylistId)?.peerId + let updatedPlaylistPeerId = (self.stateAndStatus?.state.playlistId as? PeerMessageHistoryAudioPlaylistId)?.peerId + + if previousPlaylistPeerId != updatedPlaylistPeerId { + if let listNode = self.listNode { + listNode.removeFromSupernode() + self.listNode = nil + } + if let updatedPlaylistPeerId = updatedPlaylistPeerId { + let controllerInteraction = ChatControllerInteraction(openMessage: { [weak self] id in + if let strongSelf = self, let listNode = strongSelf.listNode { + var galleryMedia: Media? + if let message = listNode.messageInCurrentHistoryView(id) { + for media in message.media { + if let file = media as? TelegramMediaFile { + galleryMedia = file + } else if let image = media as? TelegramMediaImage { + galleryMedia = image + } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { + if let file = content.file { + galleryMedia = file + } else if let image = content.image { + galleryMedia = image + } + } + } + } + + if let galleryMedia = galleryMedia { + if let file = galleryMedia as? TelegramMediaFile, file.isMusic || file.isVoice { + if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { + let player = ManagedAudioPlaylistPlayer(postbox: strongSelf.account.postbox, playlist: peerMessageHistoryAudioPlaylist(account: strongSelf.account, messageId: id)) + applicationContext.mediaManager.setPlaylistPlayer(player) + player.control(.navigation(.next)) + } + } + } + } + }, openPeer: { _ in }, openPeerMention: { _ in }, openMessageContextMenu: { _ in }, navigateToMessage: { _ in }, clickThroughMessage: { }, toggleMessageSelection: { _ in }, sendMessage: { _ in }, sendSticker: { _ in }, requestMessageActionCallback: { _, _ in }, openUrl: { _ in }, shareCurrentLocation: {}, shareAccountContact: {}, sendBotCommand: { _, _ in }, openInstantPage: { _ in }, updateInputState: { _ in }) + + let listNode = ChatHistoryListNode(account: account, peerId: updatedPlaylistPeerId, tagMask: .Music, messageId: nil, controllerInteraction: controllerInteraction, mode: .list) + listNode.preloadPages = true + self.listNode = listNode + self.contentNode.addSubnode(listNode) + + if let previousMaximizedHeight = self.previousMaximizedHeight { + self.updateLayout(size: self.bounds.size, maximizedHeight: previousMaximizedHeight, transition: .immediate) + } + } + } else { + let previousPlaylistMessageId = (oldValue?.state.item?.id as? PeerMessageHistoryAudioPlaylistItemId)?.id + let updatedPlaylistMessageId = (self.stateAndStatus?.state.item?.id as? PeerMessageHistoryAudioPlaylistItemId)?.id + if let updatedPlaylistMessageId = updatedPlaylistMessageId, previousPlaylistMessageId != updatedPlaylistMessageId { + if let listNode = self.listNode { + var foundItemNode: ListMessageFileItemNode? + listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ListMessageFileItemNode, let message = itemNode.message, message.id == updatedPlaylistMessageId { + foundItemNode = itemNode + } + } + if let foundItemNode = foundItemNode { + listNode.ensureItemNodeVisible(foundItemNode) + } else if let message = listNode.messageInCurrentHistoryView(updatedPlaylistMessageId) { + listNode.scrollToMessage(from: MessageIndex(message), to: MessageIndex(message)) + } + } + } + } + } + } + } + + init(account: Account) { + self.account = account + + self.topSeparatorNode = ASDisplayNode() + self.topSeparatorNode.isLayerBacked = true + self.topSeparatorNode.backgroundColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) + + self.bottomSeparatorNode = ASDisplayNode() + self.bottomSeparatorNode.isLayerBacked = true + self.bottomSeparatorNode.backgroundColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) + + self.separatorNode = ASDisplayNode() + self.separatorNode.isLayerBacked = true + self.separatorNode.backgroundColor = UIColor(red: 0.6953125, green: 0.6953125, blue: 0.6953125, alpha: 1.0) + + self.panelNode = HighlightTrackingButtonNode() + self.panelNode.backgroundColor = UIColor(red: 0.968626451, green: 0.968626451, blue: 0.968626451, alpha: 1.0) + + self.panelHandleNode = ASImageNode() + self.panelHandleNode.displaysAsynchronously = false + self.panelHandleNode.displayWithoutProcessing = true + self.panelHandleNode.image = handleImage + + self.contentNode = ASDisplayNode() + self.contentNode.backgroundColor = .white + self.contentNode.clipsToBounds = true + + super.init() + + self.addSubnode(self.contentNode) + self.addSubnode(self.topSeparatorNode) + self.addSubnode(self.panelNode) + self.panelNode.addSubnode(self.panelHandleNode) + self.addSubnode(self.bottomSeparatorNode) + self.addSubnode(self.separatorNode) + + self.panelNode.addTarget(self, action: #selector(self.panelPressed), forControlEvents: .touchUpInside) + self.panelNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + //strongSelf.panelNode.layer.removeAnimation(forKey: "opacity") + //strongSelf.panelNode.alpha = 0.55 + } else { + //strongSelf.panelNode.alpha = 0.35 + //strongSelf.panelNode.layer.animateAlpha(from: 0.55, to: 0.35, duration: 0.2) + } + } + } + } + + func updateLayout(size: CGSize, maximizedHeight: CGFloat, transition: ContainedViewLayoutTransition) { + self.previousMaximizedHeight = maximizedHeight + + let separatorAlpha: CGFloat = size.height.isLessThanOrEqualTo(MediaNavigationAccessoryItemListNode.minimizedPanelHeight) ? 0.0 : 1.0 + transition.updateAlpha(node: self.separatorNode, alpha: separatorAlpha) + transition.updateAlpha(node: self.panelHandleNode, alpha: min(1.0, max(0.0, size.height / MediaNavigationAccessoryItemListNode.minimizedPanelHeight))) + + transition.updateFrame(node: self.topSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: UIScreenPixel))) + transition.updateFrame(node: self.bottomSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height), size: CGSize(width: size.width, height: UIScreenPixel))) + transition.updateFrame(node: self.panelNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - MediaNavigationAccessoryItemListNode.minimizedPanelHeight), size: CGSize(width: size.width, height: MediaNavigationAccessoryItemListNode.minimizedPanelHeight))) + transition.updateFrame(node: self.panelHandleNode, frame: CGRect(origin: CGPoint(x: floor((size.width - 36.0) / 2.0), y: (size.height - 19.0) - (size.height - MediaNavigationAccessoryItemListNode.minimizedPanelHeight)), size: CGSize(width: 36.0, height: 7.0))) + transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height - MediaNavigationAccessoryItemListNode.minimizedPanelHeight - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel))) + transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: max(0.0, size.height - MediaNavigationAccessoryItemListNode.minimizedPanelHeight)))) + + if let listNode = listNode { + let listNodeSize = CGSize(width: size.width, height: max(10.0, maximizedHeight - MediaNavigationAccessoryItemListNode.minimizedPanelHeight)) + listNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: listNodeSize) + + var duration: Double = 0.0 + var curve: UInt = 0 + switch transition { + case .immediate: + break + case let .animated(animationDuration, animationCurve): + duration = animationDuration + switch animationCurve { + case .easeInOut: + break + case .spring: + curve = 7 + } + } + + let listViewCurve: ListViewAnimationCurve + if curve == 7 { + listViewCurve = .Spring(duration: duration) + } else { + listViewCurve = .Default + } + + let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: listNodeSize, insets: UIEdgeInsets(top: 0.0, left: + 0.0, bottom: 0.0, right: 0.0), duration: duration, curve: listViewCurve) + listNode.updateLayout(transition: transition, updateSizeAndInsets: updateSizeAndInsets) + } + //transition.updateFrame(node: self.contentNode, frame: )) + } + + @objc func panelPressed() { + self.collapse?() + } +} diff --git a/TelegramUI/MediaNavigationAccessoryPanel.swift b/TelegramUI/MediaNavigationAccessoryPanel.swift index 35b72e7bdf..f7f15e58b1 100644 --- a/TelegramUI/MediaNavigationAccessoryPanel.swift +++ b/TelegramUI/MediaNavigationAccessoryPanel.swift @@ -1,14 +1,19 @@ import Foundation import Display import AsyncDisplayKit +import TelegramCore final class MediaNavigationAccessoryPanel: ASDisplayNode { - private let containerNode: MediaNavigationAccessoryContainerNode + let containerNode: MediaNavigationAccessoryContainerNode var close: (() -> Void)? + var togglePlayPause: (() -> Void)? + var previous: (() -> Void)? + var next: (() -> Void)? + var seek: ((Double) -> Void)? - override init() { - self.containerNode = MediaNavigationAccessoryContainerNode() + init(account: Account) { + self.containerNode = MediaNavigationAccessoryContainerNode(account: account) super.init() @@ -19,6 +24,27 @@ final class MediaNavigationAccessoryPanel: ASDisplayNode { close() } } + containerNode.headerNode.togglePlayPause = { [weak self] in + if let strongSelf = self, let togglePlayPause = strongSelf.togglePlayPause { + togglePlayPause() + } + } + containerNode.headerNode.previous = { [weak self] in + if let strongSelf = self, let previous = strongSelf.previous { + previous() + } + } + containerNode.headerNode.next = { [weak self] in + if let strongSelf = self, let next = strongSelf.next { + next() + } + } + + containerNode.headerNode.seek = { [weak self] timestamp in + if let strongSelf = self, let seek = strongSelf.seek { + seek(timestamp) + } + } } func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { @@ -29,7 +55,7 @@ final class MediaNavigationAccessoryPanel: ASDisplayNode { func animateIn(transition: ContainedViewLayoutTransition) { self.clipsToBounds = true let contentPosition = self.containerNode.layer.position - transition.animatePosition(node: self.containerNode, from: CGPoint(x: contentPosition.x, y: contentPosition.y - self.containerNode.frame.size.height), completion: { [weak self] _ in + transition.animatePosition(node: self.containerNode, from: CGPoint(x: contentPosition.x, y: contentPosition.y - 37.0), completion: { [weak self] _ in self?.clipsToBounds = false }) } @@ -37,13 +63,13 @@ final class MediaNavigationAccessoryPanel: ASDisplayNode { func animateOut(transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { self.clipsToBounds = true let contentPosition = self.containerNode.layer.position - transition.animatePosition(node: self.containerNode, to: CGPoint(x: contentPosition.x, y: contentPosition.y - self.containerNode.frame.size.height), removeOnCompletion: false, completion: { [weak self] _ in + transition.animatePosition(node: self.containerNode, to: CGPoint(x: contentPosition.x, y: contentPosition.y - 37.0), removeOnCompletion: false, completion: { [weak self] _ in self?.clipsToBounds = false completion() }) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - return super.hitTest(point, with: event) + return self.containerNode.hitTest(point, with: event) } } diff --git a/TelegramUI/MediaPlayer.swift b/TelegramUI/MediaPlayer.swift index 78e97125e1..3bcf558ec3 100644 --- a/TelegramUI/MediaPlayer.swift +++ b/TelegramUI/MediaPlayer.swift @@ -28,6 +28,12 @@ private enum MediaPlayerState { case playing(MediaPlayerLoadedState) } +enum MediaPlayerActionAtEnd { + case loop + case action(() -> Void) + case stop +} + private final class MediaPlayerContext { private let queue: Queue private let postbox: Postbox @@ -38,7 +44,10 @@ private final class MediaPlayerContext { private var tickTimer: SwiftSignalKit.Timer? - fileprivate var status = Promise() + private var lastStatusUpdateTimestamp: Double? + private let playerStatus: ValuePromise + + fileprivate var actionAtEnd: MediaPlayerActionAtEnd = .stop fileprivate var playerNode: MediaPlayerNode? { didSet { @@ -62,10 +71,11 @@ private final class MediaPlayerContext { } } - init(queue: Queue, postbox: Postbox, resource: MediaResource) { + init(queue: Queue, playerStatus: ValuePromise, postbox: Postbox, resource: MediaResource) { assert(queue.isCurrent()) self.queue = queue + self.playerStatus = playerStatus self.postbox = postbox self.resource = resource } @@ -122,6 +132,24 @@ private final class MediaPlayerContext { CMTimebaseSetRate(loadedState.controlTimebase.timebase, 0.0) } } + let timestamp = CMTimeGetSeconds(CMTimebaseGetTime(loadedState.controlTimebase.timebase)) + var duration: Double = 0.0 + var videoStatus: MediaTrackFrameBufferStatus? + if let videoTrackFrameBuffer = loadedState.mediaBuffers.videoBuffer { + videoStatus = videoTrackFrameBuffer.status(at: timestamp) + duration = max(duration, CMTimeGetSeconds(videoTrackFrameBuffer.duration)) + } + + var audioStatus: MediaTrackFrameBufferStatus? + if let audioTrackFrameBuffer = loadedState.mediaBuffers.audioBuffer { + audioStatus = audioTrackFrameBuffer.status(at: timestamp) + duration = max(duration, CMTimeGetSeconds(audioTrackFrameBuffer.duration)) + } + let status = MediaPlayerStatus(generationTimestamp: CACurrentMediaTime(), duration: duration, timestamp: min(max(timestamp, 0.0), duration), status: .buffering(whilePlaying: action == .play)) + self.playerStatus.set(status) + } else { + let status = MediaPlayerStatus(generationTimestamp: CACurrentMediaTime(), duration: 0.0, timestamp: 0.0, status: .buffering(whilePlaying: action == .play)) + self.playerStatus.set(status) } let frameSource = FFMpegMediaFrameSource(queue: self.queue, postbox: self.postbox, resource: resource) @@ -200,6 +228,7 @@ private final class MediaPlayerContext { strongSelf.state = .paused(loadedState) } + strongSelf.lastStatusUpdateTimestamp = nil strongSelf.tick() } } @@ -213,6 +242,7 @@ private final class MediaPlayerContext { strongSelf.state = .paused(loadedState) } + strongSelf.lastStatusUpdateTimestamp = nil strongSelf.tick() } } @@ -235,6 +265,7 @@ private final class MediaPlayerContext { strongSelf.state = .paused(loadedState) } + strongSelf.lastStatusUpdateTimestamp = nil strongSelf.tick() } } @@ -248,11 +279,14 @@ private final class MediaPlayerContext { switch self.state { case .empty: + self.lastStatusUpdateTimestamp = nil self.seek(timestamp: 0.0, action: .play) case let .seeking(frameSource, timestamp, disposable, _): self.state = .seeking(frameSource: frameSource, timestamp: timestamp, disposable: disposable, action: .play) + self.lastStatusUpdateTimestamp = nil case let .paused(loadedState): self.state = .playing(loadedState) + self.lastStatusUpdateTimestamp = nil self.tick() case .playing: break @@ -267,14 +301,36 @@ private final class MediaPlayerContext { break case let .seeking(frameSource, timestamp, disposable, _): self.state = .seeking(frameSource: frameSource, timestamp: timestamp, disposable: disposable, action: .pause) + self.lastStatusUpdateTimestamp = nil case .paused: break case let .playing(loadedState): self.state = .paused(loadedState) + self.lastStatusUpdateTimestamp = nil self.tick() } } + fileprivate func togglePlayPause() { + assert(self.queue.isCurrent()) + + switch self.state { + case .empty: + break + case let .seeking(_, _, _, action): + switch action { + case .play: + self.pause() + case .pause: + self.play() + } + case .paused: + self.play() + case .playing: + self.pause() + } + } + private func tick() { self.tickTimer?.invalidate() @@ -311,7 +367,7 @@ private final class MediaPlayerContext { duration = max(duration, CMTimeGetSeconds(audioTrackFrameBuffer.duration)) } - var loopNow = false + var performActionAtEndNow = false var worstStatus: MediaTrackFrameBufferStatus? for status in [videoStatus, audioStatus] { @@ -355,15 +411,15 @@ private final class MediaPlayerContext { var buffering = false if let worstStatus = worstStatus, case let .full(fullUntil) = worstStatus, fullUntil.isFinite { - let nextTickDelay = max(0.0, fullUntil - timestamp) - let tickTimer = SwiftSignalKit.Timer(timeout: nextTickDelay, repeat: false, completion: { [weak self] in - self?.tick() - }, queue: self.queue) - self.tickTimer = tickTimer - tickTimer.start() - if case .playing = self.state { rate = 1.0 + + let nextTickDelay = max(0.0, fullUntil - timestamp) + let tickTimer = SwiftSignalKit.Timer(timeout: nextTickDelay, repeat: false, completion: { [weak self] in + self?.tick() + }, queue: self.queue) + self.tickTimer = tickTimer + tickTimer.start() } else { rate = 0.0 } @@ -371,16 +427,16 @@ private final class MediaPlayerContext { let nextTickDelay = max(0.0, finishedAt - timestamp) if nextTickDelay.isLessThanOrEqualTo(0.0) { rate = 0.0 - loopNow = true + performActionAtEndNow = true } else { - let tickTimer = SwiftSignalKit.Timer(timeout: nextTickDelay, repeat: false, completion: { [weak self] in - self?.tick() - }, queue: self.queue) - self.tickTimer = tickTimer - tickTimer.start() - if case .playing = self.state { rate = 1.0 + + let tickTimer = SwiftSignalKit.Timer(timeout: nextTickDelay, repeat: false, completion: { [weak self] in + self?.tick() + }, queue: self.queue) + self.tickTimer = tickTimer + tickTimer.start() } else { rate = 0.0 } @@ -422,62 +478,113 @@ private final class MediaPlayerContext { let playbackStatus: MediaPlayerPlaybackStatus if buffering { - playbackStatus = .buffering + var whilePlaying = false + if case .playing = self.state { + whilePlaying = true + } + playbackStatus = .buffering(whilePlaying: whilePlaying) } else if rate.isEqual(to: 1.0) { playbackStatus = .playing } else { playbackStatus = .paused } - let status = MediaPlayerStatus(duration: duration, timestamp: timestamp, status: playbackStatus) - self.status.set(.single(status)) + let statusTimestamp = CACurrentMediaTime() + if self.lastStatusUpdateTimestamp == nil || self.lastStatusUpdateTimestamp! < statusTimestamp + 500 { + lastStatusUpdateTimestamp = statusTimestamp + let status = MediaPlayerStatus(generationTimestamp: statusTimestamp, duration: duration, timestamp: min(max(timestamp, 0.0), duration), status: playbackStatus) + self.playerStatus.set(status) + } - if loopNow { - self.seek(timestamp: 0.0, action: .play) + if performActionAtEndNow { + switch self.actionAtEnd { + case .loop: + self.seek(timestamp: 0.0, action: .play) + case .stop: + self.pause() + case let .action(f): + self.pause() + f() + } } } } -enum MediaPlayerPlaybackStatus { +enum MediaPlayerPlaybackStatus: Equatable { case playing case paused - case buffering + case buffering(whilePlaying: Bool) + + static func ==(lhs: MediaPlayerPlaybackStatus, rhs: MediaPlayerPlaybackStatus) -> Bool { + switch lhs { + case .playing: + if case .playing = rhs { + return true + } else { + return false + } + case .paused: + if case .paused = rhs { + return true + } else { + return false + } + case let .buffering(whilePlaying): + if case .buffering(whilePlaying) = rhs { + return true + } else { + return false + } + } + } } -struct MediaPlayerStatus { +struct MediaPlayerStatus: Equatable { + let generationTimestamp: Double let duration: Double let timestamp: Double let status: MediaPlayerPlaybackStatus + + static func ==(lhs: MediaPlayerStatus, rhs: MediaPlayerStatus) -> Bool { + if !lhs.generationTimestamp.isEqual(to: rhs.generationTimestamp) { + return false + } + if !lhs.duration.isEqual(to: rhs.duration) { + return false + } + if !lhs.timestamp.isEqual(to: rhs.timestamp) { + return false + } + if lhs.status != rhs.status { + return false + } + return true + } } final class MediaPlayer { private let queue = Queue() private var contextRef: Unmanaged? + private let statusValue = ValuePromise(MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, timestamp: 0.0, status: .paused), ignoreRepeated: true) + var status: Signal { - return Signal { [weak self] subscriber in - let disposable = MetaDisposable() - - if let strongSelf = self { - strongSelf.queue.async { - if let context = strongSelf.contextRef?.takeUnretainedValue() { - disposable.set(context.status.get().start(next: { next in - subscriber.putNext(next) - }, error: { error in - subscriber.putError(error) - }, completed: { - subscriber.putCompletion() - })) - } + return self.statusValue.get() + } + + var actionAtEnd: MediaPlayerActionAtEnd = .stop { + didSet { + let value = self.actionAtEnd + self.queue.async { + if let context = self.contextRef?.takeUnretainedValue() { + context.actionAtEnd = value } } - - return disposable } } init(postbox: Postbox, resource: MediaResource) { self.queue.async { - let context = MediaPlayerContext(queue: self.queue, postbox: postbox, resource: resource) + let context = MediaPlayerContext(queue: self.queue, playerStatus: self.statusValue, postbox: postbox, resource: resource) self.contextRef = Unmanaged.passRetained(context) } } @@ -505,6 +612,14 @@ final class MediaPlayer { } } + func togglePlayPause() { + self.queue.async { + if let context = self.contextRef?.takeUnretainedValue() { + context.togglePlayPause() + } + } + } + func seek(timestamp: Double) { self.queue.async { if let context = self.contextRef?.takeUnretainedValue() { diff --git a/TelegramUI/MediaPlayerScrubbingNode.swift b/TelegramUI/MediaPlayerScrubbingNode.swift new file mode 100644 index 0000000000..0f53b8475d --- /dev/null +++ b/TelegramUI/MediaPlayerScrubbingNode.swift @@ -0,0 +1,350 @@ +import Foundation +import AsyncDisplayKit +import Display +import SwiftSignalKit + +enum MediaPlayerScrubbingNodeCap { + case square + case round +} + +private func generateHandleBackground(color: UIColor) -> UIImage? { + return generateImage(CGSize(width: 2.0, height: 4.0), contextGenerator: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(color.cgColor) + context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: 1.5, height: 1.5))) + context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: size.height - 1.5), size: CGSize(width: 1.5, height: 1.5))) + context.fill(CGRect(origin: CGPoint(x: 0.0, y: 1.5 / 2.0), size: CGSize(width: 1.5, height: size.height - 1.5))) + })?.stretchableImage(withLeftCapWidth: 0, topCapHeight: 2) +} + +private final class MediaPlayerScrubbingNodeButton: ASButtonNode { + var beginScrubbing: (() -> Void)? + var endScrubbing: ((Bool) -> Void)? + var updateScrubbing: ((CGFloat) -> Void)? + + private var scrubbingStartLocation: CGPoint? + + override func didLoad() { + super.didLoad() + + self.view.disablesInteractiveTransitionGestureRecognizer = true + } + + override func beginTracking(with touch: UITouch, with event: UIEvent?) -> Bool { + if super.beginTracking(with: touch, with: event) { + scrubbingStartLocation = touch.location(in: self.view) + self.beginScrubbing?() + return true + } else { + return false + } + } + + override func continueTracking(with touch: UITouch, with touchEvent: UIEvent?) -> Bool { + if super.continueTracking(with: touch, with: touchEvent) { + let location = touch.location(in: self.view) + if let scrubbingStartLocation = self.scrubbingStartLocation { + let delta = location.x - scrubbingStartLocation.x + self.updateScrubbing?(delta / self.bounds.size.width) + } + return true + } else { + return false + } + } + + override func endTracking(with touch: UITouch?, with event: UIEvent?) { + super.endTracking(with: touch, with: event) + if let touch = touch { + let location = touch.location(in: self.view) + if let scrubbingStartLocation = self.scrubbingStartLocation { + let delta = location.x - scrubbingStartLocation.x + self.updateScrubbing?(delta / self.bounds.size.width) + } + } + self.scrubbingStartLocation = nil + self.endScrubbing?(true) + } + + override func cancelTracking(with event: UIEvent?) { + super.cancelTracking(with: event) + self.scrubbingStartLocation = nil + self.endScrubbing?(false) + } +} + +private final class MediaPlayerScrubbingForegroundNode: ASDisplayNode { + var onEnterHierarchy: (() -> Void)? + + override func willEnterHierarchy() { + super.willEnterHierarchy() + + self.onEnterHierarchy?() + } +} + +final class MediaPlayerScrubbingNode: ASDisplayNode { + private let lineHeight: CGFloat + + private let backgroundNode: ASImageNode + private let foregroundContentNode: ASImageNode + private let foregroundNode: MediaPlayerScrubbingForegroundNode + private let handleNode: ASDisplayNode? + private let handleNodeContainer: MediaPlayerScrubbingNodeButton? + + private var playbackStatusValue: MediaPlayerPlaybackStatus? + private var scrubbingBeginTimestamp: Double? + private var scrubbingTimestamp: Double? + + var playbackStatusUpdated: ((MediaPlayerPlaybackStatus?) -> Void)? + var seek: ((Double) -> Void)? + + private var statusValue: MediaPlayerStatus? { + didSet { + if self.statusValue != oldValue { + self.updateProgress() + + let playbackStatus = self.statusValue?.status + if self.playbackStatusValue != playbackStatus { + self.playbackStatusValue = playbackStatus + if let playbackStatusUpdated = self.playbackStatusUpdated { + playbackStatusUpdated(playbackStatus) + } + } + } + } + } + + private var statusDisposable: Disposable? + private var statusValuePromise = Promise() + + var status: Signal? { + didSet { + if let status = self.status { + self.statusValuePromise.set(status) + } else { + self.statusValuePromise.set(.never()) + } + } + } + + init(lineHeight: CGFloat, lineCap: MediaPlayerScrubbingNodeCap, scrubberHandle: Bool, backgroundColor: UIColor, foregroundColor: UIColor) { + self.lineHeight = lineHeight + + self.backgroundNode = ASImageNode() + self.backgroundNode.isLayerBacked = true + self.backgroundNode.displaysAsynchronously = false + self.backgroundNode.displayWithoutProcessing = true + + self.foregroundContentNode = ASImageNode() + self.foregroundContentNode.isLayerBacked = true + self.foregroundContentNode.displaysAsynchronously = false + self.foregroundContentNode.displayWithoutProcessing = true + + switch lineCap { + case .round: + self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: lineHeight, color: backgroundColor) + self.foregroundContentNode.image = generateStretchableFilledCircleImage(diameter: lineHeight, color: foregroundColor) + case .square: + self.backgroundNode.backgroundColor = backgroundColor + self.foregroundContentNode.backgroundColor = foregroundColor + } + + self.foregroundNode = MediaPlayerScrubbingForegroundNode() + self.foregroundNode.isLayerBacked = true + self.foregroundNode.clipsToBounds = true + + if scrubberHandle { + let handleNode = ASImageNode() + handleNode.image = generateHandleBackground(color: foregroundColor) + handleNode.isLayerBacked = true + self.handleNode = handleNode + + let handleNodeContainer = MediaPlayerScrubbingNodeButton() + handleNodeContainer.addSubnode(handleNode) + self.handleNodeContainer = handleNodeContainer + } else { + self.handleNode = nil + self.handleNodeContainer = nil + } + + super.init() + + self.addSubnode(self.backgroundNode) + self.foregroundNode.addSubnode(self.foregroundContentNode) + self.addSubnode(self.foregroundNode) + + if let handleNodeContainer = self.handleNodeContainer { + self.addSubnode(handleNodeContainer) + handleNodeContainer.beginScrubbing = { [weak self] in + if let strongSelf = self { + if let statusValue = strongSelf.statusValue, Double(0.0).isLess(than: statusValue.duration) { + strongSelf.scrubbingBeginTimestamp = statusValue.timestamp + strongSelf.scrubbingTimestamp = statusValue.timestamp + strongSelf.updateProgress() + } + } + } + handleNodeContainer.updateScrubbing = { [weak self] addedFraction in + if let strongSelf = self { + if let statusValue = strongSelf.statusValue, let scrubbingBeginTimestamp = strongSelf.scrubbingBeginTimestamp, Double(0.0).isLess(than: statusValue.duration) { + strongSelf.scrubbingTimestamp = scrubbingBeginTimestamp + statusValue.duration * Double(addedFraction) + strongSelf.updateProgress() + } + } + } + handleNodeContainer.endScrubbing = { [weak self] apply in + if let strongSelf = self { + strongSelf.scrubbingBeginTimestamp = nil + let scrubbingTimestamp = strongSelf.scrubbingTimestamp + strongSelf.scrubbingTimestamp = nil + if let scrubbingTimestamp = scrubbingTimestamp, apply { + strongSelf.seek?(scrubbingTimestamp) + } + strongSelf.updateProgress() + } + } + } + + self.foregroundNode.onEnterHierarchy = { [weak self] in + self?.updateProgress() + } + + self.statusDisposable = (self.statusValuePromise.get() + |> deliverOnMainQueue).start(next: { [weak self] status in + if let strongSelf = self { + strongSelf.statusValue = status + } + }) + } + + deinit { + self.statusDisposable?.dispose() + } + + override var frame: CGRect { + didSet { + if self.frame.size != oldValue.size { + self.updateProgress() + } + } + } + + private func preparedAnimation(keyPath: String, from: NSValue, to: NSValue, duration: Double, beginTime: Double?, offset: Double, speed: Float, repeatForever: Bool = false) -> CAAnimation { + let animation = CABasicAnimation(keyPath: keyPath) + animation.fromValue = from + animation.toValue = to + animation.duration = duration + animation.isRemovedOnCompletion = true + animation.fillMode = kCAFillModeBoth + animation.speed = speed + animation.timeOffset = offset + animation.isAdditive = false + animation.repeatCount = Float.infinity + if let beginTime = beginTime { + animation.beginTime = beginTime + } + return animation + } + + private func updateProgress() { + self.foregroundNode.layer.removeAnimation(forKey: "playback-bounds") + self.foregroundNode.layer.removeAnimation(forKey: "playback-position") + if let handleNodeContainer = self.handleNodeContainer { + handleNodeContainer.layer.removeAnimation(forKey: "playback-bounds") + } + + let bounds = self.bounds + let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: floor((bounds.size.height - self.lineHeight) / 2.0)), size: CGSize(width: bounds.size.width, height: self.lineHeight)) + self.backgroundNode.frame = backgroundFrame + self.foregroundContentNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: backgroundFrame.size.width, height: backgroundFrame.size.height)) + + if let handleNode = self.handleNode { + handleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 2.0, height: bounds.size.height)) + handleNode.layer.removeAnimation(forKey: "playback-position") + } + + if let handleNodeContainer = self.handleNodeContainer { + handleNodeContainer.frame = bounds + } + + let timestampAndDuration: (timestamp: Double, duration: Double)? + if let statusValue = self.statusValue, Double(0.0).isLess(than: statusValue.duration) { + if let scrubbingTimestamp = self.scrubbingTimestamp { + timestampAndDuration = (max(0.0, min(scrubbingTimestamp, statusValue.duration)), statusValue.duration) + } else { + timestampAndDuration = (statusValue.timestamp, statusValue.duration) + } + } else { + timestampAndDuration = nil + } + + if let (timestamp, duration) = timestampAndDuration { + let progress = CGFloat(timestamp / duration) + if let _ = scrubbingTimestamp { + let fromRect = CGRect(origin: backgroundFrame.origin, size: CGSize(width: 0.0, height: backgroundFrame.size.height)) + let toRect = CGRect(origin: backgroundFrame.origin, size: CGSize(width: backgroundFrame.size.width, height: backgroundFrame.size.height)) + + let fromBounds = CGRect(origin: CGPoint(), size: fromRect.size) + let toBounds = CGRect(origin: CGPoint(), size: toRect.size) + + self.foregroundNode.frame = toRect + self.foregroundNode.layer.add(self.preparedAnimation(keyPath: "bounds", from: NSValue(cgRect: fromBounds), to: NSValue(cgRect: toBounds), duration: duration, beginTime: nil, offset: timestamp, speed: 0.0), forKey: "playback-bounds") + self.foregroundNode.layer.add(self.preparedAnimation(keyPath: "position", from: NSValue(cgPoint: CGPoint(x: fromRect.midX, y: fromRect.midY)), to: NSValue(cgPoint: CGPoint(x: toRect.midX, y: toRect.midY)), duration: duration, beginTime: nil, offset: timestamp, speed: 0.0), forKey: "playback-position") + + if let handleNodeContainer = self.handleNodeContainer { + let fromBounds = bounds + let toBounds = bounds.offsetBy(dx: -bounds.size.width, dy: 0.0) + + handleNodeContainer.isHidden = false + handleNodeContainer.layer.add(self.preparedAnimation(keyPath: "bounds", from: NSValue(cgRect: fromBounds), to: NSValue(cgRect: toBounds), duration: duration, beginTime: nil, offset: timestamp, speed: 0.0), forKey: "playback-bounds") + } + + if let handleNode = self.handleNode { + let fromPosition = handleNode.position + let toPosition = CGPoint(x: fromPosition.x - 1.0, y: fromPosition.y) + handleNode.layer.add(self.preparedAnimation(keyPath: "position", from: NSValue(cgPoint: fromPosition), to: NSValue(cgPoint: toPosition), duration: duration / Double(bounds.size.width), beginTime: nil, offset: timestamp, speed: 0.0, repeatForever: true), forKey: "playback-position") + } + } else if let statusValue = self.statusValue, !progress.isNaN && progress.isFinite { + let fromRect = CGRect(origin: backgroundFrame.origin, size: CGSize(width: 0.0, height: backgroundFrame.size.height)) + let toRect = CGRect(origin: backgroundFrame.origin, size: CGSize(width: backgroundFrame.size.width, height: backgroundFrame.size.height)) + + let fromBounds = CGRect(origin: CGPoint(), size: fromRect.size) + let toBounds = CGRect(origin: CGPoint(), size: toRect.size) + + self.foregroundNode.frame = toRect + self.foregroundNode.layer.add(self.preparedAnimation(keyPath: "bounds", from: NSValue(cgRect: fromBounds), to: NSValue(cgRect: toBounds), duration: duration, beginTime: statusValue.generationTimestamp, offset: timestamp, speed: statusValue.status == .playing ? 1.0 : 0.0), forKey: "playback-bounds") + self.foregroundNode.layer.add(self.preparedAnimation(keyPath: "position", from: NSValue(cgPoint: CGPoint(x: fromRect.midX, y: fromRect.midY)), to: NSValue(cgPoint: CGPoint(x: toRect.midX, y: toRect.midY)), duration: duration, beginTime: statusValue.generationTimestamp, offset: timestamp, speed: statusValue.status == .playing ? 1.0 : 0.0), forKey: "playback-position") + + if let handleNodeContainer = self.handleNodeContainer { + let fromBounds = bounds + let toBounds = bounds.offsetBy(dx: -bounds.size.width, dy: 0.0) + + handleNodeContainer.isHidden = false + handleNodeContainer.layer.add(self.preparedAnimation(keyPath: "bounds", from: NSValue(cgRect: fromBounds), to: NSValue(cgRect: toBounds), duration: statusValue.duration, beginTime: statusValue.generationTimestamp, offset: statusValue.timestamp, speed: statusValue.status == .playing ? 1.0 : 0.0), forKey: "playback-bounds") + } + + if let handleNode = self.handleNode { + let fromPosition = handleNode.position + let toPosition = CGPoint(x: fromPosition.x - 1.0, y: fromPosition.y) + handleNode.layer.add(self.preparedAnimation(keyPath: "position", from: NSValue(cgPoint: fromPosition), to: NSValue(cgPoint: toPosition), duration: duration / Double(bounds.size.width), beginTime: statusValue.generationTimestamp, offset: timestamp, speed: statusValue.status == .playing ? 1.0 : 0.0, repeatForever: true), forKey: "playback-position") + } + } else { + self.handleNodeContainer?.isHidden = true + self.foregroundNode.frame = CGRect(origin: backgroundFrame.origin, size: CGSize(width: 0.0, height: backgroundFrame.size.height)) + } + } else { + self.foregroundNode.frame = CGRect(origin: backgroundFrame.origin, size: CGSize(width: 0.0, height: backgroundFrame.size.height)) + self.handleNodeContainer?.isHidden = true + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + if self.bounds.contains(point) { + return self.handleNodeContainer?.view + } else { + return nil + } + } +} diff --git a/TelegramUI/MediaPlayerTimeTextNode.swift b/TelegramUI/MediaPlayerTimeTextNode.swift new file mode 100644 index 0000000000..1e3e2b2184 --- /dev/null +++ b/TelegramUI/MediaPlayerTimeTextNode.swift @@ -0,0 +1,140 @@ +import Foundation +import AsyncDisplayKit +import SwiftSignalKit +import Display + +private let textFont = Font.regular(13.0) + +enum MediaPlayerTimeTextNodeMode { + case normal + case reversed +} + +private struct MediaPlayerTimeTextNodeState: Equatable { + let hours: Int32 + let minutes: Int32 + let seconds: Int32 + + init() { + self.hours = 0 + self.minutes = 0 + self.seconds = 0 + } + + init(hours: Int32, minutes: Int32, seconds: Int32) { + self.hours = hours + self.minutes = minutes + self.seconds = seconds + } + + static func ==(lhs: MediaPlayerTimeTextNodeState, rhs: MediaPlayerTimeTextNodeState) -> Bool { + if lhs.hours != rhs.hours || lhs.minutes != rhs.minutes || lhs.seconds != rhs.seconds { + return false + } + return true + } +} + +private final class MediaPlayerTimeTextNodeParameters: NSObject { + let state: MediaPlayerTimeTextNodeState + let alignment: NSTextAlignment + let mode: MediaPlayerTimeTextNodeMode + + init(state: MediaPlayerTimeTextNodeState, alignment: NSTextAlignment, mode: MediaPlayerTimeTextNodeMode) { + self.state = state + self.alignment = alignment + self.mode = mode + super.init() + } +} + +final class MediaPlayerTimeTextNode: ASDisplayNode { + var alignment: NSTextAlignment = .left + var mode: MediaPlayerTimeTextNodeMode = .normal + + private var statusValue: MediaPlayerStatus? { + didSet { + if self.statusValue != oldValue { + self.updateTimestamp() + } + } + } + + private var state = MediaPlayerTimeTextNodeState() { + didSet { + if self.state != oldValue { + self.setNeedsDisplay() + } + } + } + + private var statusDisposable: Disposable? + private var statusValuePromise = Promise() + + var status: Signal? { + didSet { + if let status = self.status { + self.statusValuePromise.set(status) + } else { + self.statusValuePromise.set(.never()) + } + } + } + + override init() { + super.init() + self.isOpaque = false + + self.statusDisposable = (self.statusValuePromise.get() + |> deliverOnMainQueue).start(next: { [weak self] status in + if let strongSelf = self { + strongSelf.statusValue = status + } + }) + } + + deinit { + self.statusDisposable?.dispose() + } + + func updateTimestamp() { + if let statusValue = self.statusValue, Double(0.0).isLess(than: statusValue.duration) { + switch self.mode { + case .normal: + let timestamp = Int32(statusValue.timestamp) + self.state = MediaPlayerTimeTextNodeState(hours: timestamp / (60 * 60), minutes: timestamp % (60 * 60) / 60, seconds: timestamp % 60) + case .reversed: + let timestamp = abs(Int32(statusValue.timestamp - statusValue.duration)) + self.state = MediaPlayerTimeTextNodeState(hours: timestamp / (60 * 60), minutes: timestamp % (60 * 60) / 60, seconds: timestamp % 60) + } + } else { + self.state = MediaPlayerTimeTextNodeState() + } + } + + override func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { + return MediaPlayerTimeTextNodeParameters(state: self.state, alignment: self.alignment, mode: self.mode) + } + + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { + let context = UIGraphicsGetCurrentContext()! + + if !isRasterizing { + context.setBlendMode(.copy) + context.setFillColor(UIColor.clear.cgColor) + context.fill(bounds) + } + + if let parameters = parameters as? MediaPlayerTimeTextNodeParameters { + let text = String(format: "%d:%02d", parameters.state.minutes, parameters.state.seconds) + let string = NSAttributedString(string: text, font: textFont, textColor: UIColor(0x686669)) + let size = string.boundingRect(with: CGSize(width: 200.0, height: 100.0), options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil).size + + if parameters.alignment == .left { + string.draw(at: CGPoint()) + } else { + string.draw(at: CGPoint(x: bounds.size.width - size.width, y: 0.0)) + } + } + } +} diff --git a/TelegramUI/MediaTrackFrameBuffer.swift b/TelegramUI/MediaTrackFrameBuffer.swift index 7071f54193..d3ce979502 100644 --- a/TelegramUI/MediaTrackFrameBuffer.swift +++ b/TelegramUI/MediaTrackFrameBuffer.swift @@ -29,6 +29,7 @@ final class MediaTrackFrameBuffer { private var frameSourceSinkIndex: Int? private var frames: [MediaTrackDecodableFrame] = [] + private var endOfStream = false private var bufferedUntilTime: CMTime? init(frameSource: MediaFrameSource, decoder: MediaTrackFrameDecoder, type: MediaTrackFrameType, duration: CMTime) { @@ -50,6 +51,8 @@ final class MediaTrackFrameBuffer { if !filteredFrames.isEmpty { strongSelf.addFrames(filteredFrames) } + case .endOfStream: + strongSelf.endOfStreamReached() } } } @@ -79,10 +82,15 @@ final class MediaTrackFrameBuffer { self.statusUpdated() } + private func endOfStreamReached() { + self.endOfStream = true + self.statusUpdated() + } + func status(at timestamp: Double) -> MediaTrackFrameBufferStatus { var bufferedDuration = 0.0 if let bufferedUntilTime = bufferedUntilTime { - if CMTimeCompare(bufferedUntilTime, self.duration) >= 0 { + if CMTimeCompare(bufferedUntilTime, self.duration) >= 0 || self.endOfStream { return .finished(at: CMTimeGetSeconds(bufferedUntilTime)) } diff --git a/TelegramUI/PeerMediaAudioPlaylist.swift b/TelegramUI/PeerMediaAudioPlaylist.swift index f8bde32bac..824586c54d 100644 --- a/TelegramUI/PeerMediaAudioPlaylist.swift +++ b/TelegramUI/PeerMediaAudioPlaylist.swift @@ -10,8 +10,8 @@ struct PeerMessageHistoryAudioPlaylistItemId: AudioPlaylistItemId { return self.id.hashValue } - func isEqual(other: AudioPlaylistItemId) -> Bool { - if let other = other as? PeerMessageHistoryAudioPlaylistItemId { + func isEqual(to: AudioPlaylistItemId) -> Bool { + if let other = to as? PeerMessageHistoryAudioPlaylistItemId { return self.id == other.id } else { return false @@ -68,8 +68,8 @@ private final class PeerMessageHistoryAudioPlaylistItem: AudioPlaylistItem { self.entry = entry } - func isEqual(other: AudioPlaylistItem) -> Bool { - if let other = other as? PeerMessageHistoryAudioPlaylistItem { + func isEqual(to: AudioPlaylistItem) -> Bool { + if let other = to as? PeerMessageHistoryAudioPlaylistItem { return self.entry == other.entry } else { return false @@ -80,8 +80,8 @@ private final class PeerMessageHistoryAudioPlaylistItem: AudioPlaylistItem { struct PeerMessageHistoryAudioPlaylistId: AudioPlaylistId { let peerId: PeerId - func isEqual(other: AudioPlaylistId) -> Bool { - if let other = other as? PeerMessageHistoryAudioPlaylistId { + func isEqual(to: AudioPlaylistId) -> Bool { + if let other = to as? PeerMessageHistoryAudioPlaylistId { if self.peerId != other.peerId { return false } @@ -92,15 +92,51 @@ struct PeerMessageHistoryAudioPlaylistId: AudioPlaylistId { } } +func peerMessageAudioPlaylistAndItemIds(_ message: Message) -> (AudioPlaylistId, AudioPlaylistItemId)? { + return (PeerMessageHistoryAudioPlaylistId(peerId: message.id.peerId), PeerMessageHistoryAudioPlaylistItemId(id: message.id)) +} + func peerMessageHistoryAudioPlaylist(account: Account, messageId: MessageId) -> AudioPlaylist { return AudioPlaylist(id: PeerMessageHistoryAudioPlaylistId(peerId: messageId.peerId), navigate: { item, navigation in - return account.postbox.messageAtId(messageId) - |> map { message -> AudioPlaylistItem? in - if let message = message { - return PeerMessageHistoryAudioPlaylistItem(entry: .MessageEntry(message, false, nil)) - } else { - return nil + if let item = item as? PeerMessageHistoryAudioPlaylistItem { + return account.postbox.aroundMessageHistoryViewForPeerId(item.entry.index.id.peerId, index: item.entry.index, count: 10, anchorIndex: item.entry.index, fixedCombinedReadState: nil, tagMask: .Music) + |> take(1) + |> map { (view, _, _) -> AudioPlaylistItem? in + var index = 0 + for entry in view.entries { + if entry.index.id == item.entry.index.id { + switch navigation { + case .previous: + if index + 1 < view.entries.count { + return PeerMessageHistoryAudioPlaylistItem(entry: view.entries[index + 1]) + } else { + return PeerMessageHistoryAudioPlaylistItem(entry: view.entries.last!) + } + case .next: + if index != 0 { + return PeerMessageHistoryAudioPlaylistItem(entry: view.entries[index - 1]) + } else { + return PeerMessageHistoryAudioPlaylistItem(entry: view.entries.first!) + } + } + } + index += 1 + } + if !view.entries.isEmpty { + return PeerMessageHistoryAudioPlaylistItem(entry: view.entries.first!) + } else { + return nil + } } - } - }) + } else { + return account.postbox.messageAtId(messageId) + |> map { message -> AudioPlaylistItem? in + if let message = message { + return PeerMessageHistoryAudioPlaylistItem(entry: .MessageEntry(message, false, nil)) + } else { + return nil + } + } + } + }) } diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift index 65ce50bbab..ebf054213a 100644 --- a/TelegramUI/PeerMediaCollectionController.swift +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -186,6 +186,7 @@ public class PeerMediaCollectionController: ViewController { }, shareCurrentLocation: { }, shareAccountContact: { }, sendBotCommand: { _, _ in + }, openInstantPage: {_ in }, updateInputState: { _ in }) diff --git a/TelegramUI/PeerSelectionController.swift b/TelegramUI/PeerSelectionController.swift index 955e77ac62..ae245365b5 100644 --- a/TelegramUI/PeerSelectionController.swift +++ b/TelegramUI/PeerSelectionController.swift @@ -78,9 +78,21 @@ public final class PeerSelectionController: ViewController { } } - self.peerSelectionNode.requestOpenPeerFromSearch = { [weak self] peerId in + self.peerSelectionNode.requestOpenPeerFromSearch = { [weak self] peer in if let strongSelf = self { - (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: peerId)) + let storedPeer = strongSelf.account.postbox.modify { modifier -> Void in + if modifier.getPeer(peer.id) == nil { + modifier.updatePeers([peer], update: { previousPeer, updatedPeer in + return updatedPeer + }) + } + } + strongSelf.openMessageFromSearchDisposable.set((storedPeer |> deliverOnMainQueue).start(completed: { [weak strongSelf] in + if let strongSelf = strongSelf { + (strongSelf.navigationController as? NavigationController)?.pushViewController(ChatController(account: strongSelf.account, peerId: peer.id)) + } + })) + } } diff --git a/TelegramUI/PeerSelectionControllerNode.swift b/TelegramUI/PeerSelectionControllerNode.swift index 953a2771e9..171f0e6774 100644 --- a/TelegramUI/PeerSelectionControllerNode.swift +++ b/TelegramUI/PeerSelectionControllerNode.swift @@ -16,7 +16,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { private var containerLayout: (ContainerViewLayout, CGFloat)? var requestDeactivateSearch: (() -> Void)? - var requestOpenPeerFromSearch: ((PeerId) -> Void)? + var requestOpenPeerFromSearch: ((Peer) -> Void)? var requestOpenMessageFromSearch: ((Peer, MessageId) -> Void)? init(account: Account, dismiss: @escaping () -> Void) { @@ -89,14 +89,14 @@ final class PeerSelectionControllerNode: ASDisplayNode { } if let placeholderNode = maybePlaceholderNode { - self.searchDisplayController = SearchDisplayController(contentNode: ChatListSearchContainerNode(account: self.account, openPeer: { [weak self] peerId in + self.searchDisplayController = SearchDisplayController(contentNode: ChatListSearchContainerNode(account: self.account, openPeer: { [weak self] peer in if let requestOpenPeerFromSearch = self?.requestOpenPeerFromSearch { - requestOpenPeerFromSearch(peerId) + requestOpenPeerFromSearch(peer) + } + }, openMessage: { [weak self] peer, messageId in + if let requestOpenMessageFromSearch = self?.requestOpenMessageFromSearch { + requestOpenMessageFromSearch(peer, messageId) } - }, openMessage: { [weak self] peer, messageId in - if let requestOpenMessageFromSearch = self?.requestOpenMessageFromSearch { - requestOpenMessageFromSearch(peer, messageId) - } }), cancel: { [weak self] in if let requestDeactivateSearch = self?.requestDeactivateSearch { requestDeactivateSearch() diff --git a/TelegramUI/RadialProgressNode.swift b/TelegramUI/RadialProgressNode.swift index 1693d8cd1c..9083024d84 100644 --- a/TelegramUI/RadialProgressNode.swift +++ b/TelegramUI/RadialProgressNode.swift @@ -52,7 +52,7 @@ private class RadialProgressOverlayNode: ASDisplayNode { return RadialProgressOverlayParameters(theme: self.theme, diameter: self.frame.size.width, state: self.state) } - @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! if !isRasterizing { @@ -67,7 +67,7 @@ private class RadialProgressOverlayNode: ASDisplayNode { //CGContextSetLineCap(context, .Round) switch parameters.state { - case .None, .Remote, .Play, .Icon: + case .None, .Remote, .Play, .Pause, .Icon: break case let .Fetching(progress): let startAngle = -CGFloat(M_PI_2) @@ -109,6 +109,7 @@ public enum RadialProgressState { case Remote case Fetching(progress: Float) case Play + case Pause case Icon } @@ -163,6 +164,13 @@ class RadialProgressNode: ASControlNode { default: self.setNeedsDisplay() } + case .Pause: + switch self.state { + case .Pause: + break + default: + self.setNeedsDisplay() + } case .Icon: switch self.state { case .Icon: @@ -206,7 +214,7 @@ class RadialProgressNode: ASControlNode { return RadialProgressParameters(theme: self.theme, diameter: self.frame.size.width, state: self.state) } - @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! if !isRasterizing { @@ -274,6 +282,24 @@ class RadialProgressNode: ASControlNode { context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) } context.translateBy(x: -(parameters.diameter - size.width) / 2.0 - 1.5, y: -(parameters.diameter - size.height) / 2.0) + case .Pause: + context.setFillColor(parameters.theme.foregroundColor.cgColor) + + let size = CGSize(width: 15.0, height: 16.0) + context.translateBy(x: (parameters.diameter - size.width) / 2.0, y: (parameters.diameter - size.height) / 2.0) + if (parameters.diameter < 40.0) { + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 0.8, y: 0.8) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + } + let _ = try? drawSvgPath(context, path: "M0,1.00087166 C0,0.448105505 0.443716645,0 0.999807492,0 L4.00019251,0 C4.55237094,0 5,0.444630861 5,1.00087166 L5,14.9991283 C5,15.5518945 4.55628335,16 4.00019251,16 L0.999807492,16 C0.447629061,16 0,15.5553691 0,14.9991283 L0,1.00087166 Z M10,1.00087166 C10,0.448105505 10.4437166,0 10.9998075,0 L14.0001925,0 C14.5523709,0 15,0.444630861 15,1.00087166 L15,14.9991283 C15,15.5518945 14.5562834,16 14.0001925,16 L10.9998075,16 C10.4476291,16 10,15.5553691 10,14.9991283 L10,1.00087166 ") + context.fillPath() + if (parameters.diameter < 40.0) { + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 1.0 / 0.8, y: 1.0 / 0.8) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + } + context.translateBy(x: -(parameters.diameter - size.width) / 2.0, y: -(parameters.diameter - size.height) / 2.0) } } } diff --git a/TelegramUI/TelegramController.swift b/TelegramUI/TelegramController.swift index 932ffc3e55..dc5c263351 100644 --- a/TelegramUI/TelegramController.swift +++ b/TelegramUI/TelegramController.swift @@ -8,7 +8,7 @@ public class TelegramController: ViewController { private var mediaStatusDisposable: Disposable? - private var playlistState: AudioPlaylistState? + private var playlistStateAndStatus: AudioPlaylistStateAndStatus? private var mediaAccessoryPanel: MediaNavigationAccessoryPanel? override public var navigationHeight: CGFloat { @@ -25,11 +25,13 @@ public class TelegramController: ViewController { super.init(navigationBar: NavigationBar()) if let applicationContext = account.applicationContext as? TelegramApplicationContext { - self.mediaStatusDisposable = (applicationContext.mediaManager.playlistPlayerState - |> deliverOnMainQueue).start(next: { [weak self] playlistState in - if let strongSelf = self, strongSelf.playlistState != playlistState { - strongSelf.playlistState = playlistState - strongSelf.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) + self.mediaStatusDisposable = (applicationContext.mediaManager.playlistPlayerStateAndStatus + |> deliverOnMainQueue).start(next: { [weak self] playlistStateAndStatus in + if let strongSelf = self { + if strongSelf.playlistStateAndStatus != playlistStateAndStatus { + strongSelf.playlistStateAndStatus = playlistStateAndStatus + strongSelf.requestLayout(transition: .animated(duration: 0.4, curve: .spring)) + } } }) } @@ -46,13 +48,15 @@ public class TelegramController: ViewController { public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) - if let playlistState = playlistState { - let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: self.navigationBar.frame.maxY), size: CGSize(width: layout.size.width, height: 36.0)) + if let playlistStateAndStatus = playlistStateAndStatus { + let panelFrame = CGRect(origin: CGPoint(x: 0.0, y: self.navigationBar.frame.maxY), size: CGSize(width: layout.size.width, height: max(0.0, layout.size.height - self.navigationBar.frame.maxY - layout.insets(options: [.input]).bottom))) if let mediaAccessoryPanel = self.mediaAccessoryPanel { transition.updateFrame(node: mediaAccessoryPanel, frame: panelFrame) mediaAccessoryPanel.updateLayout(size: panelFrame.size, transition: transition) + mediaAccessoryPanel.containerNode.headerNode.stateAndStatus = playlistStateAndStatus + mediaAccessoryPanel.containerNode.itemListNode.stateAndStatus = playlistStateAndStatus } else { - let mediaAccessoryPanel = MediaNavigationAccessoryPanel() + let mediaAccessoryPanel = MediaNavigationAccessoryPanel(account: self.account) mediaAccessoryPanel.close = { [weak self] in if let strongSelf = self { if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { @@ -60,10 +64,40 @@ public class TelegramController: ViewController { } } } + mediaAccessoryPanel.togglePlayPause = { [weak self] in + if let strongSelf = self { + if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { + applicationContext.mediaManager.playlistPlayerControl(.playback(.togglePlayPause)) + } + } + } + mediaAccessoryPanel.previous = { [weak self] in + if let strongSelf = self { + if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { + applicationContext.mediaManager.playlistPlayerControl(.navigation(.previous)) + } + } + } + mediaAccessoryPanel.next = { [weak self] in + if let strongSelf = self { + if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { + applicationContext.mediaManager.playlistPlayerControl(.navigation(.next)) + } + } + } + mediaAccessoryPanel.seek = { [weak self] timestamp in + if let strongSelf = self { + if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { + applicationContext.mediaManager.playlistPlayerControl(.playback(.seek(timestamp))) + } + } + } mediaAccessoryPanel.frame = panelFrame self.displayNode.insertSubnode(mediaAccessoryPanel, belowSubnode: self.navigationBar) self.mediaAccessoryPanel = mediaAccessoryPanel mediaAccessoryPanel.updateLayout(size: panelFrame.size, transition: .immediate) + mediaAccessoryPanel.containerNode.headerNode.stateAndStatus = playlistStateAndStatus + mediaAccessoryPanel.containerNode.itemListNode.stateAndStatus = playlistStateAndStatus mediaAccessoryPanel.animateIn(transition: transition) } } else if let mediaAccessoryPanel = self.mediaAccessoryPanel { diff --git a/TelegramUI/TextNode.swift b/TelegramUI/TextNode.swift index d61783ae1b..09bfaa6975 100644 --- a/TelegramUI/TextNode.swift +++ b/TelegramUI/TextNode.swift @@ -252,7 +252,7 @@ final class TextNode: ASDisplayNode { return self.cachedLayout } - @objc override class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: @escaping asdisplaynode_iscancelled_block_t, isRasterizing: Bool) { + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: NSObjectProtocol?, isCancelled: () -> Bool, isRasterizing: Bool) { let context = UIGraphicsGetCurrentContext()! context.setAllowsAntialiasing(true) diff --git a/TelegramUI/VerticalListContextResultsChatInputContextPanelNode.swift b/TelegramUI/VerticalListContextResultsChatInputContextPanelNode.swift index 83f0957ddd..80fbef02a2 100644 --- a/TelegramUI/VerticalListContextResultsChatInputContextPanelNode.swift +++ b/TelegramUI/VerticalListContextResultsChatInputContextPanelNode.swift @@ -4,36 +4,88 @@ import Postbox import TelegramCore import Display -private struct ChatContextResultStableId: Hashable { - let result: ChatContextResult +private enum VerticalChatContextResultsEntryStableId: Hashable { + case action + case result(ChatContextResult) var hashValue: Int { - return result.id.hashValue + switch self { + case .action: + return 0 + case let .result(result): + return result.id.hashValue + } } - static func ==(lhs: ChatContextResultStableId, rhs: ChatContextResultStableId) -> Bool { - return lhs.result == rhs.result + static func ==(lhs: VerticalChatContextResultsEntryStableId, rhs: VerticalChatContextResultsEntryStableId) -> Bool { + switch lhs { + case .action: + if case .action = rhs { + return true + } else { + return false + } + case let .result(lhsResult): + if case let .result(rhsResult) = rhs, lhsResult == rhsResult { + return true + } else { + return false + } + } } } -private struct VerticalListContextResultsChatInputContextPanelEntry: Equatable, Comparable, Identifiable { - let index: Int - let result: ChatContextResult +private enum VerticalListContextResultsChatInputContextPanelEntry: Comparable, Identifiable { + case action(String) + case result(Int, ChatContextResult) - var stableId: ChatContextResultStableId { - return ChatContextResultStableId(result: self.result) + var stableId: VerticalChatContextResultsEntryStableId { + switch self { + case .action: + return .action + case let .result(_, result): + return .result(result) + } } static func ==(lhs: VerticalListContextResultsChatInputContextPanelEntry, rhs: VerticalListContextResultsChatInputContextPanelEntry) -> Bool { - return lhs.index == rhs.index && lhs.result == rhs.result + switch lhs { + case let .action(title): + if case .action(title) = rhs { + return true + } else { + return false + } + case let .result(index, result): + if case .result(index, result) = rhs { + return true + } else { + return false + } + } } static func <(lhs: VerticalListContextResultsChatInputContextPanelEntry, rhs: VerticalListContextResultsChatInputContextPanelEntry) -> Bool { - return lhs.index < rhs.index + switch lhs { + case .action: + return true + case let .result(index, _): + switch rhs { + case .action: + return false + case let .result(rhsIndex, _): + return index < rhsIndex + } + } } - func item(account: Account, resultSelected: @escaping (ChatContextResult) -> Void) -> ListViewItem { - return VerticalListContextResultsChatInputPanelItem(account: account, result: self.result, resultSelected: resultSelected) + func item(account: Account, actionSelected: @escaping () -> Void, resultSelected: @escaping (ChatContextResult) -> Void) -> ListViewItem { + switch self { + case let .action(title): + return VerticalListContextResultsChatInputPanelButtonItem(title: title, pressed: actionSelected) + case let .result(_, result): + return VerticalListContextResultsChatInputPanelItem(account: account, result: result, resultSelected: resultSelected) + } } } @@ -43,12 +95,12 @@ private struct VerticalListContextResultsChatInputContextPanelTransition { let updates: [ListViewUpdateItem] } -private func preparedTransition(from fromEntries: [VerticalListContextResultsChatInputContextPanelEntry], to toEntries: [VerticalListContextResultsChatInputContextPanelEntry], account: Account, resultSelected: @escaping (ChatContextResult) -> Void) -> VerticalListContextResultsChatInputContextPanelTransition { +private func preparedTransition(from fromEntries: [VerticalListContextResultsChatInputContextPanelEntry], to toEntries: [VerticalListContextResultsChatInputContextPanelEntry], account: Account, actionSelected: @escaping () -> Void, resultSelected: @escaping (ChatContextResult) -> Void) -> VerticalListContextResultsChatInputContextPanelTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, resultSelected: resultSelected), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, resultSelected: resultSelected), directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, actionSelected: actionSelected, resultSelected: resultSelected), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, actionSelected: actionSelected, resultSelected: resultSelected), directionHint: nil) } return VerticalListContextResultsChatInputContextPanelTransition(deletions: deletions, insertions: insertions, updates: updates) } @@ -81,9 +133,14 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex self.currentResults = results var entries: [VerticalListContextResultsChatInputContextPanelEntry] = [] var index = 0 - var resultIds = Set() + var resultIds = Set() + if let switchPeer = results.switchPeer { + let entry: VerticalListContextResultsChatInputContextPanelEntry = .action(switchPeer.text) + entries.append(entry) + resultIds.insert(entry.stableId) + } for result in results.results { - let entry = VerticalListContextResultsChatInputContextPanelEntry(index: index, result: result) + let entry: VerticalListContextResultsChatInputContextPanelEntry = .result(index, result) if resultIds.contains(entry.stableId) { continue } else { @@ -94,7 +151,11 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex } let firstTime = self.currentEntries == nil - let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, account: self.account, resultSelected: { [weak self] result in + let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, account: self.account, actionSelected: { [weak self] in + if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { + + } + }, resultSelected: { [weak self] result in if let strongSelf = self, let interfaceInteraction = strongSelf.interfaceInteraction { interfaceInteraction.sendContextResult(results, result) } @@ -126,7 +187,7 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex } var insets = UIEdgeInsets() - insets.top = topInsetForLayout(size: self.listView.bounds.size) + insets.top = topInsetForLayout(size: self.listView.bounds.size, hasSwitchPeer: self.currentResults?.switchPeer != nil) let updateSizeAndInsets = ListViewUpdateSizeAndInsets(size: self.listView.bounds.size, insets: insets, duration: 0.0, curve: .Default) @@ -150,15 +211,18 @@ final class VerticalListContextResultsChatInputContextPanelNode: ChatInputContex } } - private func topInsetForLayout(size: CGSize) -> CGFloat { + private func topInsetForLayout(size: CGSize, hasSwitchPeer: Bool) -> CGFloat { var minimumItemHeights: CGFloat = floor(VerticalListContextResultsChatInputPanelItemNode.itemHeight * 3.5) + if hasSwitchPeer { + minimumItemHeights += VerticalListContextResultsChatInputPanelButtonItemNode.itemHeight + } return max(size.height - minimumItemHeights, 0.0) } override func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) { var insets = UIEdgeInsets() - insets.top = self.topInsetForLayout(size: size) + insets.top = self.topInsetForLayout(size: size, hasSwitchPeer: self.currentResults?.switchPeer != nil) transition.updateFrame(node: self.listView, frame: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height)) diff --git a/TelegramUI/VerticalListContextResultsChatInputPanelButtonItem.swift b/TelegramUI/VerticalListContextResultsChatInputPanelButtonItem.swift new file mode 100644 index 0000000000..25bf75b0ac --- /dev/null +++ b/TelegramUI/VerticalListContextResultsChatInputPanelButtonItem.swift @@ -0,0 +1,158 @@ +import Foundation +import AsyncDisplayKit +import Display +import TelegramCore +import SwiftSignalKit +import Postbox + +final class VerticalListContextResultsChatInputPanelButtonItem: ListViewItem { + fileprivate let title: String + fileprivate let pressed: () -> Void + + public init(title: String, pressed: @escaping () -> Void) { + self.title = title + self.pressed = pressed + } + + public func nodeConfiguredForWidth(async: @escaping (@escaping () -> Void) -> Void, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> Void) -> Void) { + let configure = { () -> Void in + let node = VerticalListContextResultsChatInputPanelButtonItemNode() + + let nodeLayout = node.asyncLayout() + let (top, bottom) = (previousItem != nil, nextItem != nil) + let (layout, apply) = nodeLayout(self, width, top, bottom) + + node.contentSize = layout.contentSize + node.insets = layout.insets + + completion(node, { + apply(.None) + }) + } + if Thread.isMainThread { + async { + configure() + } + } else { + configure() + } + } + + public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: ListViewItemNode, width: CGFloat, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping () -> Void) -> Void) { + if let node = node as? VerticalListContextResultsChatInputPanelButtonItemNode { + Queue.mainQueue().async { + let nodeLayout = node.asyncLayout() + + async { + let (top, bottom) = (previousItem != nil, nextItem != nil) + + let (layout, apply) = nodeLayout(self, width, top, bottom) + Queue.mainQueue().async { + completion(layout, { + apply(animation) + }) + } + } + } + } else { + assertionFailure() + } + } +} + +private let titleFont = Font.regular(15.0) + +final class VerticalListContextResultsChatInputPanelButtonItemNode: ListViewItemNode { + static let itemHeight: CGFloat = 32.0 + + private let buttonNode: HighlightTrackingButtonNode + private let titleNode: TextNode + private let topSeparatorNode: ASDisplayNode + private let separatorNode: ASDisplayNode + + private var item: VerticalListContextResultsChatInputPanelButtonItem? + + init() { + self.buttonNode = HighlightTrackingButtonNode() + + self.topSeparatorNode = ASDisplayNode() + self.topSeparatorNode.backgroundColor = UIColor(0xC9CDD1) + self.topSeparatorNode.isLayerBacked = true + + self.separatorNode = ASDisplayNode() + self.separatorNode.backgroundColor = UIColor(0xD6D6DA) + self.separatorNode.isLayerBacked = true + + self.titleNode = TextNode() + + super.init(layerBacked: false, dynamicBounce: false) + + self.backgroundColor = .white + + self.addSubnode(self.topSeparatorNode) + self.addSubnode(self.separatorNode) + + self.addSubnode(self.titleNode) + self.addSubnode(self.buttonNode) + + self.buttonNode.highligthedChanged = { [weak self] highlighted in + if let strongSelf = self { + if highlighted { + strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") + strongSelf.titleNode.alpha = 0.4 + } else { + strongSelf.titleNode.alpha = 1.0 + strongSelf.titleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) + } + } + } + self.buttonNode.addTarget(self, action: #selector(buttonPressed), forControlEvents: .touchUpInside) + } + + override public func layoutForWidth(_ width: CGFloat, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { + if let item = item as? VerticalListContextResultsChatInputPanelButtonItem { + let doLayout = self.asyncLayout() + let merged = (top: previousItem != nil, bottom: nextItem != nil) + let (layout, apply) = doLayout(item, width, merged.top, merged.bottom) + self.contentSize = layout.contentSize + self.insets = layout.insets + apply(.None) + } + } + + func asyncLayout() -> (_ item: VerticalListContextResultsChatInputPanelButtonItem, _ width: CGFloat, _ mergedTop: Bool, _ mergedBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { + let makeTitleLayout = TextNode.asyncLayout(self.titleNode) + + return { [weak self] item, width, mergedTop, mergedBottom in + let titleString = NSAttributedString(string: item.title, font: titleFont, textColor: UIColor(0x007ee5)) + + let (titleLayout, titleApply) = makeTitleLayout(titleString, nil, 1, .end, CGSize(width: width - 16.0, height: 100.0), nil) + + let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: width, height: VerticalListContextResultsChatInputPanelButtonItemNode.itemHeight), insets: UIEdgeInsets()) + + return (nodeLayout, { _ in + if let strongSelf = self { + strongSelf.item = item + + titleApply() + + strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: floor((width - titleLayout.size.width) / 2.0), y: floor((nodeLayout.contentSize.height - titleLayout.size.height) / 2.0) + 2.0), size: titleLayout.size) + + strongSelf.topSeparatorNode.isHidden = mergedTop + strongSelf.separatorNode.isHidden = !mergedBottom + + strongSelf.topSeparatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)) + strongSelf.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: width, height: UIScreenPixel)) + + strongSelf.buttonNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) + } + }) + } + } + + @objc func buttonPressed() { + if let item = self.item { + item.pressed() + } + } +}