diff --git a/.swiftlint.yml b/.swiftlint.yml index 1f5427fa..aef19e03 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -14,6 +14,7 @@ disabled_rules: - function_body_length # Allow declaring operators without extra whitespace, like so: `func ==(_ lhs, ...)` - operator_whitespace + - redundant_string_enum_value opt_in_rules: # Prefer checking `isEmpty` over `count > 0` diff --git a/Aerial.xcodeproj/project.pbxproj b/Aerial.xcodeproj/project.pbxproj index fe62e0e0..a6ea69ea 100644 --- a/Aerial.xcodeproj/project.pbxproj +++ b/Aerial.xcodeproj/project.pbxproj @@ -8,6 +8,14 @@ /* Begin PBXBuildFile section */ 030D9B7C21551A8D00961E95 /* AerialPlayerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7E2E5D1FC62E8B00E5F320 /* AerialPlayerItem.swift */; }; + 0313F9E622942AA500B074BB /* CustomVideos.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0313F9E522942AA500B074BB /* CustomVideos.xib */; }; + 0313F9E822942B4500B074BB /* CustomVideoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313F9E722942B4500B074BB /* CustomVideoController.swift */; }; + 0313F9E92294337F00B074BB /* CustomVideos.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0313F9E522942AA500B074BB /* CustomVideos.xib */; }; + 0313F9EA2294338300B074BB /* CustomVideoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313F9E722942B4500B074BB /* CustomVideoController.swift */; }; + 0313F9EC2294468600B074BB /* SeededGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313F9EB2294468600B074BB /* SeededGenerator.swift */; }; + 0313F9ED2294468600B074BB /* SeededGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313F9EB2294468600B074BB /* SeededGenerator.swift */; }; + 0313F9EF22955F3B00B074BB /* CustomVideoFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313F9EE22955F3B00B074BB /* CustomVideoFolders.swift */; }; + 0313F9F022955F3B00B074BB /* CustomVideoFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313F9EE22955F3B00B074BB /* CustomVideoFolders.swift */; }; 03233B68217272640077D3F9 /* PoiStringProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03233B67217272640077D3F9 /* PoiStringProvider.swift */; }; 03233B692172762C0077D3F9 /* PoiStringProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03233B67217272640077D3F9 /* PoiStringProvider.swift */; }; 033192E1217B78240073B580 /* en.json in Resources */ = {isa = PBXBuildFile; fileRef = 033192E0217B78240073B580 /* en.json */; }; @@ -40,7 +48,6 @@ 03893CB4217753AC008E7125 /* ErrorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03893CB2217749F0008E7125 /* ErrorLog.swift */; }; 0393857A2175D4B80040B850 /* AVPlayerViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039385792175D4B80040B850 /* AVPlayerViewExtension.swift */; }; 0393857B2175D4B80040B850 /* AVPlayerViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039385792175D4B80040B850 /* AVPlayerViewExtension.swift */; }; - 0393857C2175D4B80040B850 /* AVPlayerViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 039385792175D4B80040B850 /* AVPlayerViewExtension.swift */; }; 03958349217F4416008E8F9C /* Solar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03958348217F4416008E8F9C /* Solar.swift */; }; 0395834A217F442A008E8F9C /* Solar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03958348217F4416008E8F9C /* Solar.swift */; }; 0395835321807D1F008E8F9C /* thumbnail@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 0395835121807D1F008E8F9C /* thumbnail@2x.png */; }; @@ -49,6 +56,21 @@ 0395835621807D1F008E8F9C /* thumbnail.png in Resources */ = {isa = PBXBuildFile; fileRef = 0395835221807D1F008E8F9C /* thumbnail.png */; }; 03A2CB9C216BA9AF0061E8E8 /* VideoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A2CB9B216BA9AF0061E8E8 /* VideoManager.swift */; }; 03A2CB9D216BB1490061E8E8 /* VideoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A2CB9B216BA9AF0061E8E8 /* VideoManager.swift */; }; + 03AD45FF22981B0C00261325 /* CustomVideoFolders+helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AD45FE22981B0C00261325 /* CustomVideoFolders+helpers.swift */; }; + 03AD460022981B1C00261325 /* CustomVideoFolders+helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AD45FE22981B0C00261325 /* CustomVideoFolders+helpers.swift */; }; + 03D1E78722842FB300D10CF7 /* DisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1E78622842FB300D10CF7 /* DisplayView.swift */; }; + 03D1E7882284367200D10CF7 /* DisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1E78622842FB300D10CF7 /* DisplayView.swift */; }; + 03D1E78A2284471A00D10CF7 /* DisplayDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1E7892284471A00D10CF7 /* DisplayDetection.swift */; }; + 03D1E78B22844AFD00D10CF7 /* DisplayDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D1E7892284471A00D10CF7 /* DisplayDetection.swift */; }; + 03D1E79122848F7F00D10CF7 /* screen2.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E78E22848F7F00D10CF7 /* screen2.jpg */; }; + 03D1E79222848F7F00D10CF7 /* screen2.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E78E22848F7F00D10CF7 /* screen2.jpg */; }; + 03D1E79322848F7F00D10CF7 /* screen2.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E78E22848F7F00D10CF7 /* screen2.jpg */; }; + 03D1E79422848F7F00D10CF7 /* screen1.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E78F22848F7F00D10CF7 /* screen1.jpg */; }; + 03D1E79522848F7F00D10CF7 /* screen1.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E78F22848F7F00D10CF7 /* screen1.jpg */; }; + 03D1E79622848F7F00D10CF7 /* screen1.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E78F22848F7F00D10CF7 /* screen1.jpg */; }; + 03D1E79722848F7F00D10CF7 /* screen0.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E79022848F7F00D10CF7 /* screen0.jpg */; }; + 03D1E79822848F7F00D10CF7 /* screen0.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E79022848F7F00D10CF7 /* screen0.jpg */; }; + 03D1E79922848F7F00D10CF7 /* screen0.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 03D1E79022848F7F00D10CF7 /* screen0.jpg */; }; 03D37FD922145487005A146F /* es.json in Resources */ = {isa = PBXBuildFile; fileRef = 03D37FD722145487005A146F /* es.json */; }; 03D37FDA22145487005A146F /* es.json in Resources */ = {isa = PBXBuildFile; fileRef = 03D37FD722145487005A146F /* es.json */; }; 03D37FDB22145487005A146F /* fr.json in Resources */ = {isa = PBXBuildFile; fileRef = 03D37FD822145487005A146F /* fr.json */; }; @@ -109,6 +131,10 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0313F9E522942AA500B074BB /* CustomVideos.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CustomVideos.xib; sourceTree = ""; }; + 0313F9E722942B4500B074BB /* CustomVideoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomVideoController.swift; sourceTree = ""; }; + 0313F9EB2294468600B074BB /* SeededGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeededGenerator.swift; sourceTree = ""; }; + 0313F9EE22955F3B00B074BB /* CustomVideoFolders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomVideoFolders.swift; sourceTree = ""; }; 03233B67217272640077D3F9 /* PoiStringProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoiStringProvider.swift; sourceTree = ""; }; 033192E0217B78240073B580 /* en.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = en.json; sourceTree = ""; }; 033D62AA216CADCD00F3AF83 /* icon-day-dark.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = "icon-day-dark.pdf"; sourceTree = ""; }; @@ -128,6 +154,12 @@ 0395835121807D1F008E8F9C /* thumbnail@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "thumbnail@2x.png"; sourceTree = ""; }; 0395835221807D1F008E8F9C /* thumbnail.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = thumbnail.png; sourceTree = ""; }; 03A2CB9B216BA9AF0061E8E8 /* VideoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoManager.swift; sourceTree = ""; }; + 03AD45FE22981B0C00261325 /* CustomVideoFolders+helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomVideoFolders+helpers.swift"; sourceTree = ""; }; + 03D1E78622842FB300D10CF7 /* DisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DisplayView.swift; path = Aerial/Source/Views/DisplayView.swift; sourceTree = SOURCE_ROOT; }; + 03D1E7892284471A00D10CF7 /* DisplayDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayDetection.swift; sourceTree = ""; }; + 03D1E78E22848F7F00D10CF7 /* screen2.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = screen2.jpg; sourceTree = ""; }; + 03D1E78F22848F7F00D10CF7 /* screen1.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = screen1.jpg; sourceTree = ""; }; + 03D1E79022848F7F00D10CF7 /* screen0.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = screen0.jpg; sourceTree = ""; }; 03D37FD722145487005A146F /* es.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = es.json; sourceTree = ""; }; 03D37FD822145487005A146F /* fr.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = fr.json; sourceTree = ""; }; 03D3DAC3221F286700BDA52F /* pl.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = pl.json; sourceTree = ""; }; @@ -230,6 +262,16 @@ path = Time; sourceTree = ""; }; + 03D1E78D22848F6D00D10CF7 /* Screenshots */ = { + isa = PBXGroup; + children = ( + 03D1E79022848F7F00D10CF7 /* screen0.jpg */, + 03D1E78F22848F7F00D10CF7 /* screen1.jpg */, + 03D1E78E22848F7F00D10CF7 /* screen2.jpg */, + ); + path = Screenshots; + sourceTree = ""; + }; 03E8730D216501B3002B469B /* Downloads */ = { isa = PBXGroup; children = ( @@ -297,6 +339,7 @@ FAC36F361BE1756D007F2A20 /* Resources */ = { isa = PBXGroup; children = ( + 03D1E78D22848F6D00D10CF7 /* Screenshots */, 033192DF217B77E90073B580 /* Community */, 033D62AA216CADCD00F3AF83 /* icon-day-dark.pdf */, FAC36F371BE1756D007F2A20 /* icon-day.pdf */, @@ -306,6 +349,7 @@ 0395835221807D1F008E8F9C /* thumbnail.png */, 0395835121807D1F008E8F9C /* thumbnail@2x.png */, FAC36F3A1BE1756D007F2A20 /* PreferencesWindow.xib */, + 0313F9E522942AA500B074BB /* CustomVideos.xib */, ); path = Resources; sourceTree = ""; @@ -325,6 +369,7 @@ isa = PBXGroup; children = ( FAC36F3E1BE1756D007F2A20 /* PreferencesWindowController.swift */, + 0313F9E722942B4500B074BB /* CustomVideoController.swift */, FA6F81DB1D939455007975FE /* Preferences.swift */, ); path = Controllers; @@ -340,6 +385,10 @@ FAC36F401BE1756D007F2A20 /* AerialVideo.swift */, FAC36F661BE1778C007F2A20 /* ManifestLoader.swift */, 03893CB2217749F0008E7125 /* ErrorLog.swift */, + 03D1E7892284471A00D10CF7 /* DisplayDetection.swift */, + 0313F9EB2294468600B074BB /* SeededGenerator.swift */, + 0313F9EE22955F3B00B074BB /* CustomVideoFolders.swift */, + 03AD45FE22981B0C00261325 /* CustomVideoFolders+helpers.swift */, ); path = Models; sourceTree = ""; @@ -363,6 +412,7 @@ FAC36F431BE1756D007F2A20 /* AerialView.swift */, FAC36F441BE1756D007F2A20 /* CheckCellView.swift */, AA7E2E5D1FC62E8B00E5F320 /* AerialPlayerItem.swift */, + 03D1E78622842FB300D10CF7 /* DisplayView.swift */, ); path = Views; sourceTree = ""; @@ -530,20 +580,24 @@ 036A34B622730A0700A49135 /* zh_CN.json in Resources */, 033192E2217B78240073B580 /* en.json in Resources */, FAC36F541BE1756D007F2A20 /* PreferencesWindow.xib in Resources */, + 03D1E79522848F7F00D10CF7 /* screen1.jpg in Resources */, FAC36F4E1BE1756D007F2A20 /* icon-day.pdf in Resources */, 03D37FDA22145487005A146F /* es.json in Resources */, 0369985E2196129C00E359D3 /* missingvideos.json in Resources */, 033D62B0216CAE2C00F3AF83 /* icon-night-dark.pdf in Resources */, 0395835621807D1F008E8F9C /* thumbnail.png in Resources */, 035A92AD226754760095AB85 /* ar.json in Resources */, + 0313F9E92294337F00B074BB /* CustomVideos.xib in Resources */, 03D3DAC5221F286D00BDA52F /* pl.json in Resources */, FAC36F481BE1756D007F2A20 /* Assets.xcassets in Resources */, 033D62AC216CADCD00F3AF83 /* icon-day-dark.pdf in Resources */, + 03D1E79222848F7F00D10CF7 /* screen2.jpg in Resources */, FAC36F4A1BE1756D007F2A20 /* MainMenu.xib in Resources */, FAC36F501BE1756D007F2A20 /* icon-night.pdf in Resources */, 035A92A9225F8C480095AB85 /* he.json in Resources */, 035A92A7225F8C480095AB85 /* de.json in Resources */, 0395835421807D1F008E8F9C /* thumbnail@2x.png in Resources */, + 03D1E79822848F7F00D10CF7 /* screen0.jpg in Resources */, 03D37FDC22145487005A146F /* fr.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -552,8 +606,11 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 03D1E79322848F7F00D10CF7 /* screen2.jpg in Resources */, 036A34B722730A0700A49135 /* zh_CN.json in Resources */, 033D62B1216CAE2C00F3AF83 /* icon-night-dark.pdf in Resources */, + 03D1E79622848F7F00D10CF7 /* screen1.jpg in Resources */, + 03D1E79922848F7F00D10CF7 /* screen0.jpg in Resources */, 033D62AD216CADCD00F3AF83 /* icon-day-dark.pdf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -567,16 +624,20 @@ 03D37FDB22145487005A146F /* fr.json in Resources */, 035A92AC226754760095AB85 /* ar.json in Resources */, 036A34B5227309FB00A49135 /* zh_CN.json in Resources */, + 03D1E79122848F7F00D10CF7 /* screen2.jpg in Resources */, FAC36F4D1BE1756D007F2A20 /* icon-day.pdf in Resources */, 035A92A8225F8C480095AB85 /* he.json in Resources */, 03D3DAC4221F286700BDA52F /* pl.json in Resources */, 033D62AB216CADCD00F3AF83 /* icon-day-dark.pdf in Resources */, + 03D1E79722848F7F00D10CF7 /* screen0.jpg in Resources */, 0395835321807D1F008E8F9C /* thumbnail@2x.png in Resources */, 0395835521807D1F008E8F9C /* thumbnail.png in Resources */, 03D37FD922145487005A146F /* es.json in Resources */, + 03D1E79422848F7F00D10CF7 /* screen1.jpg in Resources */, 033D62AF216CAE2C00F3AF83 /* icon-night-dark.pdf in Resources */, 0369985D2196103300E359D3 /* missingvideos.json in Resources */, FAC36F4F1BE1756D007F2A20 /* icon-night.pdf in Resources */, + 0313F9E622942AA500B074BB /* CustomVideos.xib in Resources */, 033192E1217B78240073B580 /* en.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -657,7 +718,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if which swiftlint >/dev/null; then\n if [ -z \"$CI\" ]; then\n make --directory=${SRCROOT} xcode-lint\n fi\nelse\n echo \"warning: SwiftLint not installed, install using `brew install swiftlint`\"\nfi\n"; + shellScript = "#if which swiftlint >/dev/null; then\n# if [ -z \"$CI\" ]; then\n# make --directory=${SRCROOT} xcode-lint\n# fi\n#else\n# echo \"warning: SwiftLint not installed, install using `brew install swiftlint`\"\n#fi\nif which swiftlint >/dev/null; then\n swiftlint autocorrect\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; 910394578D35CCEBAEE0D456 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -707,7 +768,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if which swiftlint >/dev/null; then\n if [ -z \"$CI\" ]; then\n make --directory=${SRCROOT} xcode-lint\n fi\nelse\n echo \"warning: SwiftLint not installed, install using `brew install swiftlint`\"\nfi"; + shellScript = "#if which swiftlint >/dev/null; then\n# if [ -z \"$CI\" ]; then\n# make --directory=${SRCROOT} xcode-lint\n# fi\n#else\n# echo \"warning: SwiftLint not installed, install using `brew install swiftlint`\"\n#fi\nif which swiftlint >/dev/null; then\n swiftlint autocorrect\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -720,9 +781,11 @@ 03233B692172762C0077D3F9 /* PoiStringProvider.swift in Sources */, 03A2CB9D216BB1490061E8E8 /* VideoManager.swift in Sources */, 03E87314216760B7002B469B /* TimeManagement.swift in Sources */, + 03D1E78B22844AFD00D10CF7 /* DisplayDetection.swift in Sources */, 03E8731021662AEB002B469B /* DownloadManager.swift in Sources */, 03E8731121662AEB002B469B /* AsynchronousOperation.swift in Sources */, 03510C7121834FC7008F74F2 /* IOBridge.m in Sources */, + 03D1E7882284367200D10CF7 /* DisplayView.swift in Sources */, 030D9B7C21551A8D00961E95 /* AerialPlayerItem.swift in Sources */, FAC36F5E1BE1756D007F2A20 /* CheckCellView.swift in Sources */, FAC36F5C1BE1756D007F2A20 /* AerialView.swift in Sources */, @@ -731,10 +794,14 @@ 0395834A217F442A008E8F9C /* Solar.swift in Sources */, FAB22A7F1BE17D7D0065C0F5 /* AssetLoaderDelegate.swift in Sources */, FAC36F601BE175CF007F2A20 /* AppDelegate.swift in Sources */, + 03AD460022981B1C00261325 /* CustomVideoFolders+helpers.swift in Sources */, FA36BD401BE57F8E00D5E03B /* VideoDownload.swift in Sources */, + 0313F9F022955F3B00B074BB /* CustomVideoFolders.swift in Sources */, FA6F81DD1D939455007975FE /* Preferences.swift in Sources */, FAF450221BE2B45D00C1F98A /* VideoLoader.swift in Sources */, + 0313F9EA2294338300B074BB /* CustomVideoController.swift in Sources */, FAC36F5A1BE1756D007F2A20 /* AerialVideo.swift in Sources */, + 0313F9ED2294468600B074BB /* SeededGenerator.swift in Sources */, 0393857B2175D4B80040B850 /* AVPlayerViewExtension.swift in Sources */, FAF450251BE2D2FD00C1F98A /* VideoCache.swift in Sources */, ); @@ -744,7 +811,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0393857C2175D4B80040B850 /* AVPlayerViewExtension.swift in Sources */, FA7199711D94EC5A00FBC99B /* PreferencesTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -753,26 +819,32 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 03D1E78A2284471A00D10CF7 /* DisplayDetection.swift in Sources */, FAC36F5D1BE1756D007F2A20 /* CheckCellView.swift in Sources */, FAC36F5B1BE1756D007F2A20 /* AerialView.swift in Sources */, 0393857A2175D4B80040B850 /* AVPlayerViewExtension.swift in Sources */, 03E8730F216501ED002B469B /* AsynchronousOperation.swift in Sources */, 03510C6F21834F38008F74F2 /* IOBridge.m in Sources */, + 0313F9EF22955F3B00B074BB /* CustomVideoFolders.swift in Sources */, FA6F81DC1D939455007975FE /* Preferences.swift in Sources */, FAF450241BE2D2FD00C1F98A /* VideoCache.swift in Sources */, FAC36F571BE1756D007F2A20 /* PreferencesWindowController.swift in Sources */, + 0313F9EC2294468600B074BB /* SeededGenerator.swift in Sources */, FAC36F671BE1778C007F2A20 /* ManifestLoader.swift in Sources */, FAC36F591BE1756D007F2A20 /* AerialVideo.swift in Sources */, 03893CB3217749F0008E7125 /* ErrorLog.swift in Sources */, AA7E2E5E1FC62E8B00E5F320 /* AerialPlayerItem.swift in Sources */, 03A2CB9C216BA9AF0061E8E8 /* VideoManager.swift in Sources */, + 03AD45FF22981B0C00261325 /* CustomVideoFolders+helpers.swift in Sources */, FAF450211BE2B45D00C1F98A /* VideoLoader.swift in Sources */, 03E8731321675FE0002B469B /* TimeManagement.swift in Sources */, + 03D1E78722842FB300D10CF7 /* DisplayView.swift in Sources */, FAB22A7E1BE17D7D0065C0F5 /* AssetLoaderDelegate.swift in Sources */, 03958349217F4416008E8F9C /* Solar.swift in Sources */, 03233B68217272640077D3F9 /* PoiStringProvider.swift in Sources */, FA36BD3F1BE57F8E00D5E03B /* VideoDownload.swift in Sources */, 03E8730C2165013C002B469B /* DownloadManager.swift in Sources */, + 0313F9E822942B4500B074BB /* CustomVideoController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Aerial/App/Resources/Info.plist b/Aerial/App/Resources/Info.plist index b6ebb6f6..67b3f049 100644 --- a/Aerial/App/Resources/Info.plist +++ b/Aerial/App/Resources/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.7beta6 + 1.5.0 CFBundleSignature ???? CFBundleVersion diff --git a/Aerial/Source/Controllers/CustomVideoController.swift b/Aerial/Source/Controllers/CustomVideoController.swift new file mode 100644 index 00000000..50ec9c9e --- /dev/null +++ b/Aerial/Source/Controllers/CustomVideoController.swift @@ -0,0 +1,451 @@ +// +// CustomVideoController.swift +// Aerial +// +// Created by Guillaume Louel on 21/05/2019. +// Copyright © 2019 John Coates. All rights reserved. +// + +import Foundation +import AppKit +import AVKit + +class CustomVideoController: NSWindowController, NSWindowDelegate { + @IBOutlet var mainPanel: NSWindow! + @IBOutlet var folderOutlineView: NSOutlineView! + @IBOutlet var topPathControl: NSPathControl! + + @IBOutlet var folderView: NSView! + @IBOutlet var fileView: NSView! + + @IBOutlet var folderShortNameTextField: NSTextField! + @IBOutlet var timePopUpButton: NSPopUpButton! + @IBOutlet var editPlayerView: AVPlayerView! + @IBOutlet var videoNameTextField: NSTextField! + + @IBOutlet var poiTableView: NSTableView! + @IBOutlet var addPoi: NSButton! + @IBOutlet var removePoi: NSButton! + + @IBOutlet var addPoiPopover: NSPopover! + @IBOutlet var timeTextField: NSTextField! + @IBOutlet var timeTextStepper: NSStepper! + @IBOutlet var timeTextFormatter: NumberFormatter! + @IBOutlet var descriptionTextField: NSTextField! + + @IBOutlet var durationLabel: NSTextField! + @IBOutlet var resolutionLabel: NSTextField! + + var currentFolder: Folder? + var currentAsset: Asset? + var currentAssetDuration: Int? + + var hasAwokenAlready = false + var sw: NSWindow? + var controller: PreferencesWindowController? + + // MARK: - Lifecycle + required init?(coder: NSCoder) { + super.init(coder: coder) + debugLog("cvcinit") + } + + override init(window: NSWindow?) { + super.init(window: window) + self.sw = window + debugLog("cvcinit2") + } + + override func awakeFromNib() { + if !hasAwokenAlready { + debugLog("cvcawake") + folderOutlineView.dataSource = self + folderOutlineView.delegate = self + poiTableView.dataSource = self + poiTableView.delegate = self + + hasAwokenAlready = true + editPlayerView.player = AVPlayer() + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.windowWillClose(_:)), + name: NSWindow.willCloseNotification, + object: nil) + } + } + + // We will receive this notification for every panel/window so we need to ensure it's the correct one + func windowWillClose(_ notification: Notification) { + if let wobj = notification.object as? NSPanel { + if wobj.title == "Manage Custom Videos" { + debugLog("Closing cvc") + let manifestInstance = ManifestLoader.instance + manifestInstance.saveCustomVideos() + + manifestInstance.addCallback { manifestVideos in + if let contr = self.controller { + contr.loaded(manifestVideos: []) + } + } + manifestInstance.loadManifestsFromLoadedFiles() + } + } + } + + // This is the public function to make this visible + func show(sender: NSButton, controller: PreferencesWindowController) { + self.controller = controller + if !mainPanel.isVisible { + mainPanel.makeKeyAndOrderFront(sender) + folderOutlineView.expandItem(nil, expandChildren: true) + folderOutlineView.deselectAll(self) + folderView.isHidden = true + fileView.isHidden = true + topPathControl.isHidden = true + } + } + + // MARK: - Edit Folders + @IBAction func folderNameChange(_ sender: NSTextField) { + if let folder = currentFolder { + folder.label = sender.stringValue + folderOutlineView.reloadData() + } + } + + // MARK: - Add a new folder of videos to parse + @IBAction func addFolderButton(_ sender: NSButton) { + let addFolderPanel = NSOpenPanel() + addFolderPanel.allowsMultipleSelection = false + addFolderPanel.canChooseDirectories = true + addFolderPanel.canCreateDirectories = false + addFolderPanel.canChooseFiles = false + addFolderPanel.title = "Select a folder containing videos" + + addFolderPanel.begin { (response) in + if response.rawValue == NSFileHandlingPanelOKButton { + self.processPathForVideos(url: addFolderPanel.url!) + } + addFolderPanel.close() + } + + } + + func processPathForVideos(url: URL) { + debugLog("processing url for videos : \(url) ") + let folderName = url.lastPathComponent + let manifestInstance = ManifestLoader.instance + + do { + let urls = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) + var assets = [Asset]() + + for lurl in urls { + if lurl.path.lowercased().hasSuffix(".mp4") || lurl.path.lowercased().hasSuffix(".mov") { + assets.append(Asset(pointsOfInterest: [:], + url: lurl.path, + accessibilityLabel: lurl.lastPathComponent, + id: NSUUID().uuidString, + time: "day")) + } + } + + if let cvf = manifestInstance.customVideoFolders { + // check if we have this folder already ? + if !cvf.hasFolder(withUrl: url.path) && !assets.isEmpty { + cvf.folders.append(Folder(url: url.path, label: folderName, assets: assets)) + } else if !assets.isEmpty { + // We need to append in place those that don't exist yet + let folderIndex = cvf.getFolderIndex(withUrl: url.path) + for asset in assets { + if !cvf.folders[folderIndex].hasAsset(withUrl: asset.url) { + cvf.folders[folderIndex].assets.append(asset) + } + } + } + } else { + // Create our initial CVF with the parsed folder + manifestInstance.customVideoFolders = CustomVideoFolders(folders: [Folder(url: url.path, label: folderName, assets: assets)]) + } + + folderOutlineView.reloadData() + folderOutlineView.expandItem(nil, expandChildren: true) + folderOutlineView.deselectAll(self) + + } catch { + errorLog("Could not process directory") + } + } + + // MARK: - Edit Files + @IBAction func videoNameChange(_ sender: NSTextField) { + if let asset = currentAsset { + asset.accessibilityLabel = sender.stringValue + folderOutlineView.reloadData() + } + } + + @IBAction func timePopUpChange(_ sender: NSPopUpButton) { + if let asset = currentAsset { + if sender.indexOfSelectedItem == 0 { + asset.time = "day" + } else { + asset.time = "night" + } + } + } + + // MARK: - Add/Remove POIs + @IBAction func addPoiClick(_ sender: NSButton) { + addPoiPopover.show(relativeTo: sender.preparedContentRect, of: sender, preferredEdge: .maxY) + } + + @IBAction func removePoiClick(_ sender: NSButton) { + if let asset = currentAsset { + let keys = asset.pointsOfInterest.keys.map { Int($0)!}.sorted() + asset.pointsOfInterest.removeValue(forKey: String(keys[poiTableView.selectedRow])) + poiTableView.reloadData() + } + } + + @IBAction func addPoiValidate(_ sender: NSButton) { + if let asset = currentAsset { + if timeTextField.stringValue != "" && descriptionTextField.stringValue != "" { + if asset.pointsOfInterest[timeTextField.stringValue] == nil { + asset.pointsOfInterest[timeTextField.stringValue] = descriptionTextField.stringValue + + // We also reset the popup so it's clean for next poi + timeTextField.stringValue = "" + descriptionTextField.stringValue = "" + + poiTableView.reloadData() + addPoiPopover.close() + } + } + } + } + + @IBAction func timeStepperChange(_ sender: NSStepper) { + if let player = editPlayerView.player { + player.seek(to: CMTime(seconds: Double(sender.intValue), preferredTimescale: 1)) + } + } + + @IBAction func timeTextChange(_ sender: NSTextField) { + if let player = editPlayerView.player { + player.seek(to: CMTime(seconds: Double(sender.intValue), preferredTimescale: 1)) + } + } + + @IBAction func tableViewTimeField(_ sender: NSTextField) { + if let asset = currentAsset { + if poiTableView.selectedRow != -1 { + let keys = asset.pointsOfInterest.keys.map { Int($0)!}.sorted() + asset.pointsOfInterest.switchKey(fromKey: String(keys[poiTableView.selectedRow]), toKey: sender.stringValue) + } + } + } + + @IBAction func tableViewDescField(_ sender: NSTextField) { + if let asset = currentAsset { + if poiTableView.selectedRow != -1 { + let keys = asset.pointsOfInterest.keys.map { Int($0)!}.sorted() + asset.pointsOfInterest[String(keys[poiTableView.selectedRow])] = sender.stringValue + } + } + } + +} + +// MARK: - Data source for side bar +extension CustomVideoController: NSOutlineViewDataSource { + // Find and return the child of an item. If item == nil, we need to return a child of the + // root node otherwise we find and return the child of the parent node indicated by 'item' + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + let manifestInstance = ManifestLoader.instance + + // Return an asset from folder + if let folder = item as? Folder { + return folder.assets[index] + } + + // Return a folder + return manifestInstance.customVideoFolders!.folders[index] + } + + // Tell the view controller whether an item can be expanded (i.e. it has children) or not + // (i.e. it doesn't) + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + // A folder may have childs if it's not empty + if let folder = item as? Folder { + return !folder.assets.isEmpty + } + + // But not assets + return false + } + + // Tell the view how many children an item has + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + let manifestInstance = ManifestLoader.instance + + // A folder may have childs + if let folder = item as? Folder { + return folder.assets.count + } + + // We return the number of folders here + if let cvf = manifestInstance.customVideoFolders { + return cvf.folders.count + } else { + return 0 + } + //return manifestInstance.customVideoFolders!.folders.count + } +} + +// MARK: - Delegate for side bar + +extension CustomVideoController: NSOutlineViewDelegate { + // Add text to the view. 'item' will either be a Creature object or a string. If it's the former we just + // use the 'type' attribute otherwise we downcast it to a string and use that instead. + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + var text = "" + + // Show either the folder label, or the asset label + if let folder = item as? Folder { + text = folder.label + } else { + text = (item as! Asset).accessibilityLabel + } + + // Create our table cell -- note the reference to 'creatureCell' that we set when configuring the table cell + let tableCell = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "folderCell"), owner: nil) as! NSTableCellView + tableCell.textField!.stringValue = text + return tableCell + } + + // We update our view here when an item is selected + func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { + debugLog("selected \(item)") + + if let folder = item as? Folder { + topPathControl.isHidden = false + folderView.isHidden = false + fileView.isHidden = true + + topPathControl.url = URL(fileURLWithPath: folder.url) + folderShortNameTextField.stringValue = folder.label + currentAsset = nil + currentFolder = folder + } else if let file = item as? Asset { + topPathControl.isHidden = false + folderView.isHidden = true + fileView.isHidden = false + + topPathControl.url = URL(fileURLWithPath: file.url) + videoNameTextField.stringValue = file.accessibilityLabel + if file.time == "day" { + timePopUpButton.selectItem(at: 0) + } else { + timePopUpButton.selectItem(at: 1) + } + currentFolder = nil + currentAsset = file // We use this later to populate the table view + removePoi.isEnabled = false + + if let player = editPlayerView.player { + let localitem = AVPlayerItem(url: URL(fileURLWithPath: file.url)) + currentAssetDuration = Int(localitem.asset.duration.convertScale(1, method: .default).value) + let currentResolution = getResolution(asset: localitem.asset) + let crString = String(Int(currentResolution.width)) + "x" + String(Int(currentResolution.height)) + + timeTextStepper.minValue = 0 + timeTextStepper.maxValue = Double(currentAssetDuration!) + timeTextFormatter.minimum = 0 + timeTextFormatter.maximum = NSNumber(value: currentAssetDuration!) + //timeTableFormatter.minimum = 0 + //timeTableFormatter.maximum = NSNumber(value: currentAssetDuration!) + + durationLabel.stringValue = String(currentAssetDuration!) + " seconds" + resolutionLabel.stringValue = crString + + player.replaceCurrentItem(with: localitem) + } + + poiTableView.reloadData() + } else { + topPathControl.isHidden = true + folderView.isHidden = true + fileView.isHidden = true + } + + return true + } + + func getResolution(asset: AVAsset) -> CGSize { + guard let track = asset.tracks(withMediaType: AVMediaType.video).first else { return CGSize.zero } + let size = track.naturalSize.applying(track.preferredTransform) + return CGSize(width: abs(size.width), height: abs(size.height)) + } +} + +// MARK: - Extension for poi table view +extension CustomVideoController: NSTableViewDataSource, NSTableViewDelegate { + // currentAsset contains the selected video asset + + func numberOfRows(in tableView: NSTableView) -> Int { + if let asset = currentAsset { + return asset.pointsOfInterest.count + } else { + return 0 + } + } + + // This is where we populate the tableview + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + if let asset = currentAsset { + var text: String + if tableColumn!.identifier.rawValue == "timeColumn" { + let keys = asset.pointsOfInterest.keys.map { Int($0)!}.sorted() + text = String(keys[row]) + } else { + let keys = asset.pointsOfInterest.keys.map { Int($0)!}.sorted() + text = asset.pointsOfInterest[String(keys[row])]! + } + + if let cell = tableView.makeView(withIdentifier: tableColumn!.identifier, owner: self) as? NSTableCellView { + cell.textField?.stringValue = text + cell.imageView?.image = nil + return cell + } + } + + return nil + } + + func tableViewSelectionDidChange(_ notification: Notification) { + if let asset = currentAsset { + if poiTableView.selectedRow >= 0 { + removePoi.isEnabled = true + + let keys = asset.pointsOfInterest.keys.map { Int($0)!}.sorted() + if let player = editPlayerView.player { + player.seek(to: CMTime(seconds: Double(keys[poiTableView.selectedRow]), preferredTimescale: 1)) + } + } else { + removePoi.isEnabled = false + } + } + } + +} + +extension Dictionary { + mutating func switchKey(fromKey: Key, toKey: Key) { + if let entry = removeValue(forKey: fromKey) { + self[toKey] = entry + } + } +} diff --git a/Aerial/Source/Controllers/Preferences.swift b/Aerial/Source/Controllers/Preferences.swift index 235526a3..160d561c 100644 --- a/Aerial/Source/Controllers/Preferences.swift +++ b/Aerial/Source/Controllers/Preferences.swift @@ -74,6 +74,22 @@ final class Preferences { case updateWhileSaverMode = "updateWhileSaverMode" case allowBetas = "allowBetas" case betaCheckFrequency = "betaCheckFrequency" + case newDisplayMode = "newDisplayMode" + case newViewingMode = "newViewingMode" + case newDisplayDict = "newDisplayDict" + case logMilliseconds = "logMilliseconds" + case horizontalMargin = "horizontalMargin" + case verticalMargin = "verticalMargin" + + case synchronizedMode = "synchronizedMode" + } + + enum NewDisplayMode: Int { + case allDisplays, mainOnly, secondaryOnly, selection + } + + enum NewViewingMode: Int { + case independent, mirrored, spanned } enum BetaCheckFrequency: Int { @@ -199,6 +215,13 @@ final class Preferences { defaultValues[.updateWhileSaverMode] = true defaultValues[.allowBetas] = false defaultValues[.betaCheckFrequency] = BetaCheckFrequency.daily + defaultValues[.newDisplayMode] = NewDisplayMode.allDisplays + defaultValues[.newViewingMode] = NewViewingMode.independent + defaultValues[.newDisplayDict] = [String: Bool]() + defaultValues[.logMilliseconds] = false + defaultValues[.horizontalMargin] = 0 + defaultValues[.verticalMargin] = 0 + defaultValues[.synchronizedMode] = false // Set today's date as default let dateFormatter = DateFormatter() @@ -226,6 +249,51 @@ final class Preferences { } } + var newDisplayDict: [String: Bool] { + get { + return userDefaults.dictionary(forKey: "newDisplayDict") as! [String: Bool] + } + set { + setValue(forIdentifier: .newDisplayDict, value: newValue) + } + } + + var horizontalMargin: Double? { + get { + return optionalValue(forIdentifier: .horizontalMargin) + } + set { + setValue(forIdentifier: .horizontalMargin, value: newValue) + } + } + + var verticalMargin: Double? { + get { + return optionalValue(forIdentifier: .verticalMargin) + } + set { + setValue(forIdentifier: .verticalMargin, value: newValue) + } + } + + var newDisplayMode: Int? { + get { + return optionalValue(forIdentifier: .newDisplayMode) + } + set { + setValue(forIdentifier: .newDisplayMode, value: newValue) + } + } + + var newViewingMode: Int? { + get { + return optionalValue(forIdentifier: .newViewingMode) + } + set { + setValue(forIdentifier: .newViewingMode, value: newValue) + } + } + var lastVideoCheck: String? { get { return optionalValue(forIdentifier: .lastVideoCheck) @@ -262,6 +330,24 @@ final class Preferences { } } + var synchronizedMode: Bool { + get { + return value(forIdentifier: .synchronizedMode) + } + set { + setValue(forIdentifier: .synchronizedMode, value: newValue) + } + } + + var logMilliseconds: Bool { + get { + return value(forIdentifier: .logMilliseconds) + } + set { + setValue(forIdentifier: .logMilliseconds, value: newValue) + } + } + var allowBetas: Bool { get { return value(forIdentifier: .allowBetas) diff --git a/Aerial/Source/Controllers/PreferencesWindowController.swift b/Aerial/Source/Controllers/PreferencesWindowController.swift index c407d4ca..2be22d43 100644 --- a/Aerial/Source/Controllers/PreferencesWindowController.swift +++ b/Aerial/Source/Controllers/PreferencesWindowController.swift @@ -50,6 +50,7 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo enum HEVCMain10Support: Int { case notsupported, unsure, partial, supported } + lazy var customVideosController: CustomVideoController = CustomVideoController() @IBOutlet weak var prefTabView: NSTabView! @IBOutlet var outlineView: NSOutlineView! @@ -72,7 +73,6 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo @IBOutlet var overrideNightOnDarkMode: NSButton! - @IBOutlet var multiMonitorModePopup: NSPopUpButton! @IBOutlet var popupVideoFormat: NSPopUpButton! @IBOutlet var alternatePopupVideoFormat: NSPopUpButton! @IBOutlet var descriptionModePopup: NSPopUpButton! @@ -206,6 +206,21 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo @IBOutlet var addVideoSetConfirmButton: NSButton! @IBOutlet var addVideoSetCancelButton: NSButton! @IBOutlet var addVideoSetErrorLabel: NSTextField! + + // Display tab + @IBOutlet var newDisplayModePopup: NSPopUpButton! + @IBOutlet var newViewingModePopup: NSPopUpButton! + @IBOutlet var displayInstructionLabel: NSTextField! + @IBOutlet var quitConfirmationPanel: NSPanel! + + @IBOutlet var logMillisecondsButton: NSButton! + @IBOutlet var displayMarginBox: NSBox! + @IBOutlet var horizontalDisplayMarginTextfield: NSTextField! + @IBOutlet var verticalDisplayMarginTextfield: NSTextField! + @IBOutlet var rightClickOpenQuickTimeMenuItem: NSMenuItem! + @IBOutlet var rightClickDownloadVideoMenuItem: NSMenuItem! + @IBOutlet var rightClickMoveToTrashMenuItem: NSMenuItem! + @IBOutlet var synchronizedModeCheckbox: NSButton! var player: AVPlayer = AVPlayer() var videos: [AerialVideo]? @@ -226,6 +241,7 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo var locationManager: CLLocationManager? var sparkleUpdater: SUUpdater? + @IBOutlet var displayView: DisplayView! public var appMode: Bool = false private lazy var timeFormatter: DateFormatter = { @@ -413,14 +429,19 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo if !preferences.allowSkips { rightArrowKeyPlaysNextCheckbox.state = .off } + horizontalDisplayMarginTextfield.doubleValue = preferences.horizontalMargin! + verticalDisplayMarginTextfield.doubleValue = preferences.verticalMargin! - // Aerial panel + // Advanced panel if preferences.debugMode { debugModeCheckbox.state = .on } if preferences.logToDisk { logToDiskCheckbox.state = .on } + if preferences.logMilliseconds { + logMillisecondsButton.state = .on + } // Text panel if preferences.showClock { @@ -483,6 +504,9 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo if preferences.dimOnlyAtNight { dimOnlyAtNight.state = .on } + if preferences.synchronizedMode { + synchronizedModeCheckbox.state = .on + } dimStartFrom.doubleValue = preferences.startDim ?? 0.5 dimFadeTo.doubleValue = preferences.endDim ?? 0.1 dimFadeInMinutes.stringValue = String(preferences.dimInMinutes!) @@ -566,8 +590,6 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo solarModePopup.selectItem(at: preferences.solarMode!) - multiMonitorModePopup.selectItem(at: preferences.multiMonitorMode!) - popupVideoFormat.selectItem(at: preferences.videoFormat!) alternatePopupVideoFormat.selectItem(at: preferences.alternateVideoFormat!) @@ -586,6 +608,14 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo lastCheckedVideosLabel.stringValue = "Last checked on " + preferences.lastVideoCheck! + // Displays Tab + newDisplayModePopup.selectItem(at: preferences.newDisplayMode!) + newViewingModePopup.selectItem(at: preferences.newViewingMode!) + + if preferences.newDisplayMode == Preferences.NewDisplayMode.selection.rawValue { + displayInstructionLabel.isHidden = false + } + // Format date if sparkleUpdater!.lastUpdateCheckDate != nil { let dateFormatter = DateFormatter() @@ -636,6 +666,17 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo longitudeTextField.isEnabled = false } + // We also load our CustomVideos nib + //if appMode { + //let bundle = Bundle.main + let bundle = Bundle(for: PreferencesWindowController.self) + var topLevelObjects: NSArray? = NSArray() + if !bundle.loadNibNamed(NSNib.Name("CustomVideos"), + owner: customVideosController, + topLevelObjects: &topLevelObjects) { + errorLog("Could not load nib for CustomVideos, please report") + } + debugLog("appMode : \(appMode)") } @@ -648,7 +689,23 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo } @IBAction func close(_ sender: AnyObject?) { - // This seems needed for screensavers as our lifecycle is different from a regular app + // We ask for confirmation in case downloads are ongoing + if !downloadProgressIndicator.isHidden { + quitConfirmationPanel.makeKeyAndOrderFront(self) + } else { + // This seems needed for screensavers as our lifecycle is different from a regular app + preferences.synchronize() + logPanel.close() + if appMode { + NSApplication.shared.terminate(nil) + } else { + window?.sheetParent?.endSheet(window!) + } + } + } + + @IBAction func confirmQuitClick(_ sender: Any) { + quitConfirmationPanel.close() preferences.synchronize() logPanel.close() if appMode { @@ -658,6 +715,10 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo } } + @IBAction func cancelQuitClick(_ sender: Any) { + quitConfirmationPanel.close() + } + // MARK: Video playback // Rewind preview video when reaching end @@ -698,6 +759,12 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo debugLog("UI allowSkips \(onState)") } + @IBAction func synchronizedModeClick(_ sender: NSButton) { + let onState = sender.state == .on + preferences.synchronizedMode = onState + debugLog("UI synchronizedMode \(onState)") + } + @IBAction func overrideOnBatteryClick(_ sender: NSButton) { let onState = sender.state == .on preferences.overrideOnBattery = onState @@ -741,12 +808,6 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo popoverPower.show(relativeTo: button.preparedContentRect, of: button, preferredEdge: .maxY) } - @IBAction func multiMonitorModePopupChange(_ sender: NSPopUpButton) { - debugLog("UI multiMonitorMode: \(sender.indexOfSelectedItem)") - preferences.multiMonitorMode = sender.indexOfSelectedItem - preferences.synchronize() - } - @IBAction func fadeInOutModePopupChange(_ sender: NSPopUpButton) { debugLog("UI fadeInOutMode: \(sender.indexOfSelectedItem)") preferences.fadeMode = sender.indexOfSelectedItem @@ -784,6 +845,23 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo } } + @IBAction func rightClickDownloadVideo(_ sender: NSMenuItem) { + if let video = sender.representedObject as? AerialVideo { + let videoManager = VideoManager.sharedInstance + if !videoManager.isVideoQueued(id: video.id) { + videoManager.queueDownload(video) + } + } + } + + @IBAction func rightClickMoveToTrash(_ sender: NSMenuItem) { + if let video = sender.representedObject as? AerialVideo { + VideoCache.moveToTrash(video: video) + let videoManager = VideoManager.sharedInstance + videoManager.updateAllCheckCellView() + } + } + // MARK: - Mac Model detection and HEVC Main10 detection private func getMacModel() -> String { var size = 0 @@ -839,6 +917,49 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo // Hackintosh/new SKUs may fail this test return .unsure } + // MARK: - Displays panel + @IBAction func newDisplayModeClick(_ sender: NSPopUpButton) { + debugLog("UI newDisplayModeClick: \(sender.indexOfSelectedItem)") + preferences.newDisplayMode = sender.indexOfSelectedItem + if preferences.newDisplayMode == Preferences.NewDisplayMode.selection.rawValue { + displayInstructionLabel.isHidden = false + } else { + displayInstructionLabel.isHidden = true + } + displayView.needsDisplay = true + } + + @IBAction func newViewingModeClick(_ sender: NSPopUpButton) { + debugLog("UI newViewingModeClick: \(sender.indexOfSelectedItem)") + preferences.newViewingMode = sender.indexOfSelectedItem + let displayDetection = DisplayDetection.sharedInstance + displayDetection.detectDisplays() // Force redetection to update our margin calculations in spanned mode + displayView.needsDisplay = true + + if preferences.newViewingMode == Preferences.NewViewingMode.spanned.rawValue { + displayMarginBox.isHidden = false + } else { + displayMarginBox.isHidden = true + } + } + + @IBAction func horizontalDisplayMarginChange(_ sender: NSTextField) { + debugLog("UI horizontalDisplayMarginChange \(sender.stringValue)") + preferences.horizontalMargin = sender.doubleValue + + let displayDetection = DisplayDetection.sharedInstance + displayDetection.detectDisplays() // Force redetection to update our margin calculations in spanned mode + displayView.needsDisplay = true + } + + @IBAction func verticalDisplayMarginChange(_ sender: NSTextField) { + debugLog("UI verticalDisplayMarginChange \(sender.stringValue)") + preferences.verticalMargin = sender.doubleValue + + let displayDetection = DisplayDetection.sharedInstance + displayDetection.detectDisplays() // Force redetection to update our margin calculations in spanned mode + displayView.needsDisplay = true + } // MARK: - Text panel @@ -1436,6 +1557,13 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo } // MARK: - Advanced panel + + @IBAction func logMillisecondsClick(_ button: NSButton) { + let onState = button.state == .on + preferences.logMilliseconds = onState + debugLog("UI logMilliseconds: \(onState)") + } + @IBAction func logButtonClick(_ sender: NSButton) { logTableView.reloadData() if logPanel.isVisible { @@ -1574,11 +1702,20 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo action: #selector(PreferencesWindowController.outlineViewDownloadAll(button:)), keyEquivalent: "", at: 6) + menu.insertItem(NSMenuItem.separator(), at: 7) + menu.insertItem(withTitle: "Custom Videos...", + action: #selector(PreferencesWindowController.outlineViewCustomVideos(button:)), + keyEquivalent: "", + at: 8) let event = NSApp.currentEvent NSMenu.popUpContextMenu(menu, with: event!, for: button) } + @objc func outlineViewCustomVideos(button: NSButton) { + customVideosController.show(sender: button, controller: self) + } + @objc func outlineViewUncheckAll(button: NSButton) { setAllVideos(inRotation: false) } @@ -1834,8 +1971,12 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo var videos = [AerialVideo]() var cities = [String: City]() + // Grab a fresh version, because our callback can be feeding us wrong data in CVC + let freshManifestVideos = ManifestLoader.instance.loadedManifest + //debugLog("freshManifestVideos count : \(freshManifestVideos.count)") + // First day, then night - for video in manifestVideos { + for video in freshManifestVideos { let name = video.name if cities.keys.contains(name) == false { @@ -1873,7 +2014,6 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo } // MARK: - Outline View Delegate & Data Source - func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { guard let item = item else { return cities.count } @@ -2047,6 +2187,7 @@ final class PreferencesWindowController: NSWindowController, NSOutlineViewDataSo case let video as AerialVideo: player = AVPlayer() playerView.player = player + player.isMuted = true debugLog("Playing this preview \(video)") // Workaround for cached videos generating online traffic @@ -2169,13 +2310,18 @@ extension PreferencesWindowController: NSMenuDelegate { if let video = rowItem as? AerialVideo { if video.isAvailableOffline { + rightClickOpenQuickTimeMenuItem.isHidden = false + rightClickMoveToTrashMenuItem.isHidden = false + rightClickDownloadVideoMenuItem.isHidden = true for item in menu.items { - item.isHidden = false item.representedObject = rowItem } } else { + rightClickOpenQuickTimeMenuItem.isHidden = true + rightClickMoveToTrashMenuItem.isHidden = true + rightClickDownloadVideoMenuItem.isHidden = false for item in menu.items { - item.isHidden = true + item.representedObject = rowItem } } } else { diff --git a/Aerial/Source/Models/AerialVideo.swift b/Aerial/Source/Models/AerialVideo.swift index d779b040..737a00c8 100644 --- a/Aerial/Source/Models/AerialVideo.swift +++ b/Aerial/Source/Models/AerialVideo.swift @@ -10,7 +10,7 @@ import Foundation import AVFoundation enum Manifests: String { - case tvOS10 = "tvos10.json", tvOS11 = "tvos11.json", tvOS12 = "entries.json" + case tvOS10 = "tvos10.json", tvOS11 = "tvos11.json", tvOS12 = "entries.json", customVideos = "customvideos.json" } private let spaceVideos = [ @@ -205,32 +205,45 @@ final class AerialVideo: CustomStringConvertible, Equatable { // working on a cached copy which makes the native duration retrieval fail // Not the prettiest code ! - let cacheDirectoryPath = VideoCache.cacheDirectory! as NSString let fileManager = FileManager.default - var videoCache1080pH264Path = "", videoCache1080pHEVCPath = "", videoCache4KHEVCPath = "" - if self.url1080pH264 != "" { - videoCache1080pH264Path = cacheDirectoryPath.appendingPathComponent((URL(string: url1080pH264)?.lastPathComponent)!) - } - if self.url1080pHEVC != "" { - videoCache1080pHEVCPath = cacheDirectoryPath.appendingPathComponent((URL(string: url1080pHEVC)?.lastPathComponent)!) - } - if self.url4KHEVC != "" { - videoCache4KHEVCPath = cacheDirectoryPath.appendingPathComponent((URL(string: url4KHEVC)?.lastPathComponent)!) - } - - if fileManager.fileExists(atPath: videoCache4KHEVCPath) { - let asset = AVAsset(url: URL(fileURLWithPath: videoCache4KHEVCPath)) - self.duration = CMTimeGetSeconds(asset.duration) - } else if fileManager.fileExists(atPath: videoCache1080pHEVCPath) { - let asset = AVAsset(url: URL(fileURLWithPath: videoCache1080pHEVCPath)) - self.duration = CMTimeGetSeconds(asset.duration) - } else if fileManager.fileExists(atPath: videoCache1080pH264Path) { - let asset = AVAsset(url: URL(fileURLWithPath: videoCache1080pH264Path)) - self.duration = CMTimeGetSeconds(asset.duration) + // And with local custom videos it's worse ! + if self.url.absoluteString.starts(with: "file") { + if fileManager.fileExists(atPath: self.url.path) { + let asset = AVAsset(url: self.url) + self.duration = CMTimeGetSeconds(asset.duration) + } else { + errorLog("Custom video is missing : \(self.url.path)") + self.duration = 0 + } } else { - debugLog("Could not determine duration, video is not cached in any format") - self.duration = 0 + let cacheDirectoryPath = VideoCache.cacheDirectory! as NSString + + var videoCache1080pH264Path = "", videoCache1080pHEVCPath = "", videoCache4KHEVCPath = "" + if self.url1080pH264 != "" { + videoCache1080pH264Path = cacheDirectoryPath.appendingPathComponent((URL(string: url1080pH264)?.lastPathComponent)!) + } + if self.url1080pHEVC != "" { + videoCache1080pHEVCPath = cacheDirectoryPath.appendingPathComponent((URL(string: url1080pHEVC)?.lastPathComponent)!) + } + if self.url4KHEVC != "" { + videoCache4KHEVCPath = cacheDirectoryPath.appendingPathComponent((URL(string: url4KHEVC)?.lastPathComponent)!) + } + + if fileManager.fileExists(atPath: videoCache4KHEVCPath) { + let asset = AVAsset(url: URL(fileURLWithPath: videoCache4KHEVCPath)) + self.duration = CMTimeGetSeconds(asset.duration) + } else if fileManager.fileExists(atPath: videoCache1080pHEVCPath) { + let asset = AVAsset(url: URL(fileURLWithPath: videoCache1080pHEVCPath)) + self.duration = CMTimeGetSeconds(asset.duration) + } else if fileManager.fileExists(atPath: videoCache1080pH264Path) { + let asset = AVAsset(url: URL(fileURLWithPath: videoCache1080pH264Path)) + self.duration = CMTimeGetSeconds(asset.duration) + } else { + debugLog("Could not determine duration, video is not cached in any format") + self.duration = 0 + } + } } diff --git a/Aerial/Source/Models/Cache/VideoCache.swift b/Aerial/Source/Models/Cache/VideoCache.swift index afd61fbb..77ca9de4 100644 --- a/Aerial/Source/Models/Cache/VideoCache.swift +++ b/Aerial/Source/Models/Cache/VideoCache.swift @@ -10,6 +10,7 @@ import Foundation import AVFoundation import ScreenSaver +// swiftlint:disable:next type_body_length final class VideoCache { var videoData: Data var mutableVideoData: NSMutableData? @@ -113,17 +114,40 @@ final class VideoCache { } static func isAvailableOffline(video: AerialVideo) -> Bool { - guard let videoCachePath = cachePath(forVideo: video) else { - errorLog("Couldn't get video cache path!") - return false + let fileManager = FileManager.default + + if video.url.absoluteString.starts(with: "file") { + return fileManager.fileExists(atPath: video.url.path) + } else { + guard let videoCachePath = cachePath(forVideo: video) else { + errorLog("Couldn't get video cache path!") + return false + } + + return fileManager.fileExists(atPath: videoCachePath) } + } - let fileManager = FileManager.default + static func moveToTrash(video: AerialVideo) { + guard let videoCachePath = cachePath(forVideo: video) else { + errorLog("Couldn't get video cache path to trash!") + return + } - return fileManager.fileExists(atPath: videoCachePath) + let vurl = Foundation.URL(fileURLWithPath: videoCachePath as String) + debugLog("trashing \(vurl))") + do { + try FileManager.default.trashItem(at: vurl, resultingItemURL: nil) + } catch let error { + errorLog("Could not move \(video.url) to trash \(error)") + } } static func cachePath(forVideo video: AerialVideo) -> String? { + if video.url.absoluteString.starts(with: "file") { + return video.url.path + } + let vurl = video.url let filename = vurl.lastPathComponent return cachePath(forFilename: filename) diff --git a/Aerial/Source/Models/Cache/VideoManager.swift b/Aerial/Source/Models/Cache/VideoManager.swift index 1ec9ce1b..10c6a114 100644 --- a/Aerial/Source/Models/Cache/VideoManager.swift +++ b/Aerial/Source/Models/Cache/VideoManager.swift @@ -53,6 +53,12 @@ final class VideoManager: NSObject { progressCallbacks.append(callback) } + func updateAllCheckCellView() { + for view in checkCells { + view.value.adaptIndicators() + } + } + // Is the video queued for download ? func isVideoQueued(id: String) -> Bool { if queuedVideos.firstIndex(of: id) != nil { diff --git a/Aerial/Source/Models/CustomVideoFolders+helpers.swift b/Aerial/Source/Models/CustomVideoFolders+helpers.swift new file mode 100644 index 00000000..2d1f3a58 --- /dev/null +++ b/Aerial/Source/Models/CustomVideoFolders+helpers.swift @@ -0,0 +1,47 @@ +// +// CustomVideoFolders+helpers.swift +// Aerial +// +// Created by Guillaume Louel on 24/05/2019. +// Copyright © 2019 John Coates. All rights reserved. +// + +import Foundation + +// Helpers added on top of our generated json class +extension CustomVideoFolders { + func hasFolder(withUrl: String) -> Bool { + for folder in folders where folder.url == withUrl { + return true + } + + return false + } + + func getFolderIndex(withUrl: String) -> Int { + var index = 0 + for folder in folders { + if folder.url == withUrl { + return index + } + index += 1 + } + return -1 + } + + func getFolder(withUrl: String) -> Folder? { + for folder in folders where folder.url == withUrl { + return folder + } + + return nil + } +} +extension Folder { + func hasAsset(withUrl: String) -> Bool { + for asset in assets where asset.url == withUrl { + return true + } + return false + } +} diff --git a/Aerial/Source/Models/CustomVideoFolders.swift b/Aerial/Source/Models/CustomVideoFolders.swift new file mode 100644 index 00000000..0ae7a27a --- /dev/null +++ b/Aerial/Source/Models/CustomVideoFolders.swift @@ -0,0 +1,202 @@ +// This file was generated from JSON Schema using quicktype, do not modify it directly. +// To parse the JSON, add this file to your project and do: +// +// let customVideoFolders = try CustomVideoFolders(json) + +import Foundation + +// MARK: - CustomVideoFolders +class CustomVideoFolders: Codable { + var folders: [Folder] + + enum CodingKeys: String, CodingKey { + case folders = "folders" + } + + init(folders: [Folder]) { + self.folders = folders + } +} + +// MARK: CustomVideoFolders convenience initializers and mutators + +extension CustomVideoFolders { + convenience init(data: Data) throws { + let me = try newJSONDecoder().decode(CustomVideoFolders.self, from: data) + self.init(folders: me.folders) + } + + convenience init(_ json: String, using encoding: String.Encoding = .utf8) throws { + guard let data = json.data(using: encoding) else { + throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) + } + try self.init(data: data) + } + + convenience init(fromURL url: URL) throws { + try self.init(data: try Data(contentsOf: url)) + } + + func with( + folders: [Folder]? = nil + ) -> CustomVideoFolders { + return CustomVideoFolders( + folders: folders ?? self.folders + ) + } + + func jsonData() throws -> Data { + return try newJSONEncoder().encode(self) + } + + func jsonString(encoding: String.Encoding = .utf8) throws -> String? { + return String(data: try self.jsonData(), encoding: encoding) + } +} + +// MARK: - Folder +class Folder: Codable { + var url: String + var label: String + var assets: [Asset] + + enum CodingKeys: String, CodingKey { + case url = "url" + case label = "label" + case assets = "assets" + } + + init(url: String, label: String, assets: [Asset]) { + self.url = url + self.label = label + self.assets = assets + } +} + +// MARK: Folder convenience initializers and mutators + +extension Folder { + convenience init(data: Data) throws { + let me = try newJSONDecoder().decode(Folder.self, from: data) + self.init(url: me.url, label: me.label, assets: me.assets) + } + + convenience init(_ json: String, using encoding: String.Encoding = .utf8) throws { + guard let data = json.data(using: encoding) else { + throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) + } + try self.init(data: data) + } + + convenience init(fromURL url: URL) throws { + try self.init(data: try Data(contentsOf: url)) + } + + func with( + url: String? = nil, + label: String? = nil, + assets: [Asset]? = nil + ) -> Folder { + return Folder( + url: url ?? self.url, + label: label ?? self.label, + assets: assets ?? self.assets + ) + } + + func jsonData() throws -> Data { + return try newJSONEncoder().encode(self) + } + + func jsonString(encoding: String.Encoding = .utf8) throws -> String? { + return String(data: try self.jsonData(), encoding: encoding) + } +} + +// MARK: - Asset +class Asset: Codable { + var pointsOfInterest: [String: String] + var url: String + var accessibilityLabel: String + var id: String + var time: String + + enum CodingKeys: String, CodingKey { + case pointsOfInterest = "pointsOfInterest" + case url = "url" + case accessibilityLabel = "accessibilityLabel" + case id = "id" + case time = "time" + } + + init(pointsOfInterest: [String: String], url: String, accessibilityLabel: String, id: String, time: String) { + self.pointsOfInterest = pointsOfInterest + self.url = url + self.accessibilityLabel = accessibilityLabel + self.id = id + self.time = time + } +} + +// MARK: Asset convenience initializers and mutators + +extension Asset { + convenience init(data: Data) throws { + let me = try newJSONDecoder().decode(Asset.self, from: data) + self.init(pointsOfInterest: me.pointsOfInterest, url: me.url, accessibilityLabel: me.accessibilityLabel, id: me.id, time: me.time) + } + + convenience init(_ json: String, using encoding: String.Encoding = .utf8) throws { + guard let data = json.data(using: encoding) else { + throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) + } + try self.init(data: data) + } + + convenience init(fromURL url: URL) throws { + try self.init(data: try Data(contentsOf: url)) + } + + func with( + pointsOfInterest: [String: String]? = nil, + url: String? = nil, + accessibilityLabel: String? = nil, + id: String? = nil, + time: String? = nil + ) -> Asset { + return Asset( + pointsOfInterest: pointsOfInterest ?? self.pointsOfInterest, + url: url ?? self.url, + accessibilityLabel: accessibilityLabel ?? self.accessibilityLabel, + id: id ?? self.id, + time: time ?? self.time + ) + } + + func jsonData() throws -> Data { + return try newJSONEncoder().encode(self) + } + + func jsonString(encoding: String.Encoding = .utf8) throws -> String? { + return String(data: try self.jsonData(), encoding: encoding) + } +} + +// MARK: - Helper functions for creating encoders and decoders + +func newJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) { + decoder.dateDecodingStrategy = .iso8601 + } + return decoder +} + +func newJSONEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) { + encoder.dateEncodingStrategy = .iso8601 + } + return encoder +} diff --git a/Aerial/Source/Models/DisplayDetection.swift b/Aerial/Source/Models/DisplayDetection.swift new file mode 100644 index 00000000..8fbe566a --- /dev/null +++ b/Aerial/Source/Models/DisplayDetection.swift @@ -0,0 +1,298 @@ +// +// DisplayDetection.swift +// Aerial +// +// Created by Guillaume Louel on 09/05/2019. +// Copyright © 2019 John Coates. All rights reserved. +// + +import Foundation +import Cocoa + +class Screen: NSObject { + var id: CGDirectDisplayID + var width: Int + var height: Int + var bottomLeftFrame: CGRect + var topRightCorner: CGPoint + var zeroedOrigin: CGPoint + var isMain: Bool + var backingScaleFactor: CGFloat + + init(id: CGDirectDisplayID, width: Int, height: Int, bottomLeftFrame: CGRect, isMain: Bool) { + self.id = id + self.width = width + self.height = height + self.bottomLeftFrame = bottomLeftFrame + // We precalculate the right corner too, as we will need this ! + self.topRightCorner = CGPoint(x: bottomLeftFrame.origin.x + CGFloat(width), + y: bottomLeftFrame.origin.y + CGFloat(height)) + self.zeroedOrigin = CGPoint(x: 0, y: 0) + self.isMain = isMain + self.backingScaleFactor = 1 + } + + override var description: String { + //swiftlint:disable:next line_length + return "[id=\(self.id), width=\(self.width), height=\(self.height), bottomLeftFrame=\(self.bottomLeftFrame), topRightCorner=\(self.topRightCorner), isMain=\(self.isMain), backingScaleFactor=\(self.backingScaleFactor)]" + } +} + +final class DisplayDetection: NSObject { + static let sharedInstance = DisplayDetection() + + var screens = [Screen]() + var cmInPoints: CGFloat = 40 + var maxLeftScreens: CGFloat = 0 + var maxBelowScreens: CGFloat = 0 + + // MARK: - Lifecycle + override init() { + super.init() + debugLog("Display Detection initialized") + _ = detectDisplays() + } + + // MARK: - Detection + func detectDisplays() { + // Display detection is done in two passes : + // - Through CGDisplay, we grab all online screens (connected, but + // may or may not be powered on !) and get most information needed + // - Through NSScreen to get the backingScaleFactor (retinaness of a screen) + + debugLog("***Display Detection***") + // Cleanup a bit in case of redetection + screens = [Screen]() + maxLeftScreens = 0 + maxBelowScreens = 0 + + // First pass + let maxDisplays: UInt32 = 32 + var onlineDisplays = [CGDirectDisplayID](repeating: 0, count: Int(maxDisplays)) + var displayCount: UInt32 = 0 + + _ = CGGetOnlineDisplayList(maxDisplays, &onlineDisplays, &displayCount) + debugLog("\(displayCount) display(s) detected") + + for currentDisplay in onlineDisplays[0.. maxLeftScreens { + maxLeftScreens = leftScreens + } + if belowScreens > maxBelowScreens { + maxBelowScreens = belowScreens + } + + screen.zeroedOrigin = CGPoint(x: screen.bottomLeftFrame.origin.x - orect.origin.x + (leftScreens * leftMargin()), + y: screen.bottomLeftFrame.origin.y - orect.origin.y + (belowScreens * belowMargin())) + } + } + + // Border detection + // This will work for most cases, but will fail in some grid/tetris like arrangements + func detectBorders(forScreen: Screen) -> (CGFloat, CGFloat) { + var leftScreens: CGFloat = 0 + var belowScreens: CGFloat = 0 + + for screen in screens where screen != forScreen { + if screen.bottomLeftFrame.origin.x < forScreen.bottomLeftFrame.origin.x && + screen.bottomLeftFrame.origin.x + CGFloat(screen.width) <= + forScreen.bottomLeftFrame.origin.x { + leftScreens += 1 + } + if screen.bottomLeftFrame.origin.y < forScreen.bottomLeftFrame.origin.y && + screen.bottomLeftFrame.origin.y + CGFloat(screen.height) <= + forScreen.bottomLeftFrame.origin.y { + belowScreens += 1 + } + } + debugLog("left \(leftScreens) below \(belowScreens)") + + return (leftScreens, belowScreens) + } + + func leftMargin() -> CGFloat { + let preferences = Preferences.sharedInstance + return cmInPoints * CGFloat(preferences.horizontalMargin!) + } + + func belowMargin() -> CGFloat { + let preferences = Preferences.sharedInstance + return cmInPoints * CGFloat(preferences.verticalMargin!) + } + + func findScreenWith(frame: CGRect) -> Screen? { + for screen in screens where frame == screen.bottomLeftFrame { + return screen + } + + return nil + } + + func findScreenWith(id: CGDirectDisplayID) -> Screen? { + for screen in screens where screen.id == id { + return screen + } + + return nil + } + + // Calculate the size of the global screen (the composite of all the displays attached) + func getGlobalScreenRect() -> CGRect { + var minX: CGFloat = 0.0, minY: CGFloat = 0.0, maxX: CGFloat = 0.0, maxY: CGFloat = 0.0 + for screen in screens { + if screen.bottomLeftFrame.origin.x < minX { + minX = screen.bottomLeftFrame.origin.x + } + if screen.bottomLeftFrame.origin.y < minY { + minY = screen.bottomLeftFrame.origin.y + } + if screen.topRightCorner.x > maxX { + maxX = screen.topRightCorner.x + } + if screen.topRightCorner.y > maxY { + maxY = screen.topRightCorner.y + } + } + + return CGRect(x: minX, y: minY, width: maxX-minX+(maxLeftScreens*leftMargin()), height: maxY-minY+(maxBelowScreens*belowMargin())) + } + + func getZeroedActiveSpannedRect() -> CGRect { + var minX: CGFloat = 0.0, minY: CGFloat = 0.0, maxX: CGFloat = 0.0, maxY: CGFloat = 0.0 + for screen in screens where isScreenActive(id: screen.id) { + if screen.bottomLeftFrame.origin.x < minX { + minX = screen.bottomLeftFrame.origin.x + } + if screen.bottomLeftFrame.origin.y < minY { + minY = screen.bottomLeftFrame.origin.y + } + if screen.topRightCorner.x > maxX { + maxX = screen.topRightCorner.x + } + if screen.topRightCorner.y > maxY { + maxY = screen.topRightCorner.y + } + } + + let width = maxX - minX + let height = maxY - minY + // Zero the origin to the global rect + let orect = getGlobalScreenRect() + minX -= orect.origin.x + minY -= orect.origin.y + return CGRect(x: minX, y: minY, width: width+(maxLeftScreens*leftMargin()), height: height+(maxBelowScreens*belowMargin())) + } + + // NSScreen coordinates are with a bottom left origin, whereas CGDisplay + // coordinates are top left origin, this function converts the origin.y value + func convertTopLeftToBottomLeft(rect: CGRect) -> CGRect { + let screenFrame = (NSScreen.main?.frame)! + let newY = 0 - (rect.origin.y - screenFrame.size.height + rect.height) + return CGRect(x: rect.origin.x, y: newY, width: rect.width, height: rect.height) + } + + // MARK: - Public utility fuctions + func isScreenActive(id: CGDirectDisplayID) -> Bool { + let preferences = Preferences.sharedInstance + let screen = findScreenWith(id: id) + + switch preferences.newDisplayMode { + case Preferences.NewDisplayMode.allDisplays.rawValue: + // This one is easy + return true + case Preferences.NewDisplayMode.mainOnly.rawValue: + if let scr = screen { + if scr.isMain { + return true + } + } + return false + case Preferences.NewDisplayMode.secondaryOnly.rawValue: + if let scr = screen { + if scr.isMain { + return false + } + } + return true + case Preferences.NewDisplayMode.selection.rawValue: + if isScreenSelected(id: id) { + return true + } + return false + default: + return true // Will never get called + } + } + + func isScreenSelected(id: CGDirectDisplayID) -> Bool { + let preferences = Preferences.sharedInstance + + // If we have it in the dictionnary, then return that + if preferences.newDisplayDict.keys.contains(String(id)) { + return preferences.newDisplayDict[String(id)]! + } + return false // Unknown screens will not be considered selected + } + + func selectScreen(id: CGDirectDisplayID) { + let preferences = Preferences.sharedInstance + preferences.newDisplayDict[String(id)] = true + } + + func unselectScreen(id: CGDirectDisplayID) { + let preferences = Preferences.sharedInstance + preferences.newDisplayDict[String(id)] = false + } + +} diff --git a/Aerial/Source/Models/ErrorLog.swift b/Aerial/Source/Models/ErrorLog.swift index c2aa56e7..b34d288a 100644 --- a/Aerial/Source/Models/ErrorLog.swift +++ b/Aerial/Source/Models/ErrorLog.swift @@ -77,10 +77,12 @@ func Log(level: ErrorLevel, message: String) { if preferences.logToDisk { DispatchQueue.main.async { let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .none - dateFormatter.timeStyle = .medium + if preferences.logMilliseconds { + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" + } else { + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + } let string = dateFormatter.string(from: Date()) + " : " + message + "\n" - //let string = message + "\n" // tmpOverride //if var cacheFileUrl = try? FileManager.default.url(for: .desktopDirectory, in: .userDomainMask, appropriateFor: nil, create: false) { diff --git a/Aerial/Source/Models/ManifestLoader.swift b/Aerial/Source/Models/ManifestLoader.swift index 68f382fe..6741c284 100644 --- a/Aerial/Source/Models/ManifestLoader.swift +++ b/Aerial/Source/Models/ManifestLoader.swift @@ -8,6 +8,8 @@ import Foundation import ScreenSaver +import GameplayKit +import AVFoundation typealias ManifestLoadCallback = ([AerialVideo]) -> Void @@ -20,6 +22,7 @@ class ManifestLoader { var loadedManifest = [AerialVideo]() var processedVideos = [AerialVideo]() var lastPluckedFromPlaylist: AerialVideo? + var customVideoFolders: CustomVideoFolders? var manifestTvOS10: Data? var manifestTvOS11: Data? @@ -149,7 +152,25 @@ class ManifestLoader { playlistRestrictedTo = restrictedTo // Start with a shuffled list - let shuffled = loadedManifest.shuffled() + //let shuffled = loadedManifest.shuffled() + var shuffled: [AerialVideo] + let preferences = Preferences.sharedInstance + if preferences.synchronizedMode { + if #available(OSX 10.11, *) { + let date = Date() + let calendar = NSCalendar.current + let minutes = calendar.component(.minute, from: date) + debugLog("seed : \(minutes)") + + var generator = SeededGenerator(seed: UInt64(minutes)) + shuffled = loadedManifest.shuffled(using: &generator) + } else { + // Fallback on earlier versions + shuffled = loadedManifest.shuffled() + } + } else { + shuffled = loadedManifest.shuffled() + } for video in shuffled { // We exclude videos not in rotation @@ -249,6 +270,8 @@ class ManifestLoader { init() { debugLog("Manifest init") + // tmp + loadCustomVideos() // We try to load our video manifests in 3 steps : // - reload from local variables (unused for now, maybe with previews+screensaver // in some weird edge case on some systems) @@ -363,6 +386,77 @@ class ManifestLoader { } } + // MARK: - Custom videos + func loadCustomVideos() { + do { + if let cacheDirectory = VideoCache.cacheDirectory { + // tvOS12 + var cacheFileUrl = URL(fileURLWithPath: cacheDirectory as String) + cacheFileUrl.appendPathComponent("customvideos.json") + debugLog("custom file : \(cacheFileUrl)") + let ndata = try Data(contentsOf: cacheFileUrl) + customVideoFolders = try CustomVideoFolders(data: ndata) + } + } catch { + debugLog("No customvideos.json \(error)") + } + } + + func saveCustomVideos() { + if let cvf = customVideoFolders, let cacheDirectory = VideoCache.cacheDirectory { + var cacheFileUrl = URL(fileURLWithPath: cacheDirectory as String) + cacheFileUrl.appendPathComponent("customvideos.json") + + do { + if let encodedData = try? cvf.jsonData() { + try encodedData.write(to: cacheFileUrl) + debugLog("customvideos.json saved successfully!") + loadedManifest.removeAll() // we remove our previously loaded manifest, it's invalid + } + } catch let error as NSError { + errorLog("customvideos.json could not be saved: \(error.localizedDescription)") + } + } + } + + // This is where we merge with the processed list + func mergeCustomVideos() { + if let cvf = customVideoFolders { + for folder in cvf.folders { + for asset in folder.assets { + let avResolution = getResolution(asset: AVAsset(url: URL(fileURLWithPath: asset.url))) + var url1080p = "" + var url4K = "" + + if avResolution.height > 1080 { + url4K = URL(fileURLWithPath: asset.url).absoluteString + } else { + url1080p = URL(fileURLWithPath: asset.url).absoluteString + } + + let video = AerialVideo(id: asset.id, + name: folder.label, + secondaryName: asset.accessibilityLabel, + type: "video", + timeOfDay: asset.time, + url1080pH264: url1080p, + url1080pHEVC: "", + url4KHEVC: url4K, + manifest: .customVideos, + poi: [:], + communityPoi: asset.pointsOfInterest) + processedVideos.append(video) + } + } + } + } + + func getResolution(asset: AVAsset) -> CGSize { + guard let track = asset.tracks(withMediaType: AVMediaType.video).first else { return CGSize.zero } + let size = track.naturalSize.applying(track.preferredTransform) + return CGSize(width: abs(size.width), height: abs(size.height)) + } + // MARK: - Periodically check for new videos func checkIfShouldRedownloadFiles() { let dateFormatter = DateFormatter() @@ -545,6 +639,10 @@ class ManifestLoader { warnLog("tvOS10 manifest is absent") } + if customVideoFolders != nil { + mergeCustomVideos() + } + // We sort videos by secondary names, so they can display sorted in our view later processedVideos = processedVideos.sorted { $0.secondaryName < $1.secondaryName } @@ -561,7 +659,7 @@ class ManifestLoader { } }*/ - debugLog("Total videos processed : \(processedVideos.count)") + debugLog("Total videos processed : \(processedVideos.count) callbacks : \(callbacks.count)") // callbacks for callback in self.callbacks { callback(self.loadedManifest) diff --git a/Aerial/Source/Models/SeededGenerator.swift b/Aerial/Source/Models/SeededGenerator.swift new file mode 100644 index 00000000..d350667a --- /dev/null +++ b/Aerial/Source/Models/SeededGenerator.swift @@ -0,0 +1,33 @@ +// +// SeededGenerator.swift +// Aerial +// +// Created by Guillaume Louel on 21/05/2019. +// Copyright © 2019 John Coates. All rights reserved. +// + +import Foundation +import GameplayKit + +@available(OSX 10.11, *) +class SeededGenerator: RandomNumberGenerator { + let seed: UInt64 + private let generator: GKMersenneTwisterRandomSource + + convenience init() { + self.init(seed: 0) + } + + init(seed: UInt64) { + self.seed = seed + generator = GKMersenneTwisterRandomSource(seed: seed) + } + + func next(upperBound: T) -> T where T: FixedWidthInteger, T: UnsignedInteger { + return T(abs(generator.nextInt(upperBound: Int(upperBound)))) + } + + func next() -> T where T: FixedWidthInteger, T: UnsignedInteger { + return T(abs(generator.nextInt())) + } +} diff --git a/Aerial/Source/Views/AerialView.swift b/Aerial/Source/Views/AerialView.swift index bccd7772..f332dd71 100644 --- a/Aerial/Source/Views/AerialView.swift +++ b/Aerial/Source/Views/AerialView.swift @@ -14,7 +14,7 @@ import Sparkle @objc(AerialView) // swiftlint:disable:next type_body_length -final class AerialView: ScreenSaverView { +final class AerialView: ScreenSaverView, CAAnimationDelegate { var playerLayer: AVPlayerLayer! var textLayer: CATextLayer! var clockLayer: CATextLayer! @@ -36,6 +36,8 @@ final class AerialView: ScreenSaverView { var isDisabled = false var timeObserver: Any? + var isQuickFading = false + var brightnessToRestore: Float? static var shouldFade: Bool { @@ -71,19 +73,19 @@ final class AerialView: ScreenSaverView { } } + // Mirrored viewing mode and Spanned viewing mode share the same player for sync & ressource saving static var sharingPlayers: Bool { let preferences = Preferences.sharedInstance - return (preferences.multiMonitorMode == Preferences.MultiMonitorMode.mirrored.rawValue) + return (preferences.newViewingMode == Preferences.NewViewingMode.mirrored.rawValue) || + (preferences.newViewingMode == Preferences.NewViewingMode.spanned.rawValue) } static var sharedViews: [AerialView] = [] - // because of lifecycle in Preview, we may pile up old/no longer + // Because of lifecycle in Preview, we may pile up old/no longer // shared instanciated views that we need to track to not reuse static var instanciatedViews: [AerialView] = [] - //var instanciatedIndex: Int // MARK: - Shared Player - static var singlePlayerAlreadySetup: Bool = false static var sharedPlayerIndex: Int? static var didSkipMain: Bool = false @@ -110,7 +112,7 @@ final class AerialView: ScreenSaverView { // This is the one used by System Preferences override init?(frame: NSRect, isPreview: Bool) { super.init(frame: frame, isPreview: isPreview) - debugLog("avInit1") + debugLog("avInit1 \(frame)") self.animationTimeInterval = 1.0 / 30.0 setup() } @@ -137,13 +139,11 @@ final class AerialView: ScreenSaverView { } // Remove from player index - let indexMaybe = AerialView.players.firstIndex(of: player) guard let index = indexMaybe else { return } - AerialView.players.remove(at: index) } @@ -184,9 +184,6 @@ final class AerialView: ScreenSaverView { let preferences = Preferences.sharedInstance let timeManagement = TimeManagement.sharedInstance - debugLog("isOnBattery : \(timeManagement.isOnBattery())") - debugLog("isBatteryLow : \(timeManagement.isBatteryLow())") - // Initialize Sparkle updater if !isPreview && preferences.updateWhileSaverMode { let suup = SUUpdater.init(for: Bundle(for: AerialView.self)) @@ -255,34 +252,29 @@ final class AerialView: ScreenSaverView { } } + let displayDetection = DisplayDetection.sharedInstance + // We look for the screen in our detected list. In case of preview or unknown screen + // result will be nil + let thisScreen = displayDetection.findScreenWith(frame: self.frame) var localPlayer: AVPlayer? - - let notPreview = !isPreview debugLog("\(self.description) isPreview : \(isPreview)") + debugLog("Using : \(String(describing: thisScreen))") - if notPreview { - debugLog("\(self.description) singlePlayerAlreadySetup \(AerialView.singlePlayerAlreadySetup)") - if AerialView.singlePlayerAlreadySetup && preferences.multiMonitorMode == Preferences.MultiMonitorMode.mainOnly.rawValue { - isDisabled = true - return - } - - if preferences.multiMonitorMode == Preferences.MultiMonitorMode.secondaryOnly.rawValue { - if !AerialView.didSkipMain { - AerialView.didSkipMain = true + if !isPreview { + if let screen = thisScreen { + // Is the screen active according to user settings or not ? + if !displayDetection.isScreenActive(id: screen.id) { + // Then we disable and exit + debugLog("This display is not active, disabling") isDisabled = true return } + } else { + // If we don't know this screen, we disable + //debugLog("This is an unknown display, disabling") + //isDisabled = false + //return } - - // check if we should share preview's player - //let noPlayers = (AerialView.players.count == 0) - let previewPlayerExists = (AerialView.previewPlayer != nil) - debugLog("\(self.description) nbPlayers \(AerialView.players.count) previewPlayerExists \(previewPlayerExists)") - /*if noPlayers && previewPlayerExists { - - localPlayer = AerialView.previewPlayer - }*/ } else { AerialView.previewView = self } @@ -298,12 +290,7 @@ final class AerialView: ScreenSaverView { debugLog("\(self.description) no local player") if AerialView.sharingPlayers { - /*if AerialView.previewPlayer != nil { - localPlayer = AerialView.previewPlayer - } else {*/ - localPlayer = AerialView.sharedPlayer - //} } else { localPlayer = AVPlayer() } @@ -325,6 +312,7 @@ final class AerialView: ScreenSaverView { setupPlayerLayer(withPlayer: player) + // In mirror mode we use the main instance player if AerialView.sharingPlayers && AerialView.singlePlayerAlreadySetup { self.playerLayer.player = AerialView.instanciatedViews[AerialView.sharedPlayerIndex!].player self.playerLayer.opacity = 0 @@ -355,6 +343,8 @@ final class AerialView: ScreenSaverView { func setupPlayerLayer(withPlayer player: AVPlayer) { debugLog("\(self.description) setupPlayerLayer") + let displayDetection = DisplayDetection.sharedInstance + let preferences = Preferences.sharedInstance self.layer = CALayer() guard let layer = self.layer else { @@ -365,15 +355,34 @@ final class AerialView: ScreenSaverView { layer.backgroundColor = NSColor.black.cgColor layer.needsDisplayOnBoundsChange = true layer.frame = self.bounds - - debugLog("\(self.description) setting up player layer with frame: \(self.bounds) / \(self.frame)") + debugLog("\(self.description) setting up player layer with bounds/frame: \(layer.bounds) / \(layer.frame)") playerLayer = AVPlayerLayer(player: player) + if #available(OSX 10.10, *) { playerLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill } playerLayer.autoresizingMask = [CAAutoresizingMask.layerWidthSizable, CAAutoresizingMask.layerHeightSizable] - playerLayer.frame = layer.bounds + + // In case of span mode we need to compute the size of our layer + if preferences.newViewingMode == Preferences.NewViewingMode.spanned.rawValue && !isPreview { + let zRect = displayDetection.getZeroedActiveSpannedRect() + let screen = displayDetection.findScreenWith(frame: self.frame) + if let scr = screen { + let tRect = CGRect(x: zRect.origin.x - scr.zeroedOrigin.x, + y: zRect.origin.y - scr.zeroedOrigin.y, + width: zRect.width, + height: zRect.height) + debugLog("tRect : \(tRect)") + playerLayer.frame = tRect + //playerLayer.bounds = layer.bounds + } else { + errorLog("This is an unknown screen in span mode, this is not good") + playerLayer.frame = layer.bounds + } + } else { + playerLayer.frame = layer.bounds + } layer.addSublayer(playerLayer) textLayer = CATextLayer() @@ -495,7 +504,12 @@ final class AerialView: ScreenSaverView { of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { debugLog("\(self.description) observeValue \(String(describing: keyPath))") - if self.playerLayer.isReadyForDisplay { + + if keyPath == "rate" { + if self.player!.rate > 0 { + debugLog("video started playing") + } + } else if self.playerLayer.isReadyForDisplay { self.player!.play() hasStartedPlaying = true @@ -535,6 +549,9 @@ final class AerialView: ScreenSaverView { // play another video let oldPlayer = self.player self.player = player + player.isMuted = true + player.addObserver(self, forKeyPath: "rate", options: NSKeyValueObservingOptions.new, context: nil) + self.playerLayer.player = self.player if AerialView.shouldFade { self.playerLayer.opacity = 0 @@ -625,10 +642,23 @@ final class AerialView: ScreenSaverView { if preferences.allowSkips { if event.keyCode == 124 { - //playNextVideo() - // We need to skip forward all our views - for view in AerialView.instanciatedViews { - view.playNextVideo() + if !isQuickFading { + // If we share, just call this on our main view + if AerialView.sharingPlayers { + // The first view with the player gets the fade and the play next instruction, + // it controls the others + AerialView.sharedViews.first!.fastFadeOut(andPlayNext: true) + for view in AerialView.sharedViews where AerialView.sharedViews.first != view { + view.fastFadeOut(andPlayNext: false) + } + } else { + // If we do independant playback we have to skip all views + for view in AerialView.instanciatedViews { + view.fastFadeOut(andPlayNext: true) + } + } + } else { + debugLog("Right arrow key currently locked") } } else { self.nextResponder!.keyDown(with: event) @@ -648,6 +678,37 @@ final class AerialView: ScreenSaverView { } // MARK: - Extra Animations + private func fastFadeOut(andPlayNext: Bool) { + // We need to clear the current animations running on playerLayer + isQuickFading = true // Lock the use of keydown + playerLayer.removeAllAnimations() + let fadeOutAnimation = CAKeyframeAnimation(keyPath: "opacity") + fadeOutAnimation.values = [1, 0] as [Int] + fadeOutAnimation.keyTimes = [0, AerialView.fadeDuration] as [NSNumber] + fadeOutAnimation.duration = AerialView.fadeDuration + fadeOutAnimation.delegate = self + fadeOutAnimation.isRemovedOnCompletion = false + fadeOutAnimation.calculationMode = CAAnimationCalculationMode.cubic + if andPlayNext { + playerLayer.add(fadeOutAnimation, forKey: "quickfadeandnext") + } else { + playerLayer.add(fadeOutAnimation, forKey: "quickfade") + } + } + + // Stop callback for fastFadeOut + func animationDidStop(_ anim: CAAnimation, finished flag: Bool) { + isQuickFading = false // Release our ugly lock + playerLayer.opacity = 0 + if anim == playerLayer.animation(forKey: "quickfadeandnext") { + debugLog("stop and next") + playerLayer.removeAllAnimations() // Make sure we get rid of our anim + playNextVideo() + } else { + debugLog("stop") + playerLayer.removeAllAnimations() // Make sure we get rid of our anim + } + } private func addPlayerFades(player: AVPlayer, playerLayer: AVPlayerLayer, video: AerialVideo) { // We only fade in/out if we have duration diff --git a/Aerial/Source/Views/DisplayView.swift b/Aerial/Source/Views/DisplayView.swift new file mode 100644 index 00000000..316d7b94 --- /dev/null +++ b/Aerial/Source/Views/DisplayView.swift @@ -0,0 +1,237 @@ +// +// DisplayView.swift +// Aerial +// +// Created by Guillaume Louel on 09/05/2019. +// Copyright © 2019 John Coates. All rights reserved. +// + +import Foundation +import Cocoa + +class DisplayPreview: NSObject { + var screen: Screen + var previewRect: CGRect + + init(screen: Screen, previewRect: CGRect) { + self.screen = screen + self.previewRect = previewRect + } +} + +class DisplayView: NSView { + // We store our computed previews here + var displayPreviews = [DisplayPreview]() + + // MARK: - Lifecycle + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + // MARK: - Drawing + //swiftlint:disable:next cyclomatic_complexity + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let preferences = Preferences.sharedInstance + + // We need to handle dark mode + var backgroundColor = NSColor.init(white: 0.9, alpha: 1.0) + var borderColor = NSColor.init(white: 0.8, alpha: 1.0) + + //let screenColor = NSColor.init(red: 0.38, green: 0.60, blue: 0.85, alpha: 1.0) + let screenBorderColor = NSColor.black + + let timeManagement = TimeManagement.sharedInstance + if timeManagement.isDarkModeEnabled() { + backgroundColor = NSColor.init(white: 0.2, alpha: 1.0) + borderColor = NSColor.init(white: 0.6, alpha: 1.0) + } + + // Draw background with a 1pt border + borderColor.setFill() + __NSRectFill(dirtyRect) + + let path = NSBezierPath(rect: dirtyRect.insetBy(dx: 1, dy: 1)) + backgroundColor.setFill() + path.fill() + + let displayDetection = DisplayDetection.sharedInstance + displayPreviews = [DisplayPreview]() // Empty the array in case we redraw + + // In order to draw the screen we need to know the total size of all + // the displays together + let globalRect = displayDetection.getGlobalScreenRect() + + var minX: CGFloat, minY: CGFloat, maxX: CGFloat, maxY: CGFloat, scaleFactor: CGFloat + if (frame.width / frame.height) > (globalRect.width / globalRect.height) { + // We fill vertically then + maxY = frame.height - 60 + minY = 30 + scaleFactor = globalRect.height / maxY + maxX = globalRect.width / scaleFactor + minX = (frame.width - maxX)/2 + } else { + // We fill horizontally + maxX = frame.width - 60 + minX = 30 + scaleFactor = globalRect.width / maxX + maxY = globalRect.height / scaleFactor + minY = (frame.height - maxY)/2 + } + + // In spanned mode, we start by a faint full view of the span + if preferences.newViewingMode == Preferences.NewViewingMode.spanned.rawValue { + let activeRect = displayDetection.getZeroedActiveSpannedRect() + debugLog("spanned active rect \(activeRect)") + let activeSRect = NSRect(x: minX + (activeRect.origin.x/scaleFactor), + y: minY + (activeRect.origin.y/scaleFactor), + width: activeRect.width/scaleFactor, + height: activeRect.height/scaleFactor) + + let bundle = Bundle(for: PreferencesWindowController.self) + if let imagePath = bundle.path(forResource: "screen0", ofType: "jpg") { + let image = NSImage(contentsOfFile: imagePath) + image!.draw(in: activeSRect, from: calcScreenshotRect(src: activeSRect), operation: NSCompositingOperation.copy, fraction: 0.1) + } else { + errorLog("\(#file) screenshot is missing!!!") + } + } + + var idx = 0 + // Now we draw each individual screen + for screen in displayDetection.screens { + let sRect = NSRect(x: minX + (screen.zeroedOrigin.x/scaleFactor), + y: minY + (screen.zeroedOrigin.y/scaleFactor), + width: screen.bottomLeftFrame.width/scaleFactor, + height: screen.bottomLeftFrame.height/scaleFactor) + + let sPath = NSBezierPath(rect: sRect) + screenBorderColor.setFill() + sPath.fill() + + let sInRect = sRect.insetBy(dx: 1, dy: 1) + + if preferences.newViewingMode == Preferences.NewViewingMode.independent.rawValue || + preferences.newViewingMode == Preferences.NewViewingMode.mirrored.rawValue { + if displayDetection.isScreenActive(id: screen.id) { + let bundle = Bundle(for: PreferencesWindowController.self) + if let imagePath = bundle.path(forResource: "screen"+String(idx), ofType: "jpg") { + let image = NSImage(contentsOfFile: imagePath) + //image!.draw(in: sInRect) + image!.draw(in: sInRect, from: calcScreenshotRect(src: sInRect), operation: NSCompositingOperation.copy, fraction: 1.0) + } else { + errorLog("\(#file) screenshot is missing!!!") + } + + // Show difference images in independant mode to simulate + if preferences.newViewingMode == Preferences.NewViewingMode.independent.rawValue { + if idx < 2 { + idx += 1 + } else { + idx = 0 + } + } + } else { + // If the screen is innactive we fill it with a near black color + let sInPath = NSBezierPath(rect: sInRect) + let grey = NSColor(white: 0.1, alpha: 1.0) + grey.setFill() + sInPath.fill() + } + } else { + // Spanned mode + if displayDetection.isScreenActive(id: screen.id) { + // Calculate which portion of the image to display + let activeRect = displayDetection.getZeroedActiveSpannedRect() + let activeSRect = NSRect(x: minX + (activeRect.origin.x/scaleFactor), + y: minY + (activeRect.origin.y/scaleFactor), + width: activeRect.width/scaleFactor, + height: activeRect.height/scaleFactor) + let ssRect = calcScreenshotRect(src: activeSRect) + let xFactor = ssRect.width / activeSRect.width + let yFactor = ssRect.height / activeSRect.height + // ... + let sFRect = CGRect(x: (sInRect.origin.x - activeSRect.origin.x) * xFactor + ssRect.origin.x, + y: (sInRect.origin.y - activeSRect.origin.y) * yFactor + ssRect.origin.y, + width: sInRect.width*xFactor, + height: sInRect.height*yFactor) + + let bundle = Bundle(for: PreferencesWindowController.self) + if let imagePath = bundle.path(forResource: "screen0", ofType: "jpg") { + let image = NSImage(contentsOfFile: imagePath) + //image!.draw(in: sInRect) + image!.draw(in: sInRect, from: sFRect, operation: NSCompositingOperation.copy, fraction: 1.0) + } else { + errorLog("\(#file) screenshot is missing!!!") + } + } + } + + // We preserve those calculations to handle our clicking logic + displayPreviews.append(DisplayPreview(screen: screen, previewRect: sInRect)) + + // We put a white bar on the main screen + if screen.isMain { + let mainRect = CGRect(x: sRect.origin.x, y: sRect.origin.y + sRect.height-8, width: sRect.width, height: 8) + let sMainPath = NSBezierPath(rect: mainRect) + NSColor.black.setFill() + sMainPath.fill() + let sMainInPath = NSBezierPath(rect: mainRect.insetBy(dx: 1, dy: 1)) + NSColor.white.setFill() + sMainInPath.fill() + } + } + } + + // Helper to keep aspect ratio of screenshots to be displayed + func calcScreenshotRect(src: CGRect) -> CGRect { + var minX: CGFloat, minY: CGFloat, maxX: CGFloat, maxY: CGFloat, scaleFactor: CGFloat + + let imgw: CGFloat = 720 + let imgh: CGFloat = 400 + + if (imgw/imgh) < (src.width/src.height) { + minX = 0 + maxX = imgw + scaleFactor = src.width / maxX + maxY = src.height / scaleFactor + minY = (imgh - maxY)/2 + } else { + minY = 0 + maxY = imgh + scaleFactor = src.height / maxY + maxX = src.width / scaleFactor + minX = (imgw - maxX)/2 + } + + return CGRect(x: minX, y: minY, width: maxX, height: maxY) + } + + // MARK: - Clicking + override func mouseDown(with event: NSEvent) { + let displayDetection = DisplayDetection.sharedInstance + let preferences = Preferences.sharedInstance + + // Grab relative location of the click in view + let point = convert(event.locationInWindow, from: nil) + + // If in selection mode, toggle the screen & redraw + if preferences.newDisplayMode == Preferences.NewDisplayMode.selection.rawValue { + for displayPreview in displayPreviews { + if displayPreview.previewRect.contains(point) { + if displayDetection.isScreenActive(id: displayPreview.screen.id) { + displayDetection.unselectScreen(id: displayPreview.screen.id) + } else { + displayDetection.selectScreen(id: displayPreview.screen.id) + } + debugLog("Clicked on \(displayPreview.screen.id)") + self.needsDisplay = true + } + } + } + } +} diff --git a/Resources/CustomVideos.xib b/Resources/CustomVideos.xib new file mode 100644 index 00000000..a0af20bb --- /dev/null +++ b/Resources/CustomVideos.xib @@ -0,0 +1,545 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/Info.plist b/Resources/Info.plist index 9cf15a28..5097b15a 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.10beta3 + 1.4.99beta7 CFBundleSignature ???? CFBundleVersion - 1.4.10beta3 + 1.4.99beta7 LSApplicationCategoryType LSMinimumSystemVersion diff --git a/Resources/PreferencesWindow.xib b/Resources/PreferencesWindow.xib index 00c7b8b2..4ce99a70 100644 --- a/Resources/PreferencesWindow.xib +++ b/Resources/PreferencesWindow.xib @@ -44,6 +44,9 @@ + + + @@ -67,6 +70,7 @@ + @@ -77,6 +81,7 @@ + @@ -85,10 +90,11 @@ - + + @@ -109,7 +115,11 @@ + + + + @@ -122,6 +132,7 @@ + @@ -130,6 +141,7 @@ + @@ -157,7 +169,7 @@ - + @@ -320,7 +332,7 @@ - + @@ -338,7 +350,7 @@ - + @@ -357,17 +369,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -466,7 +437,7 @@ is disabled - + @@ -487,7 +458,7 @@ is disabled - + @@ -519,7 +490,7 @@ is disabled + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1523,7 +1696,7 @@ Shift, but macOS 10.12.4 or above and a compatible Mac are required) - + @@ -1706,7 +1879,7 @@ Shift, but macOS 10.12.4 or above and a compatible Mac are required) - + @@ -1715,7 +1888,7 @@ Shift, but macOS 10.12.4 or above and a compatible Mac are required) - + @@ -1728,7 +1901,7 @@ Shift, but macOS 10.12.4 or above and a compatible Mac are required) - + @@ -1737,7 +1910,7 @@ Shift, but macOS 10.12.4 or above and a compatible Mac are required) - + @@ -1827,7 +2000,7 @@ Shift, but macOS 10.12.4 or above and a compatible Mac are required) + @@ -2005,7 +2189,7 @@ Gw - + @@ -2221,7 +2405,7 @@ You can enable it when on battery, or only when your battery reaches 20%. - + @@ -2278,7 +2462,7 @@ You can enable it when on battery, or only when your battery reaches 20%. + + + + + @@ -2516,6 +2766,7 @@ Unless you want to manually manage your updates, we highly recommand you leave t + diff --git a/Resources/Screenshots/screen0.jpg b/Resources/Screenshots/screen0.jpg new file mode 100644 index 00000000..59ed64cd Binary files /dev/null and b/Resources/Screenshots/screen0.jpg differ diff --git a/Resources/Screenshots/screen1.jpg b/Resources/Screenshots/screen1.jpg new file mode 100644 index 00000000..1a917e49 Binary files /dev/null and b/Resources/Screenshots/screen1.jpg differ diff --git a/Resources/Screenshots/screen2.jpg b/Resources/Screenshots/screen2.jpg new file mode 100644 index 00000000..080c20d7 Binary files /dev/null and b/Resources/Screenshots/screen2.jpg differ