diff --git a/Images.xcassets/Media Gallery/BackwardButton.imageset/Contents.json b/Images.xcassets/Media Gallery/BackwardButton.imageset/Contents.json new file mode 100644 index 0000000000..714534a855 --- /dev/null +++ b/Images.xcassets/Media Gallery/BackwardButton.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "VideoPlayerBackwardIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "VideoPlayerBackwardIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@2x.png b/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@2x.png new file mode 100644 index 0000000000..dd3b84b849 Binary files /dev/null and b/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@2x.png differ diff --git a/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@3x.png b/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@3x.png new file mode 100644 index 0000000000..9b278a7135 Binary files /dev/null and b/Images.xcassets/Media Gallery/BackwardButton.imageset/VideoPlayerBackwardIcon@3x.png differ diff --git a/Images.xcassets/Media Gallery/ForwardButton.imageset/Contents.json b/Images.xcassets/Media Gallery/ForwardButton.imageset/Contents.json new file mode 100644 index 0000000000..263e68fb2a --- /dev/null +++ b/Images.xcassets/Media Gallery/ForwardButton.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "VideoPlayerForwardIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "VideoPlayerForwardIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@2x.png b/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@2x.png new file mode 100644 index 0000000000..34a661e33c Binary files /dev/null and b/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@2x.png differ diff --git a/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@3x.png b/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@3x.png new file mode 100644 index 0000000000..b4f4360d33 Binary files /dev/null and b/Images.xcassets/Media Gallery/ForwardButton.imageset/VideoPlayerForwardIcon@3x.png differ diff --git a/Images.xcassets/Open In/Contents.json b/Images.xcassets/Open In/Contents.json new file mode 100644 index 0000000000..38f0c81fc2 --- /dev/null +++ b/Images.xcassets/Open In/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "provides-namespace" : true + } +} \ No newline at end of file diff --git a/Images.xcassets/Open In/Maps.imageset/Contents.json b/Images.xcassets/Open In/Maps.imageset/Contents.json new file mode 100644 index 0000000000..d5bf557e5a --- /dev/null +++ b/Images.xcassets/Open In/Maps.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "ShareSearchIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "ShareSearchIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@2x.png b/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@2x.png new file mode 100644 index 0000000000..289227ed05 Binary files /dev/null and b/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@2x.png differ diff --git a/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@3x.png b/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@3x.png new file mode 100644 index 0000000000..837458279a Binary files /dev/null and b/Images.xcassets/Open In/Maps.imageset/ShareSearchIcon@3x.png differ diff --git a/Images.xcassets/Open In/Safari.imageset/Contents.json b/Images.xcassets/Open In/Safari.imageset/Contents.json new file mode 100644 index 0000000000..af75ade06d --- /dev/null +++ b/Images.xcassets/Open In/Safari.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "OpenInSafariIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "Safari@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Images.xcassets/Open In/Safari.imageset/OpenInSafariIcon@2x.png b/Images.xcassets/Open In/Safari.imageset/OpenInSafariIcon@2x.png new file mode 100644 index 0000000000..4cecc92b07 Binary files /dev/null and b/Images.xcassets/Open In/Safari.imageset/OpenInSafariIcon@2x.png differ diff --git a/Images.xcassets/Open In/Safari.imageset/Safari@3x.png b/Images.xcassets/Open In/Safari.imageset/Safari@3x.png new file mode 100644 index 0000000000..ea18cf1ea2 Binary files /dev/null and b/Images.xcassets/Open In/Safari.imageset/Safari@3x.png differ diff --git a/TelegramUI.xcodeproj/project.pbxproj b/TelegramUI.xcodeproj/project.pbxproj index d49e1a8a6f..2f3c15a00f 100644 --- a/TelegramUI.xcodeproj/project.pbxproj +++ b/TelegramUI.xcodeproj/project.pbxproj @@ -7,6 +7,23 @@ objects = { /* Begin PBXBuildFile section */ + 0941A9A0210B057200EBE194 /* OpenInActionSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0941A99F210B057200EBE194 /* OpenInActionSheetController.swift */; }; + 0941A9A4210B0E2E00EBE194 /* OpenInAppIconResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0941A9A3210B0E2E00EBE194 /* OpenInAppIconResources.swift */; }; + 0941A9A6210B822D00EBE194 /* OpenInOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0941A9A5210B822D00EBE194 /* OpenInOptions.swift */; }; + 09797873210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09797872210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift */; }; + 0979787C210642CB0077D77F /* WebEmbedPlayerNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0979787B210642CB0077D77F /* WebEmbedPlayerNode.swift */; }; + 0979787E210646C00077D77F /* YoutubeEmbedImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0979787D210646C00077D77F /* YoutubeEmbedImplementation.swift */; }; + 09874E4F21078FA100E190B8 /* Generic.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788321065F8C0077D77F /* Generic.html */; }; + 09874E5021078FA100E190B8 /* GenericUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788821065F8C0077D77F /* GenericUserScript.js */; }; + 09874E5121078FA100E190B8 /* Instagram.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788421065F8C0077D77F /* Instagram.html */; }; + 09874E5221078FA100E190B8 /* Twitch.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788521065F8C0077D77F /* Twitch.html */; }; + 09874E5321078FA100E190B8 /* TwitchUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788621065F8C0077D77F /* TwitchUserScript.js */; }; + 09874E5421078FA100E190B8 /* Vimeo.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788221065F8C0077D77F /* Vimeo.html */; }; + 09874E5521078FA100E190B8 /* VimeoUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788021065F8B0077D77F /* VimeoUserScript.js */; }; + 09874E5621078FA100E190B8 /* Youtube.html in Resources */ = {isa = PBXBuildFile; fileRef = 0979788721065F8C0077D77F /* Youtube.html */; }; + 09874E5721078FA100E190B8 /* YoutubeUserScript.js in Resources */ = {isa = PBXBuildFile; fileRef = 0979788121065F8B0077D77F /* YoutubeUserScript.js */; }; + 09874E582107A4C300E190B8 /* VimeoEmbedImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09874E3A21075BF400E190B8 /* VimeoEmbedImplementation.swift */; }; + 09874E592107BD4100E190B8 /* GenericEmbedImplementation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09874E4021075C1700E190B8 /* GenericEmbedImplementation.swift */; }; D007019C2029E8F2006B9E34 /* LegqacyICloudFileController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D007019B2029E8F2006B9E34 /* LegqacyICloudFileController.swift */; }; D007019E2029EFDD006B9E34 /* ICloudResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = D007019D2029EFDD006B9E34 /* ICloudResources.swift */; }; D00701A12029F6D0006B9E34 /* TGMimeTypeMap.h in Headers */ = {isa = PBXBuildFile; fileRef = D007019F2029F6D0006B9E34 /* TGMimeTypeMap.h */; }; @@ -969,6 +986,27 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0941A99F210B057200EBE194 /* OpenInActionSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInActionSheetController.swift; sourceTree = ""; }; + 0941A9A3210B0E2E00EBE194 /* OpenInAppIconResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInAppIconResources.swift; sourceTree = ""; }; + 0941A9A5210B822D00EBE194 /* OpenInOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInOptions.swift; sourceTree = ""; }; + 09797872210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantPageSettingsButtonItemNode.swift; sourceTree = ""; }; + 0979787B210642CB0077D77F /* WebEmbedPlayerNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebEmbedPlayerNode.swift; sourceTree = ""; }; + 0979787D210646C00077D77F /* YoutubeEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YoutubeEmbedImplementation.swift; sourceTree = ""; }; + 0979788021065F8B0077D77F /* VimeoUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = VimeoUserScript.js; sourceTree = ""; }; + 0979788121065F8B0077D77F /* YoutubeUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = YoutubeUserScript.js; sourceTree = ""; }; + 0979788221065F8C0077D77F /* Vimeo.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Vimeo.html; sourceTree = ""; }; + 0979788321065F8C0077D77F /* Generic.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Generic.html; sourceTree = ""; }; + 0979788421065F8C0077D77F /* Instagram.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Instagram.html; sourceTree = ""; }; + 0979788521065F8C0077D77F /* Twitch.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Twitch.html; sourceTree = ""; }; + 0979788621065F8C0077D77F /* TwitchUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = TwitchUserScript.js; sourceTree = ""; }; + 0979788721065F8C0077D77F /* Youtube.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = Youtube.html; sourceTree = ""; }; + 0979788821065F8C0077D77F /* GenericUserScript.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = GenericUserScript.js; sourceTree = ""; }; + 09874E3A21075BF400E190B8 /* VimeoEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VimeoEmbedImplementation.swift; sourceTree = ""; }; + 09874E3C21075C0500E190B8 /* TwitchEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitchEmbedImplementation.swift; sourceTree = ""; }; + 09874E3E21075C0D00E190B8 /* SoundCloudEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundCloudEmbedImplementation.swift; sourceTree = ""; }; + 09874E4021075C1700E190B8 /* GenericEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericEmbedImplementation.swift; sourceTree = ""; }; + 09874E4221075C3000E190B8 /* VKEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VKEmbedImplementation.swift; sourceTree = ""; }; + 09874E4421075C3F00E190B8 /* StreamableEmbedImplementation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamableEmbedImplementation.swift; sourceTree = ""; }; D00219051DDD1C9E00BE708A /* ImageContainingNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageContainingNode.swift; sourceTree = ""; }; D002A0D01E9B99F500A81812 /* SoftwareVideoSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SoftwareVideoSource.swift; sourceTree = ""; }; D002A0D21E9BBE6700A81812 /* MultiplexedSoftwareVideoSourceManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiplexedSoftwareVideoSourceManager.swift; sourceTree = ""; }; @@ -2054,6 +2092,47 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0941A99E210B053300EBE194 /* Open In */ = { + isa = PBXGroup; + children = ( + 0941A9A5210B822D00EBE194 /* OpenInOptions.swift */, + 0941A99F210B057200EBE194 /* OpenInActionSheetController.swift */, + ); + name = "Open In"; + sourceTree = ""; + }; + 0979787F21065EAA0077D77F /* Web Embed */ = { + isa = PBXGroup; + children = ( + 0979788321065F8C0077D77F /* Generic.html */, + 0979788821065F8C0077D77F /* GenericUserScript.js */, + 0979788421065F8C0077D77F /* Instagram.html */, + 0979788521065F8C0077D77F /* Twitch.html */, + 0979788621065F8C0077D77F /* TwitchUserScript.js */, + 0979788221065F8C0077D77F /* Vimeo.html */, + 0979788021065F8B0077D77F /* VimeoUserScript.js */, + 0979788721065F8C0077D77F /* Youtube.html */, + 0979788121065F8B0077D77F /* YoutubeUserScript.js */, + ); + name = "Web Embed"; + path = TelegramUI/Resources/WebEmbed; + sourceTree = ""; + }; + 09CC52A7210615AA000578F8 /* Web Embed */ = { + isa = PBXGroup; + children = ( + 0979787B210642CB0077D77F /* WebEmbedPlayerNode.swift */, + 09874E4021075C1700E190B8 /* GenericEmbedImplementation.swift */, + 0979787D210646C00077D77F /* YoutubeEmbedImplementation.swift */, + 09874E3A21075BF400E190B8 /* VimeoEmbedImplementation.swift */, + 09874E3C21075C0500E190B8 /* TwitchEmbedImplementation.swift */, + 09874E3E21075C0D00E190B8 /* SoundCloudEmbedImplementation.swift */, + 09874E4221075C3000E190B8 /* VKEmbedImplementation.swift */, + 09874E4421075C3F00E190B8 /* StreamableEmbedImplementation.swift */, + ); + name = "Web Embed"; + sourceTree = ""; + }; D00C7CDA1E3776CA0080C3D5 /* Secret Preview */ = { isa = PBXGroup; children = ( @@ -2351,6 +2430,7 @@ D0E9BA681F056F4C00F079A4 /* Stripe */, D0E9B9E91F00853C00F079A4 /* PhoneCountries.txt */, D0471B531EFD8ECA0074D609 /* currencies.json */, + 0979787F21065EAA0077D77F /* Web Embed */, ); name = Resources; sourceTree = ""; @@ -2358,6 +2438,7 @@ D0477D191F617E4B00412B44 /* Video */ = { isa = PBXGroup; children = ( + 09CC52A7210615AA000578F8 /* Web Embed */, D0477D1A1F617E5800412B44 /* UniversalVideoNode.swift */, D06BEC761F62F68B0035A545 /* OverlayUniversalVideoNode.swift */, D0943B041FDDFDA0001522CC /* OverlayInstantVideoNode.swift */, @@ -2741,6 +2822,7 @@ D048EA881F4F297500188713 /* InstantPageSettingsFontFamilyItemNode.swift */, D048EA8A1F4F298A00188713 /* InstantPageSettingsThemeItemNode.swift */, D048EA8C1F4F299A00188713 /* InstantPageSettingsSwitchItemNode.swift */, + 09797872210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift */, ); name = "Instant Page"; sourceTree = ""; @@ -3736,6 +3818,7 @@ D0C50E361E93CAF200F62E39 /* Notifications */, D0430AFE1FF456F400A35ADD /* Web */, D0F8C3952017747300236FC5 /* Feed */, + 0941A99E210B053300EBE194 /* Open In */, ); name = Controllers; sourceTree = ""; @@ -4117,6 +4200,7 @@ D056CD731FF2996B00880D28 /* ExternalMusicAlbumArtResources.swift */, D007019D2029EFDD006B9E34 /* ICloudResources.swift */, D09F9DCE20768DAF00DB4DE1 /* SecureIdLocalResource.swift */, + 0941A9A3210B0E2E00EBE194 /* OpenInAppIconResources.swift */, ); name = Resources; sourceTree = ""; @@ -4355,6 +4439,15 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 09874E4F21078FA100E190B8 /* Generic.html in Resources */, + 09874E5021078FA100E190B8 /* GenericUserScript.js in Resources */, + 09874E5121078FA100E190B8 /* Instagram.html in Resources */, + 09874E5221078FA100E190B8 /* Twitch.html in Resources */, + 09874E5321078FA100E190B8 /* TwitchUserScript.js in Resources */, + 09874E5421078FA100E190B8 /* Vimeo.html in Resources */, + 09874E5521078FA100E190B8 /* VimeoUserScript.js in Resources */, + 09874E5621078FA100E190B8 /* Youtube.html in Resources */, + 09874E5721078FA100E190B8 /* YoutubeUserScript.js in Resources */, D0EB42051F3143AB00838FE6 /* LegacyComponentsResources.bundle in Resources */, D0E9BAA21F056F4C00F079A4 /* stp_card_discover@3x.png in Resources */, D0E9BAB01F056F4C00F079A4 /* stp_card_mastercard@3x.png in Resources */, @@ -4439,6 +4532,7 @@ D0B2F76C2052A7D600D3BFB9 /* SinglePhoneInputNode.swift in Sources */, D04281F6200E5AC2009DDE36 /* ChatRecentActionsControllerNode.swift in Sources */, D0EC6CB61EB9F58800EBF1C3 /* RMGeometry.m in Sources */, + 0941A9A0210B057200EBE194 /* OpenInActionSheetController.swift in Sources */, D079FCDD1F05C4F20038FADE /* LocalAuth.swift in Sources */, D0B2F76820528E3D00D3BFB9 /* UserInfoEditingPhoneActionItem.swift in Sources */, D0EC6CB71EB9F58800EBF1C3 /* RMIntroPageView.m in Sources */, @@ -4461,6 +4555,7 @@ D0EC6CC01EB9F58800EBF1C3 /* LegacyMediaPickers.swift in Sources */, D0AF7C4A1ED84CE000CD8E0F /* LanguageSelectionControllerNode.swift in Sources */, D0EC6CC11EB9F58800EBF1C3 /* LegacyCamera.swift in Sources */, + 0941A9A6210B822D00EBE194 /* OpenInOptions.swift in Sources */, D0754D1E1EEDDF6200884F6E /* ChatMessageAttachedContentNode.swift in Sources */, D0E9BAC71F05738600F079A4 /* STPAPIClient.m in Sources */, D0CFBB911FD881A600B65C0D /* AudioRecordningToneData.swift in Sources */, @@ -4746,6 +4841,7 @@ D0EC6D611EB9F58800EBF1C3 /* GridMessageSelectionNode.swift in Sources */, D0754D201EEDEBA000884F6E /* ChatMessageGameBubbleContentNode.swift in Sources */, D09E63AA1F0FC681003444CD /* PictureInPictureVideoControlsNode.swift in Sources */, + 09874E592107BD4100E190B8 /* GenericEmbedImplementation.swift in Sources */, D0C0B5921EDC5A3B000F4D2C /* LinkHighlightingNode.swift in Sources */, D0EC6D621EB9F58800EBF1C3 /* ContactListNode.swift in Sources */, D0EC6D631EB9F58800EBF1C3 /* ContactListActionItem.swift in Sources */, @@ -4774,6 +4870,7 @@ D0EC6D721EB9F58800EBF1C3 /* AuthorizationSequencePasswordEntryControllerNode.swift in Sources */, D09D886F1F86C11F00BEB4C9 /* AuthorizationTheme.swift in Sources */, D0EC6D731EB9F58800EBF1C3 /* AuthorizationSequenceSignUpController.swift in Sources */, + 0979787C210642CB0077D77F /* WebEmbedPlayerNode.swift in Sources */, D0C12EB01F9A8D1300600BB2 /* ListMessageDateHeader.swift in Sources */, D0E9BA5D1F055A3300F079A4 /* STPBINRange.m in Sources */, D0EC6D741EB9F58800EBF1C3 /* AuthorizationSequenceSignUpControllerNode.swift in Sources */, @@ -4826,6 +4923,7 @@ D01DBA9B209CC6AD00C64E64 /* ChatLinkPreview.swift in Sources */, D044A0FB20BDC40C00326FAC /* CachedChannelAdmins.swift in Sources */, D0EC6D901EB9F58900EBF1C3 /* ChatMessageBubbleContentNode.swift in Sources */, + 09874E582107A4C300E190B8 /* VimeoEmbedImplementation.swift in Sources */, D0EC6D911EB9F58900EBF1C3 /* ChatMessageBubbleItemNode.swift in Sources */, D0E8B8BD204479A500605593 /* SecretChatKeyController.swift in Sources */, D0B85C1C1FF6F76000E795B4 /* AuthorizationSequencePasswordRecoveryController.swift in Sources */, @@ -4865,6 +4963,7 @@ D0EC6D9E1EB9F58900EBF1C3 /* ChatMessageWebpageBubbleContentNode.swift in Sources */, D06CF82720D0080200AC4CFF /* SecureIdAuthListContentNode.swift in Sources */, D0C0B5901EDB505E000F4D2C /* ActivityIndicator.swift in Sources */, + 09797873210633CD0077D77F /* InstantPageSettingsButtonItemNode.swift in Sources */, D0EC6D9F1EB9F58900EBF1C3 /* ChatUnreadItem.swift in Sources */, D0E9B9E81EFEFB9500F079A4 /* BotPaymentDisclosureItemNode.swift in Sources */, D0EC6DA01EB9F58900EBF1C3 /* ChatHoleItem.swift in Sources */, @@ -5002,6 +5101,7 @@ D06D37B22077E77F009219B6 /* AutodownloadSizeLimitItem.swift in Sources */, D0EC6DED1EB9F58900EBF1C3 /* ChatHistoryNavigationButtonNode.swift in Sources */, D0FB87B21F7C4C19004DE005 /* FetchMediaUtils.swift in Sources */, + 0979787E210646C00077D77F /* YoutubeEmbedImplementation.swift in Sources */, D0E9BA0C1F04580700F079A4 /* BotCheckoutWebInteractionControllerNode.swift in Sources */, D0EC6DF11EB9F58900EBF1C3 /* ShareController.swift in Sources */, D0EC6DF21EB9F58900EBF1C3 /* ShareControllerNode.swift in Sources */, @@ -5080,6 +5180,7 @@ D0EC6E211EB9F58900EBF1C3 /* InstantPageController.swift in Sources */, D0EC6E221EB9F58900EBF1C3 /* InstantPageControllerNode.swift in Sources */, D0EC6E231EB9F58900EBF1C3 /* StickerPackPreviewController.swift in Sources */, + 0941A9A4210B0E2E00EBE194 /* OpenInAppIconResources.swift in Sources */, D0EC6E241EB9F58900EBF1C3 /* StickerPackPreviewControllerNode.swift in Sources */, D0FC194D201F82A000FEDBB2 /* OpenResolvedUrl.swift in Sources */, D0EC6E251EB9F58900EBF1C3 /* StickerPackPreviewGridItem.swift in Sources */, diff --git a/TelegramUI/ChatController.swift b/TelegramUI/ChatController.swift index 08506d11e8..68da097d36 100644 --- a/TelegramUI/ChatController.swift +++ b/TelegramUI/ChatController.swift @@ -641,6 +641,7 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin case let .url(url): var cleanUrl = url var canAddToReadingList = true + let canOpenIn = true let mailtoString = "mailto:" let telString = "tel:" var openText = strongSelf.presentationData.strings.Conversation_LinkDialogOpen @@ -651,6 +652,8 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin canAddToReadingList = false cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...]) openText = strongSelf.presentationData.strings.Conversation_Call + } else if canOpenIn { + openText = strongSelf.presentationData.strings.Conversation_FileOpenIn } let actionSheet = ActionSheetController(presentationTheme: strongSelf.presentationData.theme) @@ -659,7 +662,11 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin items.append(ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { - strongSelf.openUrl(url) + if canOpenIn { + strongSelf.openUrlIn(url) + } else { + strongSelf.openUrl(url) + } } })) items.append(ActionSheetButtonItem(title: canAddToReadingList ? strongSelf.presentationData.strings.ShareMenu_CopyShareLink : strongSelf.presentationData.strings.Conversation_ContextMenuCopy, color: .accent, action: { [weak actionSheet] in @@ -4112,6 +4119,18 @@ public final class ChatController: TelegramController, UIViewControllerPreviewin })) } + private func openUrlIn(_ url: String) { + if let applicationContext = self.account.applicationContext as? TelegramApplicationContext { + let actionSheet = OpenInActionSheetController(postbox: self.account.postbox, applicationContext: applicationContext, theme: self.presentationData.theme, strings: self.presentationData.strings, item: .url(url), openUrl: { [weak self] url in + if let strongSelf = self, let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext, let navigationController = strongSelf.navigationController as? NavigationController { + openExternalUrl(account: strongSelf.account, url: url, presentationData: strongSelf.presentationData, applicationContext: applicationContext, navigationController: navigationController) + } + }) + self.chatDisplayNode.dismissInput() + self.present(actionSheet, in: .window(.root)) + } + } + @available(iOSApplicationExtension 9.0, *) public func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? { if previewingContext.sourceView === (self.chatInfoNavigationButton?.buttonItem.customDisplayNode as? ChatAvatarNavigationNode)?.avatarNode.view { diff --git a/TelegramUI/ChatItemGalleryFooterContentNode.swift b/TelegramUI/ChatItemGalleryFooterContentNode.swift index bae49ba6bb..56a3a7f683 100644 --- a/TelegramUI/ChatItemGalleryFooterContentNode.swift +++ b/TelegramUI/ChatItemGalleryFooterContentNode.swift @@ -9,6 +9,9 @@ import Photos private let deleteImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Acessory Panels/MessageSelectionThrash"), color: .white) private let actionImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Input/Acessory Panels/MessageSelectionAction"), color: .white) +private let backwardImage = UIImage(bundleImageName: "Media Gallery/BackwardButton") +private let forwardImage = UIImage(bundleImageName: "Media Gallery/ForwardButton") + private let pauseImage = generateImage(CGSize(width: 16.0, height: 16.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) @@ -58,8 +61,7 @@ private let dateFont = Font.regular(14.0) enum ChatItemGalleryFooterContent { case info - case playbackPause - case playbackPlay + case playback(paused: Bool, seekable: Bool) } final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { @@ -72,6 +74,8 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { private let textNode: ASTextNode private let authorNameNode: ASTextNode private let dateNode: ASTextNode + private let backwardButton: HighlightableButtonNode + private let forwardButton: HighlightableButtonNode private let playbackControlButton: HighlightableButtonNode private var currentMessageText: String? @@ -83,26 +87,35 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { private let messageContextDisposable = MetaDisposable() var playbackControl: (() -> Void)? + var seekBackward: (() -> Void)? + var seekForward: (() -> Void)? var content: ChatItemGalleryFooterContent = .info { didSet { - if self.content != oldValue { + //if self.content != oldValue { switch self.content { case .info: self.authorNameNode.isHidden = false self.dateNode.isHidden = false + self.backwardButton.isHidden = true + self.forwardButton.isHidden = true self.playbackControlButton.isHidden = true - case .playbackPause: + case let .playback(paused, seekable): self.authorNameNode.isHidden = true self.dateNode.isHidden = true + self.backwardButton.isHidden = !seekable + self.forwardButton.isHidden = !seekable self.playbackControlButton.isHidden = false - self.playbackControlButton.setImage(pauseImage, for: []) - case .playbackPlay: - self.authorNameNode.isHidden = true - self.dateNode.isHidden = true - self.playbackControlButton.isHidden = false - self.playbackControlButton.setImage(playImage, for: []) + self.playbackControlButton.setImage(paused ? playImage : pauseImage, for: []) } + //} + } + } + + var scrubberView: ChatVideoGalleryItemScrubberView? { + didSet { + if let scrubberView = self.scrubberView { + self.view.addSubview(scrubberView) } } } @@ -128,10 +141,20 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { self.dateNode.maximumNumberOfLines = 1 self.dateNode.isLayerBacked = true self.dateNode.displaysAsynchronously = false + + self.backwardButton = HighlightableButtonNode() + self.backwardButton.isHidden = true + self.backwardButton.setImage(backwardImage, for: []) + + self.forwardButton = HighlightableButtonNode() + self.forwardButton.isHidden = true + self.forwardButton.setImage(forwardImage, for: []) self.playbackControlButton = HighlightableButtonNode() self.playbackControlButton.isHidden = true + self.scrubberView = ChatVideoGalleryItemScrubberView() + super.init() self.view.addSubview(self.deleteButton) @@ -139,12 +162,16 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { self.addSubnode(self.textNode) self.addSubnode(self.authorNameNode) self.addSubnode(self.dateNode) - + + self.addSubnode(self.backwardButton) + self.addSubnode(self.forwardButton) self.addSubnode(self.playbackControlButton) self.deleteButton.addTarget(self, action: #selector(self.deleteButtonPressed), for: [.touchUpInside]) self.actionButton.addTarget(self, action: #selector(self.actionButtonPressed), for: [.touchUpInside]) + self.backwardButton.addTarget(self, action: #selector(self.backwardButtonPressed), forControlEvents: .touchUpInside) + self.forwardButton.addTarget(self, action: #selector(self.forwardButtonPressed), forControlEvents: .touchUpInside) self.playbackControlButton.addTarget(self, action: #selector(self.playbackControlPressed), forControlEvents: .touchUpInside) } @@ -267,8 +294,20 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { self.textNode.frame = CGRect(origin: CGPoint(x: sideInset, y: topInset), size: textSize) } + if let scrubberView = self.scrubberView { + let sideInset: CGFloat = 8.0 + leftInset + let topInset: CGFloat = 8.0 + let bottomInset: CGFloat = 8.0 + panelHeight += 34.0 + topInset + bottomInset + + scrubberView.frame = CGRect(origin: CGPoint(x: sideInset, y: topInset), size: CGSize(width: width - sideInset * 2.0, height: 34.0)) + } + self.actionButton.frame = CGRect(origin: CGPoint(x: leftInset, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) self.deleteButton.frame = CGRect(origin: CGPoint(x: width - 44.0 - rightInset, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) + + self.backwardButton.frame = CGRect(origin: CGPoint(x: floor((width - 44.0) / 2.0) - 66.0, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) + self.forwardButton.frame = CGRect(origin: CGPoint(x: floor((width - 44.0) / 2.0) + 66.0, y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) self.playbackControlButton.frame = CGRect(origin: CGPoint(x: floor((width - 44.0) / 2.0), y: panelHeight - bottomInset - 44.0), size: CGSize(width: 44.0, height: 44.0)) @@ -521,4 +560,12 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode { @objc func playbackControlPressed() { self.playbackControl?() } + + @objc func backwardButtonPressed() { + self.seekBackward?() + } + + @objc func forwardButtonPressed() { + self.seekForward?() + } } diff --git a/TelegramUI/ChatMessageAttachedContentNode.swift b/TelegramUI/ChatMessageAttachedContentNode.swift index c40fd64b1e..a319b6c561 100644 --- a/TelegramUI/ChatMessageAttachedContentNode.swift +++ b/TelegramUI/ChatMessageAttachedContentNode.swift @@ -311,8 +311,8 @@ final class ChatMessageAttachedContentNode: ASDisplayNode { for media in message.media { if let media = media as? TelegramMediaWebpage { if case let .Loaded(content) = media.content, let instantPage = content.instantPage, let image = content.image { - switch websiteType(of: content) { - case .instagram, .twitter: + switch instantPageType(of: content) { + case .album: let count = instantPageGalleryMedia(webpageId: media.webpageId, page: instantPage, galleryMedia: image).count if count > 1 { webpageGalleryMediaCount = count diff --git a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift index 8f3ca5e8a9..ec617fa966 100644 --- a/TelegramUI/ChatMessageWebpageBubbleContentNode.swift +++ b/TelegramUI/ChatMessageWebpageBubbleContentNode.swift @@ -22,6 +22,24 @@ func websiteType(of webpage: TelegramMediaWebpageLoadedContent) -> WebsiteType { return .generic } +enum InstantPageType { + case generic + case album +} + +func instantPageType(of webpage: TelegramMediaWebpageLoadedContent) -> InstantPageType { + if let type = webpage.type, type == "telegram_album" { + return .album + } + + switch websiteType(of: webpage) { + case .instagram, .twitter: + return .album + default: + return .generic + } +} + func instantPageGalleryMedia(webpageId: MediaId, page: InstantPage, galleryMedia: Media) -> [InstantPageGalleryEntry] { var result: [InstantPageGalleryEntry] = [] var counter: Int = 0 @@ -190,7 +208,7 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { mediaAndFlags = (file, []) } } else if let image = mainMedia as? TelegramMediaImage { - if let type = webpage.type, ["photo", "video", "embed", "article"].contains(type) { + if let type = webpage.type, ["photo", "video", "embed", "article", "telegram_album"].contains(type) { var flags = ChatMessageAttachedContentNodeMediaFlags() if webpage.instantPage != nil, let largest = largestImageRepresentation(image.representations) { if largest.dimensions.width >= 256.0 { @@ -206,12 +224,12 @@ final class ChatMessageWebpageBubbleContentNode: ChatMessageBubbleContentNode { } if let _ = webpage.instantPage { - switch type { - case .twitter, .instagram: - break - default: + switch instantPageType(of: webpage) { + case .generic: actionIcon = .instant actionTitle = item.presentationData.strings.Conversation_InstantPagePreview + default: + break } } else if let type = webpage.type { switch type { diff --git a/TelegramUI/ChatRecentActionsControllerNode.swift b/TelegramUI/ChatRecentActionsControllerNode.swift index 1cd38a16f2..4e151867f2 100644 --- a/TelegramUI/ChatRecentActionsControllerNode.swift +++ b/TelegramUI/ChatRecentActionsControllerNode.swift @@ -242,6 +242,7 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { switch action { case let .url(url): var cleanUrl = url + let canOpenIn = true var canAddToReadingList = true let mailtoString = "mailto:" let telString = "tel:" @@ -253,6 +254,8 @@ final class ChatRecentActionsControllerNode: ViewControllerTracingNode { canAddToReadingList = false cleanUrl = String(cleanUrl[cleanUrl.index(cleanUrl.startIndex, offsetBy: telString.distance(from: telString.startIndex, to: telString.endIndex))...]) openText = strongSelf.presentationData.strings.Conversation_Call + } else if canOpenIn { + openText = strongSelf.presentationData.strings.Conversation_FileOpenIn } let actionSheet = ActionSheetController(presentationTheme: strongSelf.presentationData.theme) diff --git a/TelegramUI/ChatVideoGalleryItemScrubberView.swift b/TelegramUI/ChatVideoGalleryItemScrubberView.swift index ca2ade29e4..3c93a8a0d9 100644 --- a/TelegramUI/ChatVideoGalleryItemScrubberView.swift +++ b/TelegramUI/ChatVideoGalleryItemScrubberView.swift @@ -32,11 +32,11 @@ final class ChatVideoGalleryItemScrubberView: UIView { var seek: (Double) -> Void = { _ in } override init(frame: CGRect) { - self.scrubberNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 3.0, lineCap: .round, scrubberHandle: .line, backgroundColor: UIColor(white: 1.0, alpha: 0.42), foregroundColor: .white)) + self.scrubberNode = MediaPlayerScrubbingNode(content: .standard(lineHeight: 4.0, lineCap: .round, scrubberHandle: .circle, backgroundColor: UIColor(white: 1.0, alpha: 0.42), foregroundColor: .white)) self.leftTimestampNode = MediaPlayerTimeTextNode(textColor: .white) self.rightTimestampNode = MediaPlayerTimeTextNode(textColor: .white) - self.leftTimestampNode.alignment = .right + self.rightTimestampNode.alignment = .right self.rightTimestampNode.mode = .reversed super.init(frame: frame) @@ -92,9 +92,9 @@ final class ChatVideoGalleryItemScrubberView: UIView { let scrubberHeight: CGFloat = 14.0 - self.leftTimestampNode.frame = CGRect(origin: CGPoint(x: -10.0, y: 15.0), size: CGSize(width: 57.0 - 15.0, height: 20.0)) - self.rightTimestampNode.frame = CGRect(origin: CGPoint(x: size.width - 57.0 + 30.0, y: 15.0), size: CGSize(width: 57.0 - 10.0, height: 20.0)) + self.leftTimestampNode.frame = CGRect(origin: CGPoint(x: 6.0, y: 22.0), size: CGSize(width: 60.0, height: 20.0)) + self.rightTimestampNode.frame = CGRect(origin: CGPoint(x: size.width - 60.0 - 6.0, y: 22.0), size: CGSize(width: 60.0, height: 20.0)) - self.scrubberNode.frame = CGRect(origin: CGPoint(x: 57.0 - 15.0, y: floor((size.height - scrubberHeight) / 2.0) + 1.0), size: CGSize(width: size.width - 57.0 * 2.0 + 35.0, height: scrubberHeight)) + self.scrubberNode.frame = CGRect(origin: CGPoint(x: 6.0, y: 6.0), size: CGSize(width: size.width - 6.0 * 2.0, height: scrubberHeight)) } } diff --git a/TelegramUI/GalleryControllerNode.swift b/TelegramUI/GalleryControllerNode.swift index eebd567af6..e44797390e 100644 --- a/TelegramUI/GalleryControllerNode.swift +++ b/TelegramUI/GalleryControllerNode.swift @@ -314,7 +314,9 @@ class GalleryControllerNode: ASDisplayNode, UIScrollViewDelegate, UIGestureRecog func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { targetContentOffset.pointee = scrollView.contentOffset - if abs(velocity.y) > 1.0 { + let distanceFromEquilibrium = scrollView.contentOffset.y - scrollView.contentSize.height / 3.0 + let minimalDismissDistance = scrollView.contentSize.height / 12.0 + if abs(velocity.y) > 1.0 || abs(distanceFromEquilibrium) > minimalDismissDistance { if let backgroundColor = self.backgroundNode.backgroundColor { self.backgroundNode.layer.animate(from: backgroundColor, to: UIColor(white: 0.0, alpha: 0.0).cgColor, keyPath: "backgroundColor", timingFunction: kCAMediaTimingFunctionLinear, duration: 0.2, removeOnCompletion: false) } diff --git a/TelegramUI/GenericEmbedImplementation.swift b/TelegramUI/GenericEmbedImplementation.swift new file mode 100644 index 0000000000..d2e3536bb3 --- /dev/null +++ b/TelegramUI/GenericEmbedImplementation.swift @@ -0,0 +1,69 @@ +import Foundation +import WebKit +import SwiftSignalKit + +final class GenericEmbedImplementation: WebEmbedImplementation { + private var evalImpl: ((String) -> Void)? + private var updateStatus: ((MediaPlayerStatus) -> Void)? + private var onPlaybackStarted: (() -> Void)? + + private let url: String + + init(url: String) { + self.url = url + //self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true)) + } + + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + let bundle = Bundle(for: type(of: self)) + guard let userScriptPath = bundle.path(forResource: "GenericUserScript", ofType: "js") else { + return + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return + } + guard let htmlTemplatePath = bundle.path(forResource: "Generic", ofType: "html") else { + return + } + guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else { + return + } + guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else { + return + } + + self.evalImpl = evaluateJavaScript + self.updateStatus = updateStatus + self.onPlaybackStarted = onPlaybackStarted + //updateStatus(self.status) + + let html = String(format: htmlTemplate, self.url) + webView.loadHTMLString(html, baseURL: URL(string: "about:blank")) + + userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) + } + + func play() { + } + + func pause() { + } + + func togglePlayPause() { + } + + func seek(timestamp: Double) { + } + + func pageReady() { + if let onPlaybackStarted = self.onPlaybackStarted { + onPlaybackStarted() + } + } + + func callback(url: URL) { + } +} diff --git a/TelegramUI/GroupInfoController.swift b/TelegramUI/GroupInfoController.swift index 46c0f717a2..e250e751bb 100644 --- a/TelegramUI/GroupInfoController.swift +++ b/TelegramUI/GroupInfoController.swift @@ -1792,10 +1792,13 @@ func handlePeerInfoAboutTextAction(account: Account, peerId: PeerId, navigateDis case .longTap: switch itemLink { case let .url(url): + let canOpenIn = true + let openText = canOpenIn ? presentationData.strings.Conversation_FileOpenIn : presentationData.strings.Conversation_LinkDialogOpen + let actionSheet = ActionSheetController(presentationTheme: presentationData.theme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: url), - ActionSheetButtonItem(title: presentationData.strings.Conversation_LinkDialogOpen, color: .accent, action: { [weak actionSheet] in + ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() openLinkImpl(url) }), diff --git a/TelegramUI/InstantPageControllerNode.swift b/TelegramUI/InstantPageControllerNode.swift index e3b2b06051..1a4ef7bdc6 100644 --- a/TelegramUI/InstantPageControllerNode.swift +++ b/TelegramUI/InstantPageControllerNode.swift @@ -595,10 +595,13 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate { } case .longTap: if let url = self.urlForTapLocation(location) { + let canOpenIn = true + let openText = canOpenIn ? self.strings.Conversation_FileOpenIn : self.strings.Conversation_LinkDialogOpen + let actionSheet = ActionSheetController(presentationTheme: self.presentationTheme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: url.url), - ActionSheetButtonItem(title: self.self.strings.Conversation_LinkDialogOpen, color: .accent, action: { [weak self, weak actionSheet] in + ActionSheetButtonItem(title: openText, color: .accent, action: { [weak self, weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { strongSelf.openUrl(url) @@ -823,6 +826,10 @@ final class InstantPageControllerNode: ASDisplayNode, UIScrollViewDelegate { return settings }).start() } + }, openInSafari: { [weak self] in + if let strongSelf = self, let webPage = strongSelf.webPage, case let .Loaded(content) = webPage.content { + strongSelf.account.telegramApplicationContext.applicationBindings.openUrl(content.url) + } }) self.addSubnode(settingsNode) self.settingsNode = settingsNode diff --git a/TelegramUI/InstantPageSettingsButtonItemNode.swift b/TelegramUI/InstantPageSettingsButtonItemNode.swift new file mode 100644 index 0000000000..0d7ada8893 --- /dev/null +++ b/TelegramUI/InstantPageSettingsButtonItemNode.swift @@ -0,0 +1,43 @@ +import Foundation +import AsyncDisplayKit +import Display + +final class InstantPageSettingsButtonItemNode: InstantPageSettingsItemNode { + private let title: String + private let tapped: () -> Void + + private let labelNode: ASTextNode + + init(theme: InstantPageSettingsItemTheme, title: String, tapped: @escaping () -> Void) { + self.title = title + self.tapped = tapped + + self.labelNode = ASTextNode() + + super.init(theme: theme, selectable: true) + + self.addSubnode(self.labelNode) + + self.updateTheme(theme) + } + + override func updateTheme(_ theme: InstantPageSettingsItemTheme) { + super.updateTheme(theme) + + self.labelNode.attributedText = NSAttributedString(string: self.title, font: Font.regular(17.0), textColor: theme.accentColor) + } + + override func updateInternalLayout(width: CGFloat, insets: UIEdgeInsets, previousItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?), nextItem: (InstantPageSettingsItemNodeStatus, InstantPageSettingsItemNode?)) -> (height: CGFloat, separatorInset: CGFloat?) { + var separatorInset: CGFloat? + if case .sameSection = previousItem.0, let previousNode = previousItem.1, previousNode is InstantPageSettingsFontFamilyNode { + separatorInset = 46.0 + } + let labelSize = self.labelNode.measure(CGSize(width: width - 15.0 - 5.0, height: 44.0)) + self.labelNode.frame = CGRect(origin: CGPoint(x: 15.0, y: insets.top + floor((44.0 - labelSize.height) / 2.0)), size: labelSize) + return (44.0 + insets.top + insets.bottom, separatorInset) + } + + override func pressed() { + self.tapped() + } +} diff --git a/TelegramUI/InstantPageSettingsNode.swift b/TelegramUI/InstantPageSettingsNode.swift index 02959113a9..b9a1343313 100644 --- a/TelegramUI/InstantPageSettingsNode.swift +++ b/TelegramUI/InstantPageSettingsNode.swift @@ -22,21 +22,24 @@ final class InstantPageSettingsNode: ASDisplayNode { private var theme: InstantPageSettingsItemTheme private let applySettings: (InstantPagePresentationSettings) -> Void + private let openInSafari: () -> Void private var sections: [[InstantPageSettingsItemNode]] = [] private let sansFamilyNode: InstantPageSettingsFontFamilyNode private let serifFamilyNode: InstantPageSettingsFontFamilyNode private let themeItemNode: InstantPageSettingsThemeItemNode private let autoNightItemNode: InstantPageSettingsSwitchNode + private let openInItemNode: InstantPageSettingsButtonItemNode private let arrowNode: ASImageNode private let itemContainerNode: ASDisplayNode - init(strings: PresentationStrings, settings: InstantPagePresentationSettings, applySettings: @escaping (InstantPagePresentationSettings) -> Void) { + init(strings: PresentationStrings, settings: InstantPagePresentationSettings, applySettings: @escaping (InstantPagePresentationSettings) -> Void, openInSafari: @escaping () -> Void) { self.settings = settings self.theme = InstantPageSettingsItemTheme.themeFor(settings) self.applySettings = applySettings + self.openInSafari = openInSafari self.arrowNode = ASImageNode() self.arrowNode.displayWithoutProcessing = true @@ -51,6 +54,7 @@ final class InstantPageSettingsNode: ASDisplayNode { var updateSerifImpl: ((Bool) -> Void)? var updateThemeTypeImpl: ((InstantPageThemeType) -> Void)? var updateAutoNightImpl: ((Bool) -> Void)? + var openInSafariImpl: (() -> Void)? self.sansFamilyNode = InstantPageSettingsFontFamilyNode(theme: self.theme, title: "San Francisco", family: nil, checked: !settings.forceSerif, tapped: { updateSerifImpl?(false) @@ -64,7 +68,9 @@ final class InstantPageSettingsNode: ASDisplayNode { self.autoNightItemNode = InstantPageSettingsSwitchNode(theme: theme, title: strings.InstantPage_AutoNightTheme, isOn: settings.autoNightMode, isEnabled: settings.themeType != .dark, toggled: { value in updateAutoNightImpl?(value) }) - + self.openInItemNode = InstantPageSettingsButtonItemNode(theme: theme, title: strings.Web_OpenExternal, tapped: { + openInSafariImpl?() + }) super.init() self.addSubnode(self.arrowNode) @@ -89,6 +95,9 @@ final class InstantPageSettingsNode: ASDisplayNode { [ self.themeItemNode, self.autoNightItemNode + ], + [ + self.openInItemNode ] ] @@ -121,6 +130,11 @@ final class InstantPageSettingsNode: ASDisplayNode { } } } + openInSafariImpl = { [weak self] in + if let strongSelf = self { + strongSelf.openInSafari() + } + } } func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { diff --git a/TelegramUI/LegacyComponentsStickers.swift b/TelegramUI/LegacyComponentsStickers.swift index 25e96830aa..a15bb1bef6 100644 --- a/TelegramUI/LegacyComponentsStickers.swift +++ b/TelegramUI/LegacyComponentsStickers.swift @@ -35,6 +35,8 @@ func legacyComponentsStickers(postbox: Postbox, namespace: Int32) -> SSignal { attributes.append(TGDocumentAttributeSticker(alt: displayText, packReference: nil, mask: maskData.flatMap { return TGStickerMaskDescription(n: $0.n, point: CGPoint(x: CGFloat($0.x), y: CGFloat($0.y)), zoom: CGFloat($0.zoom)) })) + case let .ImageSize(size): + attributes.append(TGDocumentAttributeImageSize(size: size)) default: break } diff --git a/TelegramUI/MediaPlayer.swift b/TelegramUI/MediaPlayer.swift index 29034cd13d..ed0a966c7b 100644 --- a/TelegramUI/MediaPlayer.swift +++ b/TelegramUI/MediaPlayer.swift @@ -3,7 +3,6 @@ import SwiftSignalKit import Postbox import CoreMedia import TelegramCore -import Postbox private let traceEvents = false diff --git a/TelegramUI/MediaPlayerScrubbingNode.swift b/TelegramUI/MediaPlayerScrubbingNode.swift index 9af34f66c8..0273bc98c6 100644 --- a/TelegramUI/MediaPlayerScrubbingNode.swift +++ b/TelegramUI/MediaPlayerScrubbingNode.swift @@ -85,16 +85,18 @@ private final class StandardMediaPlayerScrubbingNodeContentNode { let bufferingNode: MediaPlayerScrubbingBufferingNode let foregroundContentNode: ASImageNode let foregroundNode: MediaPlayerScrubbingForegroundNode + let handle: MediaPlayerScrubbingNodeHandle let handleNode: ASDisplayNode? let handleNodeContainer: MediaPlayerScrubbingNodeButton? - init(lineHeight: CGFloat, lineCap: MediaPlayerScrubbingNodeCap, backgroundNode: ASImageNode, bufferingNode: MediaPlayerScrubbingBufferingNode, foregroundContentNode: ASImageNode, foregroundNode: MediaPlayerScrubbingForegroundNode, handleNode: ASDisplayNode?, handleNodeContainer: MediaPlayerScrubbingNodeButton?) { + init(lineHeight: CGFloat, lineCap: MediaPlayerScrubbingNodeCap, backgroundNode: ASImageNode, bufferingNode: MediaPlayerScrubbingBufferingNode, foregroundContentNode: ASImageNode, foregroundNode: MediaPlayerScrubbingForegroundNode, handle: MediaPlayerScrubbingNodeHandle, handleNode: ASDisplayNode?, handleNodeContainer: MediaPlayerScrubbingNodeButton?) { self.lineHeight = lineHeight self.lineCap = lineCap self.backgroundNode = backgroundNode self.bufferingNode = bufferingNode self.foregroundContentNode = foregroundContentNode self.foregroundNode = foregroundNode + self.handle = handle self.handleNode = handleNode self.handleNodeContainer = handleNodeContainer } @@ -286,7 +288,7 @@ final class MediaPlayerScrubbingNode: ASDisplayNode { handleNodeContainerImpl = handleNodeContainer case .circle: let handleNode = ASImageNode() - handleNode.image = generateFilledCircleImage(diameter: 7.0, color: foregroundColor) + handleNode.image = generateFilledCircleImage(diameter: lineHeight + 4.0, color: foregroundColor) handleNode.isLayerBacked = true handleNodeImpl = handleNode @@ -297,7 +299,7 @@ final class MediaPlayerScrubbingNode: ASDisplayNode { handleNodeContainerImpl?.isUserInteractionEnabled = enableScrubbing - return .standard(StandardMediaPlayerScrubbingNodeContentNode(lineHeight: lineHeight, lineCap: lineCap, backgroundNode: backgroundNode, bufferingNode: bufferingNode, foregroundContentNode: foregroundContentNode, foregroundNode: foregroundNode, handleNode: handleNodeImpl, handleNodeContainer: handleNodeContainerImpl)) + return .standard(StandardMediaPlayerScrubbingNodeContentNode(lineHeight: lineHeight, lineCap: lineCap, backgroundNode: backgroundNode, bufferingNode: bufferingNode, foregroundContentNode: foregroundContentNode, foregroundNode: foregroundNode, handle: scrubberHandle, handleNode: handleNodeImpl, handleNodeContainer: handleNodeContainerImpl)) case let .custom(backgroundNode, foregroundContentNode): let foregroundNode = MediaPlayerScrubbingForegroundNode() foregroundNode.isLayerBacked = true @@ -510,7 +512,7 @@ final class MediaPlayerScrubbingNode: ASDisplayNode { if let handleNode = node.handleNode { var handleSize: CGSize = CGSize(width: 2.0, height: bounds.size.height) - if let handleNode = handleNode as? ASImageNode, let image = handleNode.image, image.size.width.isEqual(to: 7.0) { + if case .circle = node.handle, let handleNode = handleNode as? ASImageNode, let image = handleNode.image { handleSize = image.size } handleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: floor((bounds.size.height - handleSize.height) / 2.0)), size: handleSize) diff --git a/TelegramUI/MediaResources.swift b/TelegramUI/MediaResources.swift index 3cfb7d02cd..0d2b322b3b 100644 --- a/TelegramUI/MediaResources.swift +++ b/TelegramUI/MediaResources.swift @@ -310,3 +310,51 @@ public class ExternalMusicAlbumArtResource: TelegramMediaResource { } } } + +public struct OpenInAppIconResourceId: MediaResourceId { + public let appStoreId: Int64 + + public var uniqueId: String { + return "app-icon-\(appStoreId)" + } + + public var hashValue: Int { + return self.appStoreId.hashValue + } + + public func isEqual(to: MediaResourceId) -> Bool { + if let to = to as? OpenInAppIconResourceId { + return self.appStoreId == to.appStoreId + } else { + return false + } + } +} + +public class OpenInAppIconResource: TelegramMediaResource { + public let appStoreId: Int64 + + public init(appStoreId: Int64) { + self.appStoreId = appStoreId + } + + public required init(decoder: PostboxDecoder) { + self.appStoreId = decoder.decodeInt64ForKey("i", orElse: 0) + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeInt64(self.appStoreId, forKey: "i") + } + + public var id: MediaResourceId { + return OpenInAppIconResourceId(appStoreId: self.appStoreId) + } + + public func isEqual(to: TelegramMediaResource) -> Bool { + if let to = to as? OpenInAppIconResource { + return self.appStoreId == to.appStoreId + } else { + return false + } + } +} diff --git a/TelegramUI/OpenChatMessage.swift b/TelegramUI/OpenChatMessage.swift index 6b45299753..06da9f698f 100644 --- a/TelegramUI/OpenChatMessage.swift +++ b/TelegramUI/OpenChatMessage.swift @@ -29,24 +29,20 @@ private func chatMessageGalleryControllerData(account: Account, message: Message } else if let image = media as? TelegramMediaImage { galleryMedia = image } else if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content { - if content.embedUrl != nil && !webEmbedVideoContentSupportsWebpage(content) { - return .url(content.url) - } else { - if let file = content.file { - galleryMedia = file - } else if let image = content.image { - galleryMedia = image - } - if let instantPage = content.instantPage, let galleryMedia = galleryMedia { - switch websiteType(of: content) { - case .instagram, .twitter: - let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: galleryMedia) - if medias.count > 1 { - instantPageMedia = (webpage, medias) - } - case .generic: - break - } + if let file = content.file { + galleryMedia = file + } else if let image = content.image { + galleryMedia = image + } + if let instantPage = content.instantPage, let galleryMedia = galleryMedia { + switch instantPageType(of: content) { + case .album: + let medias = instantPageGalleryMedia(webpageId: webpage.webpageId, page: instantPage, galleryMedia: galleryMedia) + if medias.count > 1 { + instantPageMedia = (webpage, medias) + } + default: + break } } } else if let mapMedia = media as? TelegramMediaMap { diff --git a/TelegramUI/OpenInActionSheetController.swift b/TelegramUI/OpenInActionSheetController.swift new file mode 100644 index 0000000000..20f88ce5fe --- /dev/null +++ b/TelegramUI/OpenInActionSheetController.swift @@ -0,0 +1,208 @@ +import Foundation +import Display +import AsyncDisplayKit +import SwiftSignalKit +import Postbox +import TelegramCore + +final class OpenInActionSheetController: ActionSheetController { + private let theme: PresentationTheme + private let strings: PresentationStrings + private let openUrl: (String) -> Void + + private let _ready = Promise() + override var ready: Promise { + return self._ready + } + + init(postbox: Postbox, applicationContext: TelegramApplicationContext, theme: PresentationTheme, strings: PresentationStrings, item: OpenInItem, openUrl: @escaping (String) -> Void) { + self.theme = theme + self.strings = strings + self.openUrl = openUrl + + super.init(theme: ActionSheetControllerTheme(presentationTheme: theme)) + + self._ready.set(.single(true)) + + var items: [ActionSheetItem] = [] + items.append(OpenInActionSheetItem(postbox: postbox, applicationContext: applicationContext, strings: strings, options: availableOpenInOptions(applicationContext: applicationContext, item: item))) + self.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: strings.Common_Cancel, action: { [weak self] in + self?.dismissAnimated() + }), + ]) + ]) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class OpenInActionSheetItem: ActionSheetItem { + let postbox: Postbox + let applicationContext: TelegramApplicationContext + let strings: PresentationStrings + let options: [OpenInOption] + + init(postbox: Postbox, applicationContext: TelegramApplicationContext, strings: PresentationStrings, options: [OpenInOption]) { + self.postbox = postbox + self.applicationContext = applicationContext + self.strings = strings + self.options = options + } + + func node(theme: ActionSheetControllerTheme) -> ActionSheetItemNode { + return OpenInActionSheetItemNode(postbox: self.postbox, applicationContext: self.applicationContext, theme: theme, strings: self.strings, options: self.options) + } + + func updateNode(_ node: ActionSheetItemNode) { + } +} + +private let titleFont = Font.medium(20.0) +private let textFont = Font.regular(11.0) + +private final class OpenInActionSheetItemNode: ActionSheetItemNode { + private let theme: ActionSheetControllerTheme + private let strings: PresentationStrings + + private let titleNode: ASTextNode + private let scrollNode: ASScrollNode + + private let openInNodes: [OpenInAppNode] + + init(postbox: Postbox, applicationContext: TelegramApplicationContext, theme: ActionSheetControllerTheme, strings: PresentationStrings, options: [OpenInOption]) { + self.theme = theme + self.strings = strings + + self.titleNode = ASTextNode() + self.titleNode.isLayerBacked = true + self.titleNode.displaysAsynchronously = true + self.titleNode.attributedText = NSAttributedString(string: strings.Map_OpenIn, font: titleFont, textColor: theme.primaryTextColor, paragraphAlignment: .center) + + self.scrollNode = ASScrollNode() + self.scrollNode.view.showsVerticalScrollIndicator = false + self.scrollNode.view.showsHorizontalScrollIndicator = false + self.scrollNode.view.clipsToBounds = false + self.scrollNode.view.scrollsToTop = false + self.scrollNode.view.delaysContentTouches = false + self.scrollNode.scrollableDirections = [.left, .right] + + self.openInNodes = options.map { option in + let node = OpenInAppNode() + node.setup(postbox: postbox, applicationContext: applicationContext, theme: theme, option: option) + return node + } + + super.init(theme: theme) + + self.addSubnode(self.titleNode) + + if !self.openInNodes.isEmpty { + for openInNode in openInNodes { + self.scrollNode.addSubnode(openInNode) + } + self.addSubnode(self.scrollNode) + } + } + + override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize { + return CGSize(width: constrainedSize.width, height: 148.0) + } + + override func layout() { + super.layout() + + let bounds = self.bounds + + let titleSize = self.titleNode.measure(bounds.size) + self.titleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 16.0), size: CGSize(width: bounds.size.width, height: titleSize.height)) + + self.scrollNode.frame = CGRect(origin: CGPoint(x: 0, y: 36.0), size: CGSize(width: bounds.size.width, height: bounds.height - 36.0)) + + let nodeInset: CGFloat = 2.0 + let nodeSize = CGSize(width: 80.0, height: 112.0) + var nodeOffset = nodeInset + + for node in self.openInNodes { + node.frame = CGRect(origin: CGPoint(x: nodeOffset, y: 0.0), size: nodeSize) + nodeOffset += nodeSize.width + } + } +} + +private final class OpenInAppNode : ASDisplayNode { + private let iconNode: TransformImageNode + private let textNode: ASTextNode + private var action: (() -> Void)? + + override init() { + self.iconNode = TransformImageNode() + self.iconNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 60.0, height: 60.0)) + self.iconNode.isLayerBacked = true + + self.textNode = ASTextNode() + self.textNode.isLayerBacked = true + self.textNode.displaysAsynchronously = true + + super.init() + + self.addSubnode(self.iconNode) + self.addSubnode(self.textNode) + } + + func setup(postbox: Postbox, applicationContext: TelegramApplicationContext, theme: ActionSheetControllerTheme, option: OpenInOption) { + self.textNode.attributedText = NSAttributedString(string: option.title, font: textFont, textColor: theme.primaryTextColor, paragraphAlignment: .center) + + let iconSize = CGSize(width: 60.0, height: 60.0) + let makeLayout = self.iconNode.asyncLayout() + let applyLayout = makeLayout(TransformImageArguments(corners: ImageCorners(radius: 16.0), imageSize: iconSize, boundingSize: iconSize, intrinsicInsets: UIEdgeInsets())) + applyLayout() + + //option.a + +// switch option.action { +// case .o +//// case .safari: +//// self.iconNode.setSignal(openInAppIcon(postbox: postbox, appIcon: nil)) +//// self.action = { +//// applicationContext.applicationBindings.openUrl("https://telegram.org") +//// } +//// // //self.iconNode.setSignal( +//// // case .maps: +//// // nil +//// case let .external(identifier, _, _): +//// self.iconNode.setSignal(openInAppIcon(postbox: postbox, appIcon: OpenInAppIconResource(appStoreId: identifier))) +//// self.action = { +//// applicationContext.applicationBindings.openUrl("googlechromes://telegram.org") +//// } +//// default: +//// break +//// } +// } + } + + override func didLoad() { + super.didLoad() + + self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) + } + + @objc func tapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + self.action?() + } + } + + override func layout() { + super.layout() + + let bounds = self.bounds + + self.iconNode.frame = CGRect(origin: CGPoint(x: 10.0, y: 14.0), size: CGSize(width: 60.0, height: 60.0)) + self.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 14.0 + 60.0 + 4.0), size: CGSize(width: bounds.size.width, height: 16.0)) + } +} diff --git a/TelegramUI/OpenInAppIconResources.swift b/TelegramUI/OpenInAppIconResources.swift new file mode 100644 index 0000000000..78fe322d28 --- /dev/null +++ b/TelegramUI/OpenInAppIconResources.swift @@ -0,0 +1,59 @@ +import Foundation +import TelegramCore +import SwiftSignalKit +import Postbox + +func fetchOpenInAppIconResource(account: Account, resource: OpenInAppIconResource) -> Signal { + return Signal { subscriber in + subscriber.putNext(.reset) + + let metaUrl = "https://itunes.apple.com/lookup?id=\(resource.appStoreId)" + + let fetchDisposable = MetaDisposable() + + let disposable = fetchHttpResource(url: metaUrl).start(next: { result in + if case let .dataPart(_, data, _, complete) = result, complete { + guard let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + guard let results = dict["results"] as? [Any] else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + guard let result = results.first as? [String: Any] else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + guard let artworkUrl = result["artworkUrl100"] as? String else { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } + + if artworkUrl.isEmpty { + subscriber.putNext(.dataPart(resourceOffset: 0, data: Data(), range: 0 ..< 0, complete: true)) + subscriber.putCompletion() + return + } else { + fetchDisposable.set(fetchHttpResource(url: artworkUrl).start(next: { next in + subscriber.putNext(next) + }, completed: { + subscriber.putCompletion() + })) + } + } + }) + + return ActionDisposable { + disposable.dispose() + fetchDisposable.dispose() + } + } +} diff --git a/TelegramUI/OpenInOptions.swift b/TelegramUI/OpenInOptions.swift new file mode 100644 index 0000000000..eb7bd2b81a --- /dev/null +++ b/TelegramUI/OpenInOptions.swift @@ -0,0 +1,218 @@ +import UIKit +import TelegramCore +import CoreLocation +import MapKit + +enum OpenInItem { + case url(_ url: String) + case location(_ location: TelegramMediaMap, withDirections: Bool) +} + +enum OpenInApplication { + case safari + case maps + case other(title: String, identifier: Int64, scheme: String) +} + +enum OpenInAction { + case openUrl(_ url: String) + case openLocation(latitude: Double, longitude: Double, withDirections: Bool) +} + +final class OpenInOption { + let application: OpenInApplication + let action: () -> OpenInAction + + init(application: OpenInApplication, action: @escaping () -> OpenInAction) { + self.application = application + self.action = action + } + + var title: String { + get { + switch self.application { + case .safari: + return "Safari" + case .maps: + return "Maps" + case let .other(title, _, _): + return title + } + } + } +} + +func availableOpenInOptions(applicationContext: TelegramApplicationContext, item: OpenInItem) -> [OpenInOption] { + return allOpenInOptions(applicationContext: applicationContext, item: item).filter { option in + if case let .other(_, _, scheme) = option.application { + return applicationContext.applicationBindings.canOpenUrl("\(scheme)://") + } else { + return true + } + } +} + +private func allOpenInOptions(applicationContext: TelegramApplicationContext, item: OpenInItem) -> [OpenInOption] { + var options: [OpenInOption] = [] + switch item { + case let .url(url): + options.append(OpenInOption(application: .safari, action: { + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Chrome", identifier: 535886823, scheme: "chrome"), action: { +// NSURL *url = (NSURL *)self.object; +// NSString *scheme = [url.scheme lowercaseString]; +// +// bool secure = [scheme isEqualToString:@"https"]; +// if (!secure && ![scheme isEqualToString:@"http"]) +// return; +// +// NSURL *openInURL = nil; +// if (iosMajorVersion() >= 7) +// { +// NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:true]; +// components.scheme = secure ? @"googlechromes" : @"googlechrome"; +// openInURL = components.URL; +// } +// else +// { +// NSString *str = url.absoluteString; +// NSInteger colon = [str rangeOfString:@":"].location; +// if (colon != NSNotFound) +// str = [(secure ? @"googlechromes" : @"googlechrome") stringByAppendingString:[str substringFromIndex:colon]]; +// openInURL = [NSURL URLWithString:str]; +// } + + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Firefox", identifier: 989804926, scheme: "firefox"), action: { +// NSURL *url = (NSURL *)self.object; +// NSString *scheme = [url.scheme lowercaseString]; +// +// if (![scheme isEqualToString:@"http"] && ![scheme isEqualToString:@"https"]) +// return; +// +// NSURL *openInURL = [NSURL URLWithString:[NSString stringWithFormat:@"firefox://open-url?url=%@", [TGStringUtils stringByEscapingForURL:url.absoluteString]]]; +// [TGOpenInBrowserItem openURL:openInURL]; + + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Opera Mini", identifier: 363729560, scheme: "opera-http"), action: { +// bool secure = [scheme isEqualToString:@"https"]; +// if (!secure && ![scheme isEqualToString:@"http"]) +// return; +// +// NSURL *openInURL = nil; +// if (iosMajorVersion() >= 7) +// { +// NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:true]; +// components.scheme = secure ? @"opera-https" : @"opera-http"; +// openInURL = components.URL; +// } +// else +// { +// NSString *str = url.absoluteString; +// NSInteger colon = [str rangeOfString:@":"].location; +// if (colon != NSNotFound) +// str = [(secure ? @"opera-https" : @"opera-http") stringByAppendingString:[str substringFromIndex:colon]]; +// openInURL = [NSURL URLWithString:str]; +// } + return .openUrl(url) + })) + + options.append(OpenInOption(application: .other(title: "Yandex", identifier: 483693909, scheme: "yandexbrowser-open-url"), action: { +// NSURL *url = (NSURL *)self.object; +// NSString *scheme = [url.scheme lowercaseString]; +// +// if (![scheme isEqualToString:@"http"] && ![scheme isEqualToString:@"https"]) +// return; +// +// NSURL *openInURL = [NSURL URLWithString:[NSString stringWithFormat:@"yandexbrowser-open-url://%@", [TGStringUtils stringByEscapingForURL:url.absoluteString]]]; +// [TGOpenInBrowserItem openURL:openInURL]; + return .openUrl(url) + })) + + + case let .location(location, withDirections): + let lat = location.latitude + let lon = location.longitude + + if let venue = location.venue, let venueId = venue.id, let provider = venue.provider, provider == "foursquare" { + options.append(OpenInOption(application: .other(title: "Foursquare", identifier: 306934924, scheme: "foursquare"), action: { + return .openUrl("foursquare://venues/\(venueId)") + })) + } + + options.append(OpenInOption(application: .maps, action: { + return .openLocation(latitude: lat, longitude: lon, withDirections: withDirections) + })) + + options.append(OpenInOption(application: .other(title: "Google Maps", identifier: 585027354, scheme: "comgooglemaps-x-callback"), action: { + let coordinates = "\(lat),\(lon)" + if withDirections { + return .openUrl("comgooglemaps-x-callback://?daddr=\(coordinates)&directionsmode=driving&x-success=telegram://?resume=true&&x-source=Telegram") + } else { + return .openUrl("comgooglemaps-x-callback://?center=\(coordinates)&q=\(coordinates)&x-success=telegram://?resume=true&&x-source=Telegram") + } + })) + + options.append(OpenInOption(application: .other(title: "Yandex.Maps", identifier: 313877526, scheme: "yandexmaps"), action: { + if withDirections { + return .openUrl("yandexmaps://build_route_on_map?lat_to=\(lat)&lon_to=\(lon)") + } else { + return .openUrl("yandexmaps://maps.yandex.ru/?pt=\(lat),\(lon)&z=16") + } + })) + + options.append(OpenInOption(application: .other(title: "Uber", identifier: 368677368, scheme: "uber"), action: { + var dropoffName = "" + var dropoffAddress = "" + if let title = location.venue?.title.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), title.count > 0 { + dropoffName = title + } + if let address = location.venue?.address?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), address.count > 0 { + dropoffAddress = address + } + return .openUrl("uber://?client_id=&action=setPickup&pickup=my_location&dropoff[latitude]=\(lat)&dropoff[longitude]=\(lon)&dropoff[nickname]=\(dropoffName)&dropoff[formatted_address]=\(dropoffAddress)") + })) + + options.append(OpenInOption(application: .other(title: "Lyft", identifier: 529379082, scheme: "lyft"), action: { + return .openUrl("lyft://ridetype?id=lyft&destination[latitude]=\(lat)&destination[longitude]=\(lon)") + })) + + options.append(OpenInOption(application: .other(title: "Citymapper", identifier: 469463298, scheme: "citymapper"), action: { + var endName = "" + var endAddress = "" + if let title = location.venue?.title.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), title.count > 0 { + endName = title + } + if let address = location.venue?.address?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), address.count > 0 { + endAddress = address + } + return .openUrl("citymapper://directions?endcoord=\(lat),\(lon)&endname=\(endName)&endaddress=\(endAddress)") + })) + + if withDirections { + options.append(OpenInOption(application: .other(title: "Yandex.Navi", identifier: 474500851, scheme: "yandexnavi"), action: { + return .openUrl("yandexnavi://build_route_on_map?lat_to=\(lat)&lon_to=\(lon)") + })) + } + + options.append(OpenInOption(application: .other(title: "HERE Maps", identifier: 955837609, scheme: "here-location"), action: { + return .openUrl("here-location://\(lat),\(lon)") + })) + + options.append(OpenInOption(application: .other(title: "Waze", identifier: 323229106, scheme: "waze"), action: { + let url = "waze://?ll=\(lat),\(lon)" + if withDirections { + return .openUrl(url.appending("&navigate=yes")) + } else { + return .openUrl(url) + } + })) + } + return options +} diff --git a/TelegramUI/PeerMediaCollectionController.swift b/TelegramUI/PeerMediaCollectionController.swift index 02a34ad840..efff4cd806 100644 --- a/TelegramUI/PeerMediaCollectionController.swift +++ b/TelegramUI/PeerMediaCollectionController.swift @@ -175,10 +175,13 @@ public class PeerMediaCollectionController: TelegramController { if let strongSelf = self { switch content { case let .url(url): + let canOpenIn = true + let openText = canOpenIn ? strongSelf.presentationData.strings.Conversation_FileOpenIn : strongSelf.presentationData.strings.Conversation_LinkDialogOpen + let actionSheet = ActionSheetController(presentationTheme: strongSelf.presentationData.theme) actionSheet.setItemGroups([ActionSheetItemGroup(items: [ ActionSheetTextItem(title: url), - ActionSheetButtonItem(title: strongSelf.presentationData.strings.Conversation_LinkDialogOpen, color: .accent, action: { [weak actionSheet] in + ActionSheetButtonItem(title: openText, color: .accent, action: { [weak actionSheet] in actionSheet?.dismissAnimated() if let strongSelf = self { if let applicationContext = strongSelf.account.applicationContext as? TelegramApplicationContext { diff --git a/TelegramUI/PhotoResources.swift b/TelegramUI/PhotoResources.swift index 3e43cf7c28..1089d1d0ac 100644 --- a/TelegramUI/PhotoResources.swift +++ b/TelegramUI/PhotoResources.swift @@ -2427,3 +2427,101 @@ func securePhotoInternal(account: Account, resource: TelegramMediaResource, acce }) } } + +private func openInAppIconData(postbox: Postbox, appIcon: MediaResource) -> Signal<(Data?), NoError> { + let appIconResource = postbox.mediaBox.resourceData(appIcon) + + let signal = appIconResource |> take(1) |> mapToSignal { maybeData -> Signal<(Data?), NoError> in + if maybeData.complete { + let loadedData: Data? = try? Data(contentsOf: URL(fileURLWithPath: maybeData.path), options: []) + return .single((loadedData)) + } else { + let fetchedAppIcon = postbox.mediaBox.fetchedResource(appIcon, parameters: nil) + + let appIcon = Signal { subscriber in + let fetchedDisposable = fetchedAppIcon.start() + let appIconDisposable = appIconResource.start(next: { next in + subscriber.putNext(next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: [])) + }, error: subscriber.putError, completed: subscriber.putCompletion) + + return ActionDisposable { + fetchedDisposable.dispose() + appIconDisposable.dispose() + } + } + + return appIcon + } + } |> distinctUntilChanged(isEqual: { lhs, rhs in + if lhs == nil && rhs == nil { + return true + } else { + return false + } + }) + + return signal +} + +private func drawOpenInAppIconBorder(into c: CGContext, arguments: TransformImageArguments) { + c.setBlendMode(.normal) + c.setStrokeColor(UIColor(rgb: 0xeeeeee).cgColor) + c.setLineWidth(1.0) + + var cornerRadius: CGFloat = 0.0 + if case let .Corner(radius) = arguments.corners.topLeft, radius > CGFloat.ulpOfOne { + cornerRadius = radius + } + + let path = UIBezierPath(roundedRect: arguments.drawingRect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: cornerRadius) + c.addPath(path.cgPath) + c.strokePath() +} + +func openInAppIcon(postbox: Postbox, appIcon: TelegramMediaResource?) -> Signal<(TransformImageArguments) -> DrawingContext?, NoError> { + if let appIcon = appIcon { + return openInAppIconData(postbox: postbox, appIcon: appIcon) |> map { data in + return { arguments in + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + var sourceImage: UIImage? + if let data = data, let image = UIImage(data: data) { + sourceImage = image + } + + if let sourceImage = sourceImage, let cgImage = sourceImage.cgImage { + let imageSize = sourceImage.size.aspectFilled(arguments.drawingRect.size) + context.withFlippedContext { c in + c.draw(cgImage, in: CGRect(origin: CGPoint(x: floor((arguments.drawingRect.size.width - imageSize.width) / 2.0), y: floor((arguments.drawingRect.size.height - imageSize.height) / 2.0)), size: imageSize)) + drawOpenInAppIconBorder(into: c, arguments: arguments) + } + } else { + context.withFlippedContext { c in + drawOpenInAppIconBorder(into: c, arguments: arguments) + } + } + + addCorners(context, arguments: arguments) + + return context + } + } + } else { + return .single({ arguments in + let context = DrawingContext(size: arguments.drawingSize, clear: true) + + let img = UIImage(bundleImageName: "Open In/Safari") + + context.withFlippedContext { c in + if let image = img { + c.draw(image.cgImage!, in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: arguments.drawingSize)) + } + drawOpenInAppIconBorder(into: c, arguments: arguments) + } + + addCorners(context, arguments: arguments) + + return context + }) + } +} diff --git a/TelegramUI/Resources/WebEmbed/Generic.html b/TelegramUI/Resources/WebEmbed/Generic.html new file mode 100755 index 0000000000..bb80007337 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Generic.html @@ -0,0 +1,28 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/GenericUserScript.js b/TelegramUI/Resources/WebEmbed/GenericUserScript.js new file mode 100644 index 0000000000..4430900d38 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/GenericUserScript.js @@ -0,0 +1,26 @@ +function initialize() { + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitExitFullscreen = undefined; + + video.play(); + } +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); diff --git a/TelegramUI/Resources/WebEmbed/Instagram.html b/TelegramUI/Resources/WebEmbed/Instagram.html new file mode 100755 index 0000000000..147d33a100 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Instagram.html @@ -0,0 +1,40 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/Twitch.html b/TelegramUI/Resources/WebEmbed/Twitch.html new file mode 100755 index 0000000000..b1b5faa9e3 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Twitch.html @@ -0,0 +1,47 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/TwitchUserScript.js b/TelegramUI/Resources/WebEmbed/TwitchUserScript.js new file mode 100644 index 0000000000..c4865582a7 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/TwitchUserScript.js @@ -0,0 +1,114 @@ +function initialize() { + var controls = document.getElementsByClassName("player-controls-bottom")[0]; + if (controls == null) { + controls = document.getElementsByClassName("player-overlay-container")[0]; + } + if (controls != null) { + controls.style.display = "none"; + } + + var topBar = document.getElementById("top-bar"); + if (topBar == null) { + topBar = document.getElementsByClassName("player-controls-top")[0]; + } + if (topBar != null) { + topBar.style.display = "none"; + } + + var pauseOverlay = document.getElementsByClassName("player-play-overlay")[0]; + if (pauseOverlay == null) { + pauseOverlay = document.getElementsByClassName("player-controls-bottom")[0]; + } + if (pauseOverlay != null) { + pauseOverlay.style.display = "none"; + } + + var statusOverlay = document.getElementsByClassName("player-streamstatus")[0]; + if (statusOverlay != null) { + statusOverlay.style.right = undefined; + statusOverlay.style.left = "0px"; + statusOverlay.style.padding = "1.5em 1.5em 5.5em 2.5em"; + } + + var recommendationOverlay = document.getElementById("js-player-recommendations-overlay"); + if (recommendationOverlay != null) { + recommendationOverlay.style.display = "none"; + } + + var adOverlay = document.getElementsByClassName("player-ad-overlay")[0]; + if (adOverlay != null) { + adOverlay.style.display = "none"; + } + + var alertOverlay = document.getElementById("js-player-alert-container"); + if (alertOverlay != null) { + alertOverlay.style.display = "none"; + } + + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitEnterFullscreen = undefined; + video.addEventListener("playing", onPlaybackStart, false); + video.play(); + } + + var css = "video::-webkit-media-controls { display: none !important } video::--webkit-media-controls-play-button { display: none !important; -webkit-appearance: none; } video::-webkit-media-controls-start-playback-button { display: none !important; -webkit-appearance: none; }", + head = document.head || document.getElementsByTagName("head")[0], + style = document.createElement("style"); + style.type = "text/css"; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + head.appendChild(style); + + var ageButton = document.getElementById("mature-link"); + if (ageButton != null) { + eventFire(ageButton, "click"); + } +} + +function onPlaybackStart() { + window.parent.postMessage("playbackStarted", "*"); +} + +function eventFire(el, etype){ + if (el.fireEvent) { + el.fireEvent("on" + etype); + } else { + var evObj = document.createEvent("Events"); + evObj.initEvent(etype, true, false); + el.dispatchEvent(evObj); + } +} + +function play() { + var playButton = document.getElementsByClassName("js-control-playpause-button")[0]; + if (playButton == null) { + playButton = document.getElementsByClassName("player-button--playpause")[0]; + } + + eventFire(playButton, "click"); +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + else if (obj.command == "play") + play(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); + diff --git a/TelegramUI/Resources/WebEmbed/Vimeo.html b/TelegramUI/Resources/WebEmbed/Vimeo.html new file mode 100755 index 0000000000..ecea52af89 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Vimeo.html @@ -0,0 +1,101 @@ + + + + + + +
+ +
+ + + diff --git a/TelegramUI/Resources/WebEmbed/VimeoUserScript.js b/TelegramUI/Resources/WebEmbed/VimeoUserScript.js new file mode 100644 index 0000000000..dae2e18316 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/VimeoUserScript.js @@ -0,0 +1,53 @@ +function initialize() { + var controls = document.getElementsByClassName("controls")[0]; + if (controls != null) { + controls.style.display = "none"; + } + + var sidedock = document.getElementsByClassName("sidedock")[0]; + if (sidedock != null) { + sidedock.style.display = "none"; + } + + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitEnterFullscreen = undefined; + } +} + +function eventFire(el, etype){ + if (el.fireEvent) { + el.fireEvent("on" + etype); + } else { + var evObj = document.createEvent("Events"); + evObj.initEvent(etype, true, false); + el.dispatchEvent(evObj); + } +} + +function autoplay() { + var playButton = document.getElementsByClassName("play")[0]; + if (playButton != null) { + eventFire(playButton, "click"); + } +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + else if (obj.command == "autoplay") + autoplay(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); diff --git a/TelegramUI/Resources/WebEmbed/Youtube.html b/TelegramUI/Resources/WebEmbed/Youtube.html new file mode 100755 index 0000000000..911bfcff04 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/Youtube.html @@ -0,0 +1,99 @@ + + + + + + +
+
+
+ + + + diff --git a/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js b/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js new file mode 100644 index 0000000000..e0b3cbc341 --- /dev/null +++ b/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js @@ -0,0 +1,58 @@ +function initialize() { + var css = "video::-webkit-media-controls { display: none !important } video::--webkit-media-controls-play-button { display: none !important; -webkit-appearance: none; } video::-webkit-media-controls-start-playback-button { display: none !important; -webkit-appearance: none; }", + head = document.head || document.getElementsByTagName("head")[0], + style = document.createElement("style"); + + style.type = "text/css"; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + + head.appendChild(style); +} + +function tick() { + var watermark = document.getElementsByClassName("ytp-watermark")[0]; + if (watermark != null) { + watermark.style.display = "none"; + } + + var button = document.getElementsByClassName("ytp-large-play-button")[0]; + if (button != null) { + button.style.display = "none"; + button.style.opacity = "0"; + } + + var progress = document.getElementsByClassName("ytp-spinner-container")[0]; + if (progress != null) { + progress.style.display = "none"; + progress.style.opacity = "0"; + } + + var video = document.getElementsByTagName("video")[0]; + if (video != null) { + video.setAttribute("webkit-playsinline", ""); + video.setAttribute("playsinline", ""); + video.webkitEnterFullscreen = undefined; + } +} + +function receiveMessage(evt) { + if ((typeof evt.data) != "string") + return; + + try { + var obj = JSON.parse(evt.data); + if (!obj.event || obj.event != "inject") + return; + + if (obj.command == "initialize") + initialize(); + else if (obj.command == "tick") + tick(); + } catch (ex) { } +} + +window.addEventListener("message", receiveMessage, false); diff --git a/TelegramUI/SoundCloudEmbedImplementation.swift b/TelegramUI/SoundCloudEmbedImplementation.swift new file mode 100644 index 0000000000..f893c8151c --- /dev/null +++ b/TelegramUI/SoundCloudEmbedImplementation.swift @@ -0,0 +1,59 @@ +import UIKit + +func extractSoundCloudTrackIdAndTimestamp(url: String) -> (String, Int)? { + guard let url = URL(string: url), let host = url.host?.lowercased() else { + return nil + } + + let domain = "w.soundcloud.com" + let match = host == domain || host.contains(".\(domain)") + + guard match else { + return nil + } + + var videoId: String? + var timestamp: Int? + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "v" { + videoId = value + } else if queryItem.name == "t" || queryItem.name == "time_continue" { + if value.contains("s") { + + } else { + timestamp = Int(value) + } + } + } + } + } + + if videoId == nil { + let pathComponents = components.path.components(separatedBy: "/") + var nextComponentIsVideoId = host.contains("youtu.be") + + for component in pathComponents { + if nextComponentIsVideoId { + videoId = component + break + } else if component == "embed" { + nextComponentIsVideoId = true + } + } + } + } + + if let videoId = videoId { + return (videoId, timestamp ?? 0) + } + + return nil +} + +final class SoundCloudEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/StreamableEmbedImplementation.swift b/TelegramUI/StreamableEmbedImplementation.swift new file mode 100644 index 0000000000..87b193e031 --- /dev/null +++ b/TelegramUI/StreamableEmbedImplementation.swift @@ -0,0 +1,5 @@ +import UIKit + +final class StreamableEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/TelegramAccountAuxiliaryMethods.swift b/TelegramUI/TelegramAccountAuxiliaryMethods.swift index 891040dba4..881b9a73bb 100644 --- a/TelegramUI/TelegramAccountAuxiliaryMethods.swift +++ b/TelegramUI/TelegramAccountAuxiliaryMethods.swift @@ -25,6 +25,8 @@ public let telegramAccountAuxiliaryMethods = AccountAuxiliaryMethods(updatePeerC return fetchICloudFileResource(resource: resource) } else if let resource = resource as? SecureIdLocalImageResource { return fetchSecureIdLocalImageResource(postbox: account.postbox, resource: resource) + } else if let resource = resource as? OpenInAppIconResource { + return fetchOpenInAppIconResource(account: account, resource: resource) } return nil }, fetchResourceMediaReferenceHash: { resource in diff --git a/TelegramUI/TwitchEmbedImplementation.swift b/TelegramUI/TwitchEmbedImplementation.swift new file mode 100644 index 0000000000..25bc13273b --- /dev/null +++ b/TelegramUI/TwitchEmbedImplementation.swift @@ -0,0 +1,5 @@ +import UIKit + +final class TwitchEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/UniversalVideoCalleryItem.swift b/TelegramUI/UniversalVideoCalleryItem.swift index 1dedf40688..469defb826 100644 --- a/TelegramUI/UniversalVideoCalleryItem.swift +++ b/TelegramUI/UniversalVideoCalleryItem.swift @@ -38,6 +38,11 @@ class UniversalVideoGalleryItem: GalleryItem { func node() -> GalleryItemNode { let node = UniversalVideoGalleryItemNode(account: self.account, theme: self.theme, strings: self.strings) + + if let indexData = self.indexData { + node._title.set(.single("\(indexData.position + 1) \(self.strings.Common_of) \(indexData.totalCount)")) + } + node.setupItem(self) return node @@ -45,6 +50,10 @@ class UniversalVideoGalleryItem: GalleryItem { func updateNode(node: GalleryItemNode) { if let node = node as? UniversalVideoGalleryItemNode { + if let indexData = self.indexData { + node._title.set(.single("\(indexData.position + 1) \(self.strings.Common_of) \(indexData.totalCount)")) + } + node.setupItem(self) } } @@ -149,6 +158,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { self.scrubberView = ChatVideoGalleryItemScrubberView() self.footerContentNode = ChatItemGalleryFooterContentNode(account: account, theme: theme, strings: strings) + self.footerContentNode.scrubberView = self.scrubberView self.statusButtonNode = HighlightableButtonNode() self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5)) @@ -175,6 +185,24 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { strongSelf.videoNode?.togglePlayPause() } } + self.footerContentNode.seekBackward = { [weak self] in + if let strongSelf = self, let videoNode = strongSelf.videoNode { + let _ = (videoNode.status |> take(1)).start(next: { [weak videoNode] status in + if let strongVideoNode = videoNode, let timestamp = status?.timestamp { + strongVideoNode.seek(max(0.0, timestamp - 15.0)) + } + }) + } + } + self.footerContentNode.seekForward = { [weak self] in + if let strongSelf = self, let videoNode = strongSelf.videoNode { + let _ = (videoNode.status |> take(1)).start(next: { [weak videoNode] status in + if let strongVideoNode = videoNode, let timestamp = status?.timestamp, let duration = status?.duration { + strongVideoNode.seek(min(duration, timestamp + 15.0)) + } + }) + } + } } deinit { @@ -243,6 +271,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if let strongSelf = self { var initialBuffering = false var isPaused = true + var seekable = false if let value = value { if let zoomableContent = strongSelf.zoomableContent, !value.dimensions.width.isZero && !value.dimensions.height.isZero { let videoSize = CGSize(width: value.dimensions.width * 2.0, height: value.dimensions.height * 2.0) @@ -270,6 +299,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } } } + seekable = value.duration >= 44.0 } if initialBuffering { @@ -286,12 +316,12 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } if isPaused { if strongSelf.didPause { - strongSelf.footerContentNode.content = .playbackPlay + strongSelf.footerContentNode.content = .playback(paused: true, seekable: seekable) } else { strongSelf.footerContentNode.content = .info } } else { - strongSelf.footerContentNode.content = .playbackPause + strongSelf.footerContentNode.content = .playback(paused: false, seekable: seekable) } } })) @@ -308,7 +338,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } if !isAnimated && !isInstagram { - self._titleView.set(.single(self.scrubberView)) + //self._titleView.set(.single(self.scrubberView)) } if !isAnimated { diff --git a/TelegramUI/VKEmbedImplementation.swift b/TelegramUI/VKEmbedImplementation.swift new file mode 100644 index 0000000000..16e18c5867 --- /dev/null +++ b/TelegramUI/VKEmbedImplementation.swift @@ -0,0 +1,5 @@ +import UIKit + +final class VKEmbedImplementation: WebEmbedImplementation { + +} diff --git a/TelegramUI/VimeoEmbedImplementation.swift b/TelegramUI/VimeoEmbedImplementation.swift new file mode 100644 index 0000000000..54ed30cad8 --- /dev/null +++ b/TelegramUI/VimeoEmbedImplementation.swift @@ -0,0 +1,208 @@ +import Foundation +import WebKit +import SwiftSignalKit + +func extractVimeoVideoIdAndTimestamp(url: String) -> (String, Int)? { + guard let url = URL(string: url), let host = url.host?.lowercased() else { + return nil + } + + let domain = "player.vimeo.com" + let match = host == domain || host.contains(".\(domain)") + + guard match else { + return nil + } + + var videoId: String? + var timestamp = 0 + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { +// if let queryItems = components.queryItems { +// for queryItem in queryItems { +// if let value = queryItem.value { +// if queryItem.name == "v" { +// videoId = value +// } else if queryItem.name == "t" || queryItem.name == "time_continue" { +// if value.contains("s") { +// +// } else { +// timestamp = Int(value) +// } +// } +// } +// } +// } + + if videoId == nil { + let pathComponents = components.path.components(separatedBy: "/") + var nextComponentIsVideoId = false + + for component in pathComponents { + if nextComponentIsVideoId { + videoId = component + break + } else if component == "video" { + nextComponentIsVideoId = true + } + } + } + } + + if let videoId = videoId { + return (videoId, timestamp) + } + + return nil +} + +final class VimeoEmbedImplementation: WebEmbedImplementation { + private var evalImpl: ((String) -> Void)? + private var updateStatus: ((MediaPlayerStatus) -> Void)? + private var onPlaybackStarted: (() -> Void)? + + private let videoId: String + private let timestamp: Int + private var status : MediaPlayerStatus + + private var ready: Bool = false + private var started: Bool = false + private var ignorePosition: Int? + + init(videoId: String, timestamp: Int = 0) { + self.videoId = videoId + self.timestamp = timestamp + self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true)) + } + + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + let bundle = Bundle(for: type(of: self)) + guard let userScriptPath = bundle.path(forResource: "VimeoUserScript", ofType: "js") else { + return + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return + } + guard let htmlTemplatePath = bundle.path(forResource: "Vimeo", ofType: "html") else { + return + } + guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else { + return + } + guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else { + return + } + + self.evalImpl = evaluateJavaScript + self.updateStatus = updateStatus + self.onPlaybackStarted = onPlaybackStarted + updateStatus(self.status) + + let html = String(format: htmlTemplate, self.videoId, "true") + webView.loadHTMLString(html, baseURL: URL(string: "https://player.vimeo.com/")) + webView.isUserInteractionEnabled = false + + userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) + } + + func play() { + if let eval = evalImpl { + eval("play();") + } + + ignorePosition = 2 + } + + func pause() { + if let eval = evalImpl { + eval("pause();") + } + } + + func togglePlayPause() { + if case .playing = self.status.status { + pause() + } else { + play() + } + } + + func seek(timestamp: Double) { + if let eval = evalImpl { + eval("seek(\(timestamp));") + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, seekId: self.status.seekId + 1, status: self.status.status) + if let updateStatus = self.updateStatus { + updateStatus(self.status) + } + + ignorePosition = 2 + } + + func pageReady() { + } + + func callback(url: URL) { + if url.host == "onState" { + var newTimestamp = self.status.timestamp + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + var playback: Int? + var position: Double? + var duration: Double? + var download: Float? + var failed: Bool? + + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "playback" { + playback = Int(value) + } else if queryItem.name == "position" { + position = Double(value) + } else if queryItem.name == "duration" { + duration = Double(value) + } else if queryItem.name == "download" { + download = Float(value) + } + } + } + } + + if let position = position { + if let ticksToIgnore = self.ignorePosition { + if ticksToIgnore > 1 { + self.ignorePosition = ticksToIgnore - 1 + } else { + self.ignorePosition = nil + } + } else { + newTimestamp = Double(position) + } + } + + if let updateStatus = self.updateStatus, let playback = playback, let duration = duration { + let playbackStatus: MediaPlayerPlaybackStatus + switch playback { + case 0: + playbackStatus = .paused + case 1: + playbackStatus = .playing + case 2: + playbackStatus = .paused + newTimestamp = 0.0 + default: + playbackStatus = .buffering(initial: true, whilePlaying: false) + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: Double(duration), dimensions: self.status.dimensions, timestamp: newTimestamp, seekId: self.status.seekId, status: playbackStatus) + updateStatus(self.status) + } + } + } + } +} diff --git a/TelegramUI/WebEmbedPlayerNode.swift b/TelegramUI/WebEmbedPlayerNode.swift new file mode 100644 index 0000000000..d8812af13c --- /dev/null +++ b/TelegramUI/WebEmbedPlayerNode.swift @@ -0,0 +1,148 @@ +import Foundation +import AsyncDisplayKit +import SwiftSignalKit +import WebKit + +protocol WebEmbedImplementation { + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) + + func play() + func pause() + func togglePlayPause() + func seek(timestamp: Double) + + func pageReady() + func callback(url: URL) +} + +func webEmbedImplementation(embedUrl: String, url: String) -> WebEmbedImplementation { + if let (videoId, timestamp) = extractYoutubeVideoIdAndTimestamp(url: url) { + return YoutubeEmbedImplementation(videoId: videoId, timestamp: timestamp) + } else if let (videoId, timestamp) = extractVimeoVideoIdAndTimestamp(url: url) { + return VimeoEmbedImplementation(videoId: videoId, timestamp: timestamp) + } + + return GenericEmbedImplementation(url: url) +} + +final class WebEmbedPlayerNode: ASDisplayNode, WKNavigationDelegate { + private let statusValue = ValuePromise(MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, seekId: 0, status: .paused), ignoreRepeated: true) + + var status: Signal { + return self.statusValue.get() + } + + private let impl: WebEmbedImplementation + + private let intrinsicDimensions: CGSize + private let webView: WKWebView + + private let semaphore = DispatchSemaphore(value: 0) + private let queue = Queue() + + init(impl: WebEmbedImplementation, intrinsicDimensions: CGSize) { + self.impl = impl + self.intrinsicDimensions = intrinsicDimensions + + let userContentController = WKUserContentController() + userContentController.addUserScript(WKUserScript(source: "var meta = document.createElement('meta'); meta.setAttribute('name', 'viewport'); meta.setAttribute('content', 'width=device-width'); document.getElementsByTagName('head')[0].appendChild(meta)", injectionTime: .atDocumentEnd, forMainFrameOnly: true)) + + let config = WKWebViewConfiguration() + config.allowsInlineMediaPlayback = true + config.userContentController = userContentController + + if #available(iOSApplicationExtension 10.0, *) { + config.mediaTypesRequiringUserActionForPlayback = [] + } else if #available(iOSApplicationExtension 9.0, *) { + config.requiresUserActionForMediaPlayback = false + } else { + config.mediaPlaybackRequiresUserAction = false + } + + if #available(iOSApplicationExtension 9.0, *) { + config.allowsPictureInPictureMediaPlayback = false + } + + let frame = CGRect(origin: CGPoint.zero, size: intrinsicDimensions) + self.webView = WKWebView(frame: frame, configuration: config) + + super.init() + self.frame = frame + + self.webView.navigationDelegate = self + self.webView.scrollView.isScrollEnabled = false + if #available(iOSApplicationExtension 11.0, *) { + self.webView.accessibilityIgnoresInvertColors = true + self.webView.scrollView.contentInsetAdjustmentBehavior = .never + } + self.view.addSubview(self.webView) + + self.impl.setup(self.webView, userContentController: userContentController, evaluateJavaScript: { [weak self] js in + if let strongSelf = self { + strongSelf.evaluateJavaScript(js: js) + } + }, updateStatus: { [weak self] status in + if let strongSelf = self { + strongSelf.statusValue.set(status) + } + }, onPlaybackStarted: { [weak self] in + + }) + } + + func play() { + self.impl.play() + } + + func pause() { + self.impl.pause() + } + + func togglePlayPause() { + self.impl.togglePlayPause() + } + + func seek(timestamp: Double) { + self.impl.seek(timestamp: timestamp) + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.impl.pageReady() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + if let error = error as? WKError, error.code.rawValue == 204 { + return + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if let url = navigationAction.request.url, url.scheme == "embed" { + self.impl.callback(url: url) + decisionHandler(.cancel) + } else if let _ = navigationAction.targetFrame { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + + private func evaluateJavaScript(js: String) { + self.queue.async { [weak self] in + if let strongSelf = self { + let impl = { + strongSelf.webView.evaluateJavaScript(js, completionHandler: { (_, _) in + strongSelf.semaphore.signal() + }) + } + + Queue.mainQueue().async(impl) + strongSelf.semaphore.wait() + } + } + } +} diff --git a/TelegramUI/WebEmbedVideoContent.swift b/TelegramUI/WebEmbedVideoContent.swift index 879fe0d835..268d7bb3cc 100644 --- a/TelegramUI/WebEmbedVideoContent.swift +++ b/TelegramUI/WebEmbedVideoContent.swift @@ -7,32 +7,6 @@ import TelegramCore import LegacyComponents -func webEmbedVideoContentSupportsWebpage(_ webpageContent: TelegramMediaWebpageLoadedContent) -> Bool { - switch websiteType(of: webpageContent) { - case .instagram: - return true - default: - break - } - - let converted = TGWebPageMediaAttachment() - - converted.url = webpageContent.url - converted.displayUrl = webpageContent.displayUrl - converted.pageType = webpageContent.type - converted.siteName = webpageContent.websiteName - converted.title = webpageContent.title - converted.pageDescription = webpageContent.text - converted.embedUrl = webpageContent.embedUrl - converted.embedType = webpageContent.embedType - converted.embedSize = webpageContent.embedSize ?? CGSize() - let approximateDuration = Int32(webpageContent.duration ?? 0) - converted.duration = approximateDuration as NSNumber - converted.author = webpageContent.author - - return TGEmbedPlayerView.hasNativeSupportFor(x: converted) -} - final class WebEmbedVideoContent: UniversalVideoContent { let id: AnyHashable let webPage: TelegramMediaWebpage @@ -61,15 +35,13 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte private let intrinsicDimensions: CGSize private let approximateDuration: Int32 - private let playerView: TGEmbedPlayerView - private let playerViewContainer: UIView private let audioSessionDisposable = MetaDisposable() private var hasAudioSession = false private let playbackCompletedListeners = Bag<() -> Void>() private var initializedStatus = false - private let _status = ValuePromise() + private let _status = Promise() var status: Signal { return self._status.get() } @@ -91,6 +63,9 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte return self._preloadCompleted.get() } + private let imageNode: TransformImageNode + private let playerNode: WebEmbedPlayerNode + private let thumbnail = Promise() private var thumbnailDisposable: Disposable? @@ -99,72 +74,45 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte init(postbox: Postbox, audioSessionManager: ManagedAudioSession, webPage: TelegramMediaWebpage, webpageContent: TelegramMediaWebpageLoadedContent) { self.webpageContent = webpageContent - - let converted = TGWebPageMediaAttachment() - - converted.url = webpageContent.url - converted.displayUrl = webpageContent.displayUrl - converted.pageType = webpageContent.type - converted.siteName = webpageContent.websiteName - converted.title = webpageContent.title - converted.pageDescription = webpageContent.text - converted.embedUrl = webpageContent.embedUrl - converted.embedType = webpageContent.embedType - converted.embedSize = webpageContent.embedSize ?? CGSize() self.approximateDuration = Int32(webpageContent.duration ?? 0) - converted.duration = self.approximateDuration as NSNumber - converted.author = webpageContent.author if let embedSize = webpageContent.embedSize { self.intrinsicDimensions = embedSize } else { self.intrinsicDimensions = CGSize(width: 480.0, height: 320.0) } - - var thumbmnailSignal: SSignal? - if let _ = webpageContent.image { - let thumbnail = self.thumbnail - thumbmnailSignal = SSignal(generator: { subscriber in - let disposable = thumbnail.get().start(next: { image in - subscriber?.putNext(image) - }) - - return SBlockDisposable(block: { - disposable.dispose() - }) - }) + + self.imageNode = TransformImageNode() + if let embedUrl = webpageContent.embedUrl { + let impl = webEmbedImplementation(embedUrl: embedUrl, url: webpageContent.url) + self.playerNode = WebEmbedPlayerNode(impl: impl, intrinsicDimensions: self.intrinsicDimensions) + } else { + let impl = GenericEmbedImplementation(url: webpageContent.url) + self.playerNode = WebEmbedPlayerNode(impl: impl, intrinsicDimensions: self.intrinsicDimensions) } - self.playerViewContainer = UIView() - - self.playerView = TGEmbedPlayerView.make(forWebPage: converted, thumbnailSignal: thumbmnailSignal)! - self.playerView.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions) - self.playerViewContainer.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions) - self.playerView.disallowPIP = true - self.playerView.isUserInteractionEnabled = false - //self.playerView.disallowAutoplay = true - self.playerView.disableControls = true - super.init() - self.playerViewContainer.addSubview(self.playerView) - self.view.addSubview(self.playerViewContainer) - self.playerView.setup(withEmbedSize: self.intrinsicDimensions) + self.addSubnode(self.playerNode) + self.addSubnode(self.imageNode) - let nativeLoadProgress = self.playerView.loadProgress() - let loadProgress: Signal = Signal { subscriber in - let disposable = nativeLoadProgress?.start(next: { value in - subscriber.putNext((value as! NSNumber).floatValue) - }) - return ActionDisposable { - disposable?.dispose() - } - } - self.loadProgressDisposable = (loadProgress |> deliverOnMainQueue).start(next: { [weak self] value in - if let strongSelf = self { - strongSelf._preloadCompleted.set(value.isEqual(to: 1.0)) - } - }) +// let nativeLoadProgress = nil //self.playerView.loadProgress() +// let loadProgress: Signal = Signal { subscriber in +// let disposable = nativeLoadProgress?.start(next: { value in +// subscriber.putNext((value as! NSNumber).floatValue) +// }) +// return ActionDisposable { +// disposable?.dispose() +// } +// } + + self._preloadCompleted.set(true) + +// self.loadProgressDisposable = (loadProgress |> deliverOnMainQueue).start(next: { [weak self] value in +// if let strongSelf = self { +// strongSelf._preloadCompleted.set(value.isEqual(to: 1.0)) +// } +// }) if let image = webpageContent.image { self.thumbnailDisposable = (rawMessagePhoto(postbox: postbox, photoReference: .webPage(webPage: WebpageReference(webPage), media: image)) @@ -177,39 +125,8 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte } else { self._ready.set(.single(Void())) } - - self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true))) - - let stateSignal = self.playerView.stateSignal()! - self.statusDisposable = (Signal { subscriber in - let innerDisposable = stateSignal.start(next: { next in - if let next = next as? TGEmbedPlayerState { - let status: MediaPlayerPlaybackStatus - if next.playing { - status = .playing - } else if next.buffering { - status = .buffering(initial: false, whilePlaying: next.playing) - } else { - status = .paused - } - subscriber.putNext(MediaPlayerStatus(generationTimestamp: 0.0, duration: next.duration, dimensions: CGSize(), timestamp: max(0.0, next.position), seekId: 0, status: status)) - } - }) - return ActionDisposable { - innerDisposable?.dispose() - } - } |> deliverOnMainQueue).start(next: { [weak self] value in - if let strongSelf = self { - if !strongSelf.initializedStatus { - if case .paused = value.status { - return - } - } - strongSelf.initializedStatus = true - strongSelf._status.set(MediaPlayerStatus(generationTimestamp: value.generationTimestamp, duration: value.duration, dimensions: CGSize(), timestamp: value.timestamp, seekId: strongSelf.seekId, status: value.status)) - } - }) - + + self._status.set(self.playerNode.status) self._bufferingStatus.set(.single(nil)) } @@ -222,34 +139,25 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte } func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { - transition.updatePosition(layer: self.playerViewContainer.layer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - transition.updateTransformScale(layer: self.playerViewContainer.layer, scale: size.width / self.intrinsicDimensions.width) + transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width) + + transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size)) } func play() { assert(Queue.mainQueue().isCurrent()) - if !self.initializedStatus { - self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true))) - } else { - self.playerView.playVideo() - } + self.playerNode.play() } func pause() { assert(Queue.mainQueue().isCurrent()) - if !self.initializedStatus { - self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, seekId: self.seekId, status: .paused)) - } - self.playerView.pauseVideo() + self.playerNode.pause() } func togglePlayPause() { assert(Queue.mainQueue().isCurrent()) - if let state = self.playerView.state, state.playing { - self.pause() - } else { - self.play() - } + self.playerNode.togglePlayPause() } func setSoundEnabled(_ value: Bool) { @@ -264,7 +172,8 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte func seek(_ timestamp: Double) { assert(Queue.mainQueue().isCurrent()) self.seekId += 1 - self.playerView.seek(toPosition: timestamp) + self.playerNode.seek(timestamp: timestamp) + //self.playerView.seek(toPosition: timestamp) } func playOnceWithSound(playAndRecord: Bool) { diff --git a/TelegramUI/YoutubeEmbedImplementation.swift b/TelegramUI/YoutubeEmbedImplementation.swift new file mode 100644 index 0000000000..8926d650d9 --- /dev/null +++ b/TelegramUI/YoutubeEmbedImplementation.swift @@ -0,0 +1,295 @@ +import Foundation +import WebKit +import SwiftSignalKit + +func extractYoutubeVideoIdAndTimestamp(url: String) -> (String, Int)? { + guard let url = URL(string: url), let host = url.host?.lowercased() else { + return nil + } + + let match = ["youtube.com", "youtu.be"].contains(where: { (domain) -> Bool in + return host == domain || host.contains(".\(domain)") + }) + + guard match else { + return nil + } + + var videoId: String? + var timestamp = 0 + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "v" { + videoId = value + } else if queryItem.name == "t" || queryItem.name == "time_continue" { + if value.contains("s") { + var range = value.startIndex.. 0 && nextComponentIsVideoId { + videoId = component + break + } else if component == "embed" { + nextComponentIsVideoId = true + } + } + } + } + + if let videoId = videoId { + return (videoId, timestamp) + } + + return nil +} + +final class YoutubeEmbedImplementation: WebEmbedImplementation { + private var evalImpl: ((String) -> Void)? + private var updateStatus: ((MediaPlayerStatus) -> Void)? + private var onPlaybackStarted: (() -> Void)? + + private let videoId: String + private let timestamp: Int + private var status : MediaPlayerStatus + + private var ready: Bool = false + private var started: Bool = false + private var ignorePosition: Int? + + enum PlaybackDelay { + case None + case AfterPositionUpdates(count: Int) + } + private var playbackDelay = PlaybackDelay.None + + init(videoId: String, timestamp: Int = 0) { + self.videoId = videoId + self.timestamp = timestamp + self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: Double(timestamp), seekId: 0, status: .buffering(initial: true, whilePlaying: true)) + } + + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + let bundle = Bundle(for: type(of: self)) + guard let userScriptPath = bundle.path(forResource: "YoutubeUserScript", ofType: "js") else { + return + } + guard let userScriptData = try? Data(contentsOf: URL(fileURLWithPath: userScriptPath)) else { + return + } + guard let userScript = String(data: userScriptData, encoding: .utf8) else { + return + } + guard let htmlTemplatePath = bundle.path(forResource: "Youtube", ofType: "html") else { + return + } + guard let htmlTemplateData = try? Data(contentsOf: URL(fileURLWithPath: htmlTemplatePath)) else { + return + } + guard let htmlTemplate = String(data: htmlTemplateData, encoding: .utf8) else { + return + } + + let params: [String : Any] = [ "videoId": self.videoId, + "width": "100%", + "height": "100%", + "events": [ "onReady": "onReady", + "onStateChange": "onStateChange", + "onPlaybackQualityChange": "onPlaybackQualityChange", + "onError": "onPlayerError" ], + "playerVars": [ "cc_load_policy": 1, + "iv_load_policy": 3, + "controls": 0, + "playsinline": 1, + "autohide": 1, + "showinfo": 0, + "rel": 0, + "modestbranding": 1, + "start": timestamp ] ] + + guard let paramsJsonData = try? JSONSerialization.data(withJSONObject: params, options: .prettyPrinted), let paramsJson = String(data: paramsJsonData, encoding: .utf8) else { + return + } + + self.evalImpl = evaluateJavaScript + self.updateStatus = updateStatus + self.onPlaybackStarted = onPlaybackStarted + updateStatus(self.status) + + let html = String(format: htmlTemplate, paramsJson) + webView.loadHTMLString(html, baseURL: URL(string: "https://youtube.com/")) + webView.isUserInteractionEnabled = false + + userContentController.addUserScript(WKUserScript(source: userScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false)) + } + + func play() { + guard ready else { + self.playbackDelay = .AfterPositionUpdates(count: 2) + return + } + + if let eval = evalImpl { + eval("play();") + } + } + + func pause() { + if let eval = evalImpl { + eval("pause();") + } + } + + func togglePlayPause() { + if case .playing = self.status.status { + pause() + } else { + play() + } + } + + func seek(timestamp: Double) { + if let eval = evalImpl { + eval("seek(\(timestamp));") + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, seekId: self.status.seekId + 1, status: self.status.status) + if let updateStatus = self.updateStatus { + updateStatus(self.status) + } + } + + func pageReady() { + } + + func callback(url: URL) { + switch url.host { + case "onState": + var newTimestamp = self.status.timestamp + + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + var playback: Int? + var position: Double? + var duration: Int? + var download: Float? + var failed: Bool? + + if let queryItems = components.queryItems { + for queryItem in queryItems { + if let value = queryItem.value { + if queryItem.name == "playback" { + playback = Int(value) + } else if queryItem.name == "position" { + position = Double(value) + } else if queryItem.name == "duration" { + duration = Int(value) + } else if queryItem.name == "download" { + download = Float(value) + } else if queryItem.name == "failed" { + failed = Bool(value) + } + } + } + } + + if let position = position { + if let ticksToIgnore = self.ignorePosition { + if ticksToIgnore > 1 { + self.ignorePosition = ticksToIgnore - 1 + } else { + self.ignorePosition = nil + } + } else { + newTimestamp = Double(position) + } + } + + if let updateStatus = self.updateStatus, let playback = playback, let duration = duration { + let playbackStatus: MediaPlayerPlaybackStatus + switch playback { + case 0: + playbackStatus = .paused + newTimestamp = 0.0 + case 1: + playbackStatus = .playing + case 2: + playbackStatus = .paused + case 3: + playbackStatus = .buffering(initial: false, whilePlaying: false) + default: + playbackStatus = .buffering(initial: true, whilePlaying: false) + } + + self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: Double(duration), dimensions: self.status.dimensions, timestamp: newTimestamp, seekId: 0, status: playbackStatus) + updateStatus(self.status) + } + } + + if case let .AfterPositionUpdates(count) = self.playbackDelay { + if count == 1 { + self.ready = true + self.playbackDelay = .None + self.play() + } else { + self.playbackDelay = .AfterPositionUpdates(count: count - 1) + } + } + case "onReady": + self.ready = true + + if case .AfterPositionUpdates(_) = self.playbackDelay { + self.playbackDelay = .None + self.play() + } + + Queue.mainQueue().async { + self.play() + + Queue.mainQueue().after(2.0, { + if !self.started { + self.play() + } + }) + } + default: + break + } + } +}