Showing preview only (2,419K chars total). Download the full file or copy to clipboard to get everything.
Repository: JohnCoates/Aerial
Branch: master
Commit: 43daa78e8f71
Files: 225
Total size: 2.3 MB
Directory structure:
gitextract_s15f9pm6/
├── .codeclimate.yml
├── .gitignore
├── .gitmodules
├── .swiftlint.yml
├── .travis.yml
├── Aerial/
│ ├── App/
│ │ ├── AppDelegate.swift
│ │ └── Resources/
│ │ ├── Assets.xcassets/
│ │ │ ├── Accent Color.colorset/
│ │ │ │ └── Contents.json
│ │ │ ├── AppIcon.appiconset/
│ │ │ │ └── Contents.json
│ │ │ ├── Contents.json
│ │ │ └── FirstPanelBackground.colorset/
│ │ │ └── Contents.json
│ │ ├── Base.lproj/
│ │ │ └── MainMenu.xib
│ │ └── Info.plist
│ └── Source/
│ ├── Controllers/
│ │ └── CustomVideoController.swift
│ ├── Header.h
│ ├── Models/
│ │ ├── API/
│ │ │ ├── Forecast.swift
│ │ │ ├── GeoCoding.swift
│ │ │ ├── OneCall.swift
│ │ │ └── OpenWeather.swift
│ │ ├── Aerial.swift
│ │ ├── AerialVideo.swift
│ │ ├── Cache/
│ │ │ ├── AssetLoaderDelegate.swift
│ │ │ ├── Cache.swift
│ │ │ ├── PoiStringProvider.swift
│ │ │ ├── Thumbnails.swift
│ │ │ ├── TimeMachine.swift
│ │ │ ├── VideoCache.swift
│ │ │ ├── VideoDownload.swift
│ │ │ ├── VideoLoader.swift
│ │ │ └── VideoManager.swift
│ │ ├── CompanionBridge.swift
│ │ ├── CustomVideoFolders+helpers.swift
│ │ ├── CustomVideoFolders.swift
│ │ ├── Downloads/
│ │ │ ├── AsynchronousOperation.swift
│ │ │ ├── DownloadManager.swift
│ │ │ └── FileHelpers.swift
│ │ ├── ErrorLog.swift
│ │ ├── Extensions/
│ │ │ ├── AVAsset+VideoOrientation.swift
│ │ │ ├── AVPlayerItem+vibrance.swift
│ │ │ ├── AVPlayerViewExtension.swift
│ │ │ ├── DispatchQueue+Extension.swift
│ │ │ ├── NSButton+icons.swift
│ │ │ ├── NSImage+trim.swift
│ │ │ └── NSMenuItem+icons.swift
│ │ ├── Hardware/
│ │ │ ├── Battery.swift
│ │ │ ├── Brightness.swift
│ │ │ ├── DarkMode.swift
│ │ │ ├── DisplayDetection.swift
│ │ │ ├── HardwareDetection.swift
│ │ │ ├── ISSoundAdditions/
│ │ │ │ ├── Sound.swift
│ │ │ │ ├── SoundOutputManager+Goodies.swift
│ │ │ │ ├── SoundOutputManager+Properties.swift
│ │ │ │ └── SoundOutputManager.swift
│ │ │ └── NightShift.swift
│ │ ├── Locations.swift
│ │ ├── ManifestLoader.swift
│ │ ├── Music/
│ │ │ └── Music.swift
│ │ ├── PlaybackSpeed.swift
│ │ ├── Prefs/
│ │ │ ├── PrefsAdvanced.swift
│ │ │ ├── PrefsCache.swift
│ │ │ ├── PrefsDisplays.swift
│ │ │ ├── PrefsInfo.swift
│ │ │ ├── PrefsTime.swift
│ │ │ ├── PrefsUpdates.swift
│ │ │ └── PrefsVideos.swift
│ │ ├── SeededGenerator.swift
│ │ ├── Sources/
│ │ │ ├── Sidebar.swift
│ │ │ ├── Source.swift
│ │ │ ├── SourceInfo.swift
│ │ │ ├── SourceList.swift
│ │ │ └── VideoList.swift
│ │ └── Time/
│ │ ├── Aerial-Bridging-Header.h
│ │ ├── IOBridge.m
│ │ ├── Solar.swift
│ │ └── TimeManagement.swift
│ └── Views/
│ ├── AerialPlayerItem.swift
│ ├── AerialView+Brightness.swift
│ ├── AerialView+Player.swift
│ ├── AerialView.swift
│ ├── Layers/
│ │ ├── AnimatableLayer.swift
│ │ ├── AnimationLayer.swift
│ │ ├── AnimationTextLayer.swift
│ │ ├── BatteryIconLayer.swift
│ │ ├── ClockLayer.swift
│ │ ├── CountdownLayer.swift
│ │ ├── DateLayer.swift
│ │ ├── DownloadIndicatorLayer.swift
│ │ ├── LayerManager.swift
│ │ ├── LayerOffsets.swift
│ │ ├── LocationLayer.swift
│ │ ├── MessageLayer.swift
│ │ ├── Music/
│ │ │ ├── ArtworkLayer.swift
│ │ │ └── MusicLayer.swift
│ │ ├── TimerLayer.swift
│ │ └── Weather/
│ │ ├── ConditionLayer.swift
│ │ ├── ConditionSymbolLayer.swift
│ │ ├── ForecastLayer.swift
│ │ ├── WeatherLayer.swift
│ │ ├── WindDirectionLayer.swift
│ │ └── YahooLogoLayer.swift
│ ├── MainUI/
│ │ ├── AspectFillNSImageView.swift
│ │ ├── NowPlayingCollectionView.swift
│ │ ├── ShadowTextFieldCell.swift
│ │ ├── SidebarOutlineView.swift
│ │ └── VideoCellView.swift
│ ├── PrefPanel/
│ │ ├── CheckCellView.swift
│ │ ├── DisplayView.swift
│ │ ├── InfoBatteryView.swift
│ │ ├── InfoClockView.swift
│ │ ├── InfoCommonView.swift
│ │ ├── InfoContainerView.swift
│ │ ├── InfoCountdownView.swift
│ │ ├── InfoDateView.swift
│ │ ├── InfoLocationView.swift
│ │ ├── InfoMessageView.swift
│ │ ├── InfoMusicView.swift
│ │ ├── InfoSettingsTableSource.swift
│ │ ├── InfoSettingsView.swift
│ │ ├── InfoTableSource.swift
│ │ ├── InfoTimerView.swift
│ │ ├── InfoWeatherView.swift
│ │ ├── VideoHeaderView.swift
│ │ └── VideoViewItem.swift
│ └── Sources/
│ ├── ActionCellView.swift
│ ├── CheckboxCellView.swift
│ ├── DescriptionCellView.swift
│ └── SourceOutlineView.swift
├── Aerial copy-Info.plist
├── Aerial.xcodeproj/
│ ├── project.pbxproj
│ ├── project.xcworkspace/
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata/
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── WorkspaceSettings.xcsettings
│ └── xcshareddata/
│ └── xcschemes/
│ └── Aerial.xcscheme
├── AerialApp copy-Info.plist
├── Documentation/
│ ├── AutoUpdates.md
│ ├── ChangeLog.md
│ ├── Contribute.md
│ ├── CustomVideos.md
│ ├── FAQs.md
│ ├── HardwareDecoding.md
│ ├── Installation.md
│ ├── MoreVideos.md
│ ├── OfflineMode.md
│ ├── README.md
│ └── Troubleshooting.md
├── LICENSE
├── Makefile
├── Podfile
├── Readme.md
├── Resources/
│ ├── Community/
│ │ ├── Readme.md
│ │ ├── ar.json
│ │ ├── de.json
│ │ ├── en.json
│ │ ├── es.json
│ │ ├── fr.json
│ │ ├── he.json
│ │ ├── hu.json
│ │ ├── it.json
│ │ ├── ja.json
│ │ ├── ko.json
│ │ ├── missingvideos.json
│ │ ├── nl.json
│ │ ├── pl.json
│ │ ├── pt.json
│ │ ├── pt_BR.json
│ │ ├── ru.json
│ │ ├── sv.json
│ │ ├── tl.json
│ │ ├── zh_CN.json
│ │ └── zh_TW.json
│ ├── MainUI/
│ │ ├── First time setup/
│ │ │ ├── CacheSetupViewController.swift
│ │ │ ├── CacheSetupViewController.xib
│ │ │ ├── FirstSetupWindowController.swift
│ │ │ ├── FirstSetupWindowController.xib
│ │ │ ├── NextViewController.swift
│ │ │ ├── NextViewController.xib
│ │ │ ├── RecapViewController.swift
│ │ │ ├── RecapViewController.xib
│ │ │ ├── TimeSetupViewController.swift
│ │ │ ├── TimeSetupViewController.xib
│ │ │ ├── VideoFormatViewController.swift
│ │ │ ├── VideoFormatViewController.xib
│ │ │ ├── WelcomeViewController.swift
│ │ │ └── WelcomeViewController.xib
│ │ ├── Infos panels/
│ │ │ ├── CreditsViewController.swift
│ │ │ ├── CreditsViewController.xib
│ │ │ ├── HelpViewController.swift
│ │ │ ├── HelpViewController.xib
│ │ │ ├── InfoViewController.swift
│ │ │ └── InfoViewController.xib
│ │ ├── PanelWindowController.swift
│ │ ├── PanelWindowController.xib
│ │ ├── Settings panels/
│ │ │ ├── AdvancedViewController.swift
│ │ │ ├── AdvancedViewController.xib
│ │ │ ├── BrightnessViewController.swift
│ │ │ ├── BrightnessViewController.xib
│ │ │ ├── CacheViewController.swift
│ │ │ ├── CacheViewController.xib
│ │ │ ├── Collection View/
│ │ │ │ ├── PlayingCollectionViewItem.swift
│ │ │ │ └── PlayingCollectionViewItem.xib
│ │ │ ├── CompanionCacheViewController.swift
│ │ │ ├── CompanionCacheViewController.xib
│ │ │ ├── DisplaysViewController.swift
│ │ │ ├── DisplaysViewController.xib
│ │ │ ├── FiltersViewController.swift
│ │ │ ├── FiltersViewController.xib
│ │ │ ├── NowPlayingViewController.swift
│ │ │ ├── NowPlayingViewController.xib
│ │ │ ├── OverlaysViewController.swift
│ │ │ ├── OverlaysViewController.xib
│ │ │ ├── SourcesViewController.swift
│ │ │ ├── SourcesViewController.xib
│ │ │ ├── TimeViewController.swift
│ │ │ └── TimeViewController.xib
│ │ ├── SidebarViewController.swift
│ │ ├── SidebarViewController.xib
│ │ ├── VideosViewController.swift
│ │ └── VideosViewController.xib
│ └── Old stuff/
│ ├── CustomVideos.xib
│ └── Info.plist
├── Tests/
│ ├── Info.plist
│ └── PreferencesTests.swift
├── appcast.xml
├── beta-appcast.xml
├── issue_template.md
└── lokalise.example.cfg
================================================
FILE CONTENTS
================================================
================================================
FILE: .codeclimate.yml
================================================
engines:
tailor:
enabled: true
ratings:
paths:
- "**.swift"
exclude_paths: []
================================================
FILE: .gitignore
================================================
lokalise.cfg
.DS_Store
xcuserdata/
compile/
build/
DerivedData/
*.xccheckout
release/
debug.plist
Examples/debug.html
Aerial/Source/Models/API/APISecrets.swift
================================================
FILE: .gitmodules
================================================
[submodule "Extern/OAuthSwift"]
path = Extern/OAuthSwift
url = https://github.com/OAuthSwift/OAuthSwift.git
branch = 2.0.0
ignore = dirty
================================================
FILE: .swiftlint.yml
================================================
disabled_rules:
# Allow force-casting (e.g. `x as! UICollectionViewCell`).
# We may want to re-enable and address this rule.
- force_cast
# Allow `TODO` and `FIXME` comments.
- todo
# Allow the use of `let _ = <optional>`
- unused_optional_binding
# Allow the use of parantheses when calling methods with trailing completion closures
- empty_parentheses_with_trailing_closure
# We use enum "namespaces" which leads to nesting violations
- nesting
# Re-evalature to shorten functions up
- function_body_length
# Allow declaring operators without extra whitespace, like so: `func ==(_ lhs, ...)`
- operator_whitespace
- redundant_string_enum_value
- inclusive_language
excluded:
- Extern
opt_in_rules:
# Prefer checking `isEmpty` over `count > 0`
- empty_count
file_length:
warning: 1000
error: 2000
line_length: 250
identifier_name:
min_length:
warning: 2
================================================
FILE: .travis.yml
================================================
language: objective-c
osx_image: xcode11.2
before_install:
- pod repo update
after_success:
- bash <(curl -s https://codecov.io/bash) -J 'AerialApp'
before_script:
- make lint
script:
- make test-travis
================================================
FILE: Aerial/App/AppDelegate.swift
================================================
//
// AppDelegate.swift
// Aerial Test
//
// Created by John Coates on 10/23/15.
// Copyright © 2015 John Coates. All rights reserved.
//
import Cocoa
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
// So this is where we come in, when compiled as an Application
override init() {
super.init()
// First thing : let our model know we are an app and not a screensaver !
Aerial.helper.appMode = true
let panelWindowController = PanelWindowController()
panelWindowController.showWindow(self)
panelWindowController.window?.makeKeyAndOrderFront(nil)
}
}
================================================
FILE: Aerial/App/Resources/Assets.xcassets/Accent Color.colorset/Contents.json
================================================
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.517",
"green" : "0.585",
"red" : "0.176"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.726",
"green" : "0.800",
"red" : "0.354"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: Aerial/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
================================================
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon-color-1-1024x1024-transparent.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: Aerial/App/Resources/Assets.xcassets/Contents.json
================================================
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: Aerial/App/Resources/Assets.xcassets/FirstPanelBackground.colorset/Contents.json
================================================
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "37",
"green" : "35",
"red" : "33"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
================================================
FILE: Aerial/App/Resources/Base.lproj/MainMenu.xib
================================================
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21225" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment version="101202" identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21225"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="AerialApp" customModuleProvider="target">
<connections>
<outlet property="window" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
</connections>
</customObject>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="AerialConfig" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="AerialConfig" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About AerialConfig" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Services" id="NMo-om-nkz">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide AerialConfig" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
</connections>
</menuItem>
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
</connections>
</menuItem>
<menuItem title="Show All" id="Kd2-mp-pUS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Quit AerialConfig" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Edit" id="Ely-96-cwI">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="hps-3b-qtH">
<items>
<menuItem title="Undo" keyEquivalent="z" id="AZa-b7-JEi">
<connections>
<action selector="undo:" target="-1" id="F1I-HX-2LI"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="z8s-KR-qxL">
<connections>
<action selector="redo:" target="-1" id="vrh-8A-jrP"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="8O0-qa-oC2"/>
<menuItem title="Cut" keyEquivalent="x" id="u6z-lC-eno">
<connections>
<action selector="cut:" target="-1" id="01I-WP-MKY"/>
</connections>
</menuItem>
<menuItem title="Copy" keyEquivalent="c" id="fHG-NW-0z3">
<connections>
<action selector="copy:" target="-1" id="s4a-Cj-Acy"/>
</connections>
</menuItem>
<menuItem title="Paste" keyEquivalent="v" id="sYN-V0-TdJ">
<connections>
<action selector="paste:" target="-1" id="55U-Jl-25y"/>
</connections>
</menuItem>
<menuItem title="Paste and Match Style" keyEquivalent="V" id="aja-8e-dgu">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="pasteAsPlainText:" target="-1" id="Tfs-Vv-6sM"/>
</connections>
</menuItem>
<menuItem title="Delete" id="yrU-MX-E7p">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="delete:" target="-1" id="kLj-Wn-94S"/>
</connections>
</menuItem>
<menuItem title="Select All" keyEquivalent="a" id="PNg-Ap-ics">
<connections>
<action selector="selectAll:" target="-1" id="Yba-wJ-PSK"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="RoR-UE-hoY"/>
<menuItem title="Find" id="vW6-hY-MBb">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Find" id="Ver-gz-vxf">
<items>
<menuItem title="Find…" tag="1" keyEquivalent="f" id="SH6-Y1-RCn">
<connections>
<action selector="performFindPanelAction:" target="-1" id="3Ky-oc-3OH"/>
</connections>
</menuItem>
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="kbM-Wh-CXk">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="performTextFinderAction:" target="-1" id="WOe-88-IYx"/>
</connections>
</menuItem>
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="tLt-jX-W6u">
<connections>
<action selector="performFindPanelAction:" target="-1" id="wOn-MG-aJ9"/>
</connections>
</menuItem>
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="FLM-v2-9VU">
<connections>
<action selector="performFindPanelAction:" target="-1" id="vNk-lI-KrY"/>
</connections>
</menuItem>
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="KED-o2-kES">
<connections>
<action selector="performFindPanelAction:" target="-1" id="iE2-Sk-Y1f"/>
</connections>
</menuItem>
<menuItem title="Jump to Selection" keyEquivalent="j" id="hx3-kP-qhB">
<connections>
<action selector="centerSelectionInVisibleArea:" target="-1" id="G9A-VW-vqf"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Spelling and Grammar" id="rRo-Jn-UVa">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Spelling" id="aoA-V7-nAF">
<items>
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="i0r-K8-M7B">
<connections>
<action selector="showGuessPanel:" target="-1" id="o1H-3B-zTq"/>
</connections>
</menuItem>
<menuItem title="Check Document Now" keyEquivalent=";" id="Utp-sk-ZNq">
<connections>
<action selector="checkSpelling:" target="-1" id="4lE-58-CzY"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="iRA-Bb-AOf"/>
<menuItem title="Check Spelling While Typing" id="pwo-Qg-oJR">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleContinuousSpellChecking:" target="-1" id="fgA-sP-weF"/>
</connections>
</menuItem>
<menuItem title="Check Grammar With Spelling" id="tvK-Fx-9X7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleGrammarChecking:" target="-1" id="7PX-9g-HSb"/>
</connections>
</menuItem>
<menuItem title="Correct Spelling Automatically" id="WXd-nz-2VJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticSpellingCorrection:" target="-1" id="hND-cN-FCl"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Substitutions" id="qZk-DK-LJ2">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Substitutions" id="buk-mV-Szi">
<items>
<menuItem title="Show Substitutions" id="uuW-7m-6fq">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontSubstitutionsPanel:" target="-1" id="jKc-1S-kDY"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="03X-EQ-kB2"/>
<menuItem title="Smart Copy/Paste" id="Sab-6D-ctk">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleSmartInsertDelete:" target="-1" id="FtY-gg-piq"/>
</connections>
</menuItem>
<menuItem title="Smart Quotes" id="NBZ-hz-SgE">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="paF-wL-PNP"/>
</connections>
</menuItem>
<menuItem title="Smart Dashes" id="qDJ-ox-apW">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDashSubstitution:" target="-1" id="EsJ-XT-H2z"/>
</connections>
</menuItem>
<menuItem title="Smart Links" id="puf-Wy-wRh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticLinkDetection:" target="-1" id="H7m-TQ-z5r"/>
</connections>
</menuItem>
<menuItem title="Data Detectors" id="ZlN-Pp-V3w">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDataDetection:" target="-1" id="dFC-9H-fe8"/>
</connections>
</menuItem>
<menuItem title="Text Replacement" id="U9I-vu-BCF">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticTextReplacement:" target="-1" id="RaR-Cg-QQY"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Transformations" id="YIb-H8-lLI">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Transformations" id="K5e-xT-H5f">
<items>
<menuItem title="Make Upper Case" id="Dit-nN-q29">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="uppercaseWord:" target="-1" id="zBp-XU-qfZ"/>
</connections>
</menuItem>
<menuItem title="Make Lower Case" id="MFP-vp-qyh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="lowercaseWord:" target="-1" id="PT3-O8-U6D"/>
</connections>
</menuItem>
<menuItem title="Capitalize" id="siS-MZ-CDU">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="capitalizeWord:" target="-1" id="R1r-R3-IeY"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Speech" id="vAf-Xs-OJA">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Speech" id="qtJ-AJ-lgf">
<items>
<menuItem title="Start Speaking" id="YXV-gH-abS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="startSpeaking:" target="-1" id="wZU-sl-bMV"/>
</connections>
</menuItem>
<menuItem title="Stop Speaking" id="5vG-ZP-DEH">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="stopSpeaking:" target="-1" id="Kjo-3s-VPE"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="AerialConfig Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="-1" id="y7X-2Q-9no"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
<point key="canvasLocation" x="-460" y="78"/>
</menu>
<window allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" titlebarAppearsTransparent="YES" id="QvC-M9-y7g">
<windowStyleMask key="styleMask" titled="YES" resizable="YES" fullSizeContentView="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="335" y="390" width="1744" height="1125"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
<view key="contentView" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="1744" height="1125"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<customView fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="M0V-7R-ZSD" customClass="AerialView" customModule="AerialApp" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="1744" height="1125"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</customView>
</subviews>
</view>
<point key="canvasLocation" x="492.5" y="420"/>
</window>
</objects>
</document>
================================================
FILE: Aerial/App/Resources/Info.plist
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2015 John Coates. All rights reserved.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Aerial uses location services to calculate Sunset and Sunrise times from your position</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Aerial uses location services to calculate Sunset and Sunrise times from your position</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/JohnCoates/Aerial/master/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>fbiQGEFq55xl4bjwj2/SpIO4JMsKmEyhHEWlMMueyDY=</string>
</dict>
</plist>
================================================
FILE: Aerial/Source/Controllers/CustomVideoController.swift
================================================
//
// 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, NSDraggingDestination {
@IBOutlet var mainPanel: NSWindow!
// This is the panel workaround for Catalina
@IBOutlet var addFolderCatalinaPanel: NSPanel!
@IBOutlet var addFolderTextField: NSTextField!
@IBOutlet var folderOutlineView: NSOutlineView!
@IBOutlet var topPathControl: NSPathControl!
@IBOutlet var folderView: NSView!
@IBOutlet var fileView: NSView!
@IBOutlet var onboardingLabel: NSTextField!
@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!
@IBOutlet var cvcMenu: NSMenu!
@IBOutlet var menuRemoveFolderAndVideos: NSMenuItem!
@IBOutlet var menuRemoveVideo: NSMenuItem!
var currentFolder: Folder?
var currentAsset: Asset?
var currentAssetDuration: Int?
var hasAwokenAlready = false
var sw: NSWindow?
var controller: SourcesViewController?
// 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")
// self.menu = cvcMenu
folderOutlineView.dataSource = self
folderOutlineView.delegate = self
folderOutlineView.menu = cvcMenu
cvcMenu.delegate = self
if #available(OSX 10.13, *) {
folderOutlineView.registerForDraggedTypes([.fileURL, .URL])
} else {
// Fallback on earlier versions
}
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")
// TODO 2.0
/*
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: SourcesViewController) {
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) {
debugLog("addFolder")
if #available(OSX 10.15, *) {
// On Catalina, we can't use NSOpenPanel right now
addFolderTextField.stringValue = ""
addFolderCatalinaPanel.makeKeyAndOrderFront(self)
} else {
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()
}
}
}
@IBAction func addFolderCatalinaConfirm(_ sender: Any) {
let strFolder = addFolderTextField.stringValue
if FileManager.default.fileExists(atPath: strFolder as String) {
self.processPathForVideos(url: URL(fileURLWithPath: strFolder, isDirectory: true))
}
addFolderCatalinaPanel.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 = [VideoAsset]()
for lurl in urls {
if lurl.path.lowercased().hasSuffix(".mp4") || lurl.path.lowercased().hasSuffix(".mov") {
assets.append(VideoAsset(accessibilityLabel: folderName,
id: NSUUID().uuidString,
title: lurl.lastPathComponent,
timeOfDay: "day",
scene: "",
pointsOfInterest: [:],
url4KHDR: "",
url4KSDR: lurl.path,
url1080H264: "",
url1080HDR: "",
url4KSDR120FPS: "",
url4KSDR240FPS: "",
url1080SDR: "",
url: "",
type: "nature"))
}
}
// ...
if SourceList.hasNamed(name: url.lastPathComponent) {
Aerial.helper.showInfoAlert(title: "Source name mismatch",
text: "A source with this name already exists. Try renaming your folder and try again.")
} else {
debugLog("Creating source \(url.lastPathComponent)")
// Generate and save the Source
let source = Source(name: url.lastPathComponent,
description: "Local files from \(url.path)",
manifestUrl: "manifest.json",
type: .local,
scenes: [.nature],
isCachable: false,
license: "",
more: "")
SourceList.saveSource(source)
// Then the entries
let videoManifest = VideoManifest(assets: assets, initialAssetCount: 1, version: 1)
SourceList.saveEntries(source: source, manifest: videoManifest)
}
/*
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: - Context menu
@IBAction func menuRemoveFolderAndVideoClick(_ sender: NSMenuItem) {
if let folder = sender.representedObject as? Folder {
let manifestInstance = ManifestLoader.instance
if let cvf = manifestInstance.customVideoFolders {
cvf.folders.remove(at: cvf.getFolderIndex(withUrl: folder.url))
}
}
folderOutlineView.reloadData()
}
@IBAction func menuRemoveVideoClick(_ sender: NSMenuItem) {
if let asset = sender.representedObject as? Asset {
let manifestInstance = ManifestLoader.instance
if let cvf = manifestInstance.customVideoFolders {
for fld in cvf.folders {
let index = fld.getAssetIndex(withUrl: asset.url)
if index > -1 {
fld.assets.remove(at: index)
}
}
}
}
folderOutlineView.reloadData()
}
}
// 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
if let source = item as? Source {
return VideoList.instance.videos.filter({ $0.source.name == source.name })[index]
}
// Return a source
return SourceList.foundSources.filter({ $0.type == .local })[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 source = item as? Source {
return VideoList.instance.videos.filter({ $0.source.name == source.name }).count
}
return SourceList.foundSources.filter({ $0.type == .local }).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 = ""
if let source = item as? Source {
text = source.name
} else if let video = item as? AerialVideo {
text = video.name
}
// 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 source = item as? Source {
topPathControl.isHidden = false
folderView.isHidden = false
fileView.isHidden = true
onboardingLabel.isHidden = true
topPathControl.url = URL(fileURLWithPath: source.manifestUrl)
folderShortNameTextField.stringValue = source.description
currentAsset = nil
currentFolder = nil // folder
} else if let file = item as? Asset {
topPathControl.isHidden = false
folderView.isHidden = true
fileView.isHidden = false
onboardingLabel.isHidden = true
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!)
durationLabel.stringValue = String(currentAssetDuration!) + " seconds"
resolutionLabel.stringValue = crString
player.replaceCurrentItem(with: localitem)
}
poiTableView.reloadData()
} else {
topPathControl.isHidden = true
folderView.isHidden = true
fileView.isHidden = true
onboardingLabel.isHidden = false
}
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))
}
func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation {
return NSDragOperation.copy
}
func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool {
if let items = info.draggingPasteboard.pasteboardItems {
for item in items {
if #available(OSX 10.13, *) {
if let str = item.string(forType: .fileURL) {
let surl = URL(fileURLWithPath: str).standardized
debugLog("received drop \(surl)")
if surl.isDirectory {
debugLog("processing dir")
self.processPathForVideos(url: surl)
}
}
} else {
// Fallback on earlier versions
}
}
}
return true
}
}
// 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
}
}
}
extension URL {
/*var isDirectory: Bool? {
do {
let values = try self.resourceValues(
forKeys: Set([URLResourceKey.isDirectoryKey])
)
return values.isDirectory
} catch { return nil }
}*/
var isDirectory: Bool {
return (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
}
var subDirectories: [URL] {
guard isDirectory else { return [] }
return (try? FileManager.default.contentsOfDirectory(at: self,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]).filter(\.isDirectory)) ?? []
}
}
extension CustomVideoController: NSMenuDelegate {
func menuNeedsUpdate(_ menu: NSMenu) {
let row = folderOutlineView.clickedRow
guard row != -1 else { return }
let rowItem = folderOutlineView.item(atRow: row)
if (rowItem as? Folder) != nil {
menuRemoveVideo.isHidden = true
menuRemoveFolderAndVideos.isHidden = false
} else if (rowItem as? Asset) != nil {
menuRemoveVideo.isHidden = false
menuRemoveFolderAndVideos.isHidden = true
}
// Mark the clicked item here
for item in menu.items {
item.representedObject = rowItem
}
}
}
================================================
FILE: Aerial/Source/Header.h
================================================
//
// Header.h
// Aerial
//
// Created by Guillaume Louel on 26/07/2023.
// Copyright © 2023 Guillaume Louel. All rights reserved.
//
#ifndef Header_h
#define Header_h
#endif /* Header_h */
================================================
FILE: Aerial/Source/Models/API/Forecast.swift
================================================
//
// Forecast.swift
// Aerial
//
// Created by Guillaume Louel on 26/04/2021.
// Copyright © 2021 Guillaume Louel. All rights reserved.
//
// 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 forecast = try? newJSONDecoder().decode(Forecast.self, from: jsonData)
import Foundation
// MARK: - Forecast
struct ForecastElement: Codable {
let cod: String?
let message, cnt: Int?
let list: [FList]?
let city: City?
}
// MARK: - City
struct City: Codable {
let id: Int?
let name: String?
let coord: Coord?
let country: String?
let population, timezone, sunrise, sunset: Int?
}
// MARK: - Coord
struct Coord: Codable {
let lat, lon: Double?
}
// MARK: - List
struct FList: Codable {
let dt: Int?
let main: MainClass?
let weather: [OWWeather]?
let clouds: Clouds?
let wind: Wind?
let visibility: Int?
let pop: Double?
let sys: Sys?
let dtTxt: String?
let rain: Rain?
enum CodingKeys: String, CodingKey {
case dt, main, weather, clouds, wind, visibility, pop, sys
case dtTxt = "dt_txt"
case rain
}
}
// MARK: - Clouds
struct Clouds: Codable {
let all: Int?
}
// MARK: - MainClass
struct MainClass: Codable {
let temp, feelsLike, tempMin, tempMax: Double?
let pressure, seaLevel, grndLevel, humidity: Int?
let tempKf: Double?
enum CodingKeys: String, CodingKey {
case temp
case feelsLike = "feels_like"
case tempMin = "temp_min"
case tempMax = "temp_max"
case pressure
case seaLevel = "sea_level"
case grndLevel = "grnd_level"
case humidity
case tempKf = "temp_kf"
}
}
// MARK: - Rain
struct Rain: Codable {
let the3H: Double?
enum CodingKeys: String, CodingKey {
case the3H = "3h"
}
}
// MARK: - Sys
struct Sys: Codable {
let pod: String?
}
// MARK: - ForecastError
struct ForecastError: Codable {
let cod, message: String?
}
// MARK: - Wind
struct Wind: Codable {
let speed: Double?
let deg: Int?
let gust: Double?
}
struct Forecast {
static var testJson = ""
static func getUnits() -> String {
if PrefsInfo.weather.degree == .celsius {
return "metric"
} else {
return "imperial"
}
}
static func getShortcodeLanguage() -> String {
// Those are the languages supported by OpenWeather
let weatherLanguages = ["af", "al", "ar", "az", "bg", "ca", "cz", "da", "de", "el", "en",
"eu", "fa", "fi", "fr", "gl", "he", "hi", "hr", "hu", "id", "it",
"ja", "kr", "la", "lt", "mk", "no", "nl", "pl", "pt", "pt_br", "ro",
"ru", "sv", "sk", "sl", "es", "sr", "th", "tr", "uk", "vi", "zh_cn",
"zh_tw", "zu" ]
if PrefsAdvanced.ciOverrideLanguage == "" {
let bestMatchedLanguage = Bundle.preferredLocalizations(from: weatherLanguages, forPreferences: Locale.preferredLanguages).first
if let match = bestMatchedLanguage {
debugLog("Best matched language : \(match)")
return match
}
} else {
debugLog("Overrode matched language : \(PrefsAdvanced.ciOverrideLanguage)")
return PrefsAdvanced.ciOverrideLanguage
}
// We fallback here if nothing works
return "en"
}
static func makeUrl(lat: String, lon: String) -> String {
return "https://api.openweathermap.org/data/2.5/forecast"
+ "?lat=\(lat)&lon=\(lon)"
+ "&units=\(getUnits())"
+ "&lang=\(getShortcodeLanguage())"
+ "&APPID=\(APISecrets.openWeatherAppId)"
}
static func makeUrl(location: String) -> String {
let nloc = location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
return "https://api.openweathermap.org/data/2.5/forecast"
+ "?q=\(nloc)"
+ "&units=\(getUnits())"
+ "&lang=\(getShortcodeLanguage())"
+ "&APPID=\(APISecrets.openWeatherAppId)"
}
// swiftlint:disable:next cyclomatic_complexity
static func fetch(completion: @escaping(Result<ForecastElement, NetworkError>) -> Void) {
guard testJson == "" else {
let jsonData = testJson.data(using: .utf8)!
if let forecast = try? newJSONDecoder().decode(ForecastElement.self, from: jsonData) {
completion(.success(forecast))
} else {
completion(.failure(.unknown))
}
return
}
if PrefsInfo.weather.locationMode == .useCurrent {
let location = Locations.sharedInstance
location.getCoordinates(failure: { (_) in
completion(.failure(.unknown))
}, success: { (coordinates) in
let lat = String(format: "%.2f", coordinates.latitude)
let lon = String(format: "%.2f", coordinates.longitude)
debugLog("=== OF: Starting locationMode")
fetchData(from: makeUrl(lat: lat, lon: lon)) { result in
switch result {
case .success(let jsonString):
let jsonData = jsonString.data(using: .utf8)!
if let forecast = try? newJSONDecoder().decode(ForecastElement.self, from: jsonData) {
completion(.success(forecast))
} else if (try? newJSONDecoder().decode(ForecastError.self, from: jsonData)) != nil {
completion(.failure(.cityNotFound))
} else {
completion(.failure(.unknown))
}
case .failure(let error):
completion(.failure(.unknown))
print(error.localizedDescription)
}
}
})
} else {
// Just in case, we add a failsafe
if PrefsInfo.weather.locationString == "" {
PrefsInfo.weather.locationString = "Paris, FR"
}
debugLog("=== OF: Starting manual mode")
fetchData(from: makeUrl(location: PrefsInfo.weather.locationString)) { result in
switch result {
case .success(let jsonString):
let jsonData = jsonString.data(using: .utf8)!
if let forecast = try? newJSONDecoder().decode(ForecastElement.self, from: jsonData) {
completion(.success(forecast))
} else if (try? newJSONDecoder().decode(ForecastError.self, from: jsonData)) != nil {
completion(.failure(.cityNotFound))
} else {
completion(.failure(.unknown))
}
case .failure(_):
completion(.failure(.unknown))
}
}
}
}
private static func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
// check the URL is OK, otherwise return with a failure
guard let url = URL(string: urlString) else {
completion(.failure(.badURL))
return
}
URLSession.shared.dataTask(with: url) { data, _, error in
// the task has completed – push our work back to the main thread
DispatchQueue.main.async {
if let data = data {
// success: convert the data to a string and send it back
let stringData = String(decoding: data, as: UTF8.self)
completion(.success(stringData))
} else if error != nil {
// any sort of network failure
completion(.failure(.requestFailed))
} else {
// this ought not to be possible, yet here we are
completion(.failure(.unknown))
}
}
}.resume()
}
}
================================================
FILE: Aerial/Source/Models/API/GeoCoding.swift
================================================
//
// GeoCoding.swift
// Aerial
//
// Created by Guillaume Louel on 22/04/2021.
// Copyright © 2021 Guillaume Louel. All rights reserved.
//
import Foundation
// MARK: - GeoCodingElement
struct GeoCodingElement: Codable {
let name: String?
let lat, lon: Double?
let country, state: String?
enum CodingKeys: String, CodingKey {
case name
case lat, lon, country, state
}
}
typealias GeoCodingArray = [GeoCodingElement]
struct GeoLocation {
let lat, lon: String
}
struct GeoCoding {
static func fetch(completion: @escaping(Result<GeoLocation, NetworkError>) -> Void) {
// Check if we already have a geocoded location for this ?
if PrefsTime.geocodedString == PrefsInfo.weather.locationString {
debugLog("returning cached location from previous geocoding")
let lat = String(format: "%.2f", PrefsTime.cachedLatitude)
let lon = String(format: "%.2f", PrefsTime.cachedLongitude)
completion(.success(GeoLocation(lat: lat, lon: lon)))
} else {
// Seriously, please use Location services...
debugLog("looking for location through geocoding api")
// Just in case, we add a ugly failsafe
if PrefsInfo.weather.locationString == "" {
PrefsInfo.weather.locationString = "Paris, FR"
}
fetchData(from: makeUrl()) { result in
switch result {
case .success(let jsonString):
let jsonData = jsonString.data(using: .utf8)!
if let geoEntity = try? newJSONDecoder().decode(GeoCodingArray.self, from: jsonData) {
if geoEntity.count >= 1 {
let lat = String(format: "%.2f", geoEntity[0].lat!)
let lon = String(format: "%.2f", geoEntity[0].lon!)
// Let's save for next time
PrefsTime.geocodedString = PrefsInfo.weather.locationString
PrefsTime.cachedLatitude = geoEntity[0].lat!
PrefsTime.cachedLongitude = geoEntity[0].lon!
completion(.success(GeoLocation(lat: lat, lon: lon)))
} else {
completion(.failure(.unknown))
}
} else {
completion(.failure(.unknown))
}
case .failure(_):
completion(.failure(.unknown))
}
}
}
}
static func makeUrl() -> String {
let nloc = PrefsInfo.weather.locationString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
return "https://api.openweathermap.org/geo/1.0/direct"
+ "?q=\(nloc)"
+ "&appid=\(APISecrets.openWeatherAppId)"
}
private static func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
// check the URL is OK, otherwise return with a failure
guard let url = URL(string: urlString) else {
completion(.failure(.badURL))
return
}
URLSession.shared.dataTask(with: url) { data, _, error in
// the task has completed – push our work back to the main thread
DispatchQueue.main.async {
if let data = data {
// success: convert the data to a string and send it back
let stringData = String(decoding: data, as: UTF8.self)
completion(.success(stringData))
} else if error != nil {
// any sort of network failure
completion(.failure(.requestFailed))
} else {
// this ought not to be possible, yet here we are
completion(.failure(.unknown))
}
}
}.resume()
}
}
================================================
FILE: Aerial/Source/Models/API/OneCall.swift
================================================
//
// OWOneCall.swift
// Aerial
//
// Created by Guillaume Louel on 23/03/2021.
// Copyright © 2021 Guillaume Louel. All rights reserved.
//
// 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 oCOneCall = try? newJSONDecoder().decode(OCOneCall.self, from: jsonData)
import Foundation
// MARK: - OCOneCall
struct OCOneCall: Codable {
let lat, lon: Double?
let timezone: String?
let timezoneOffset: Int?
let current: OCCurrent?
let minutely: [OCMinutely]?
let hourly: [OCCurrent]?
let daily: [OCDaily]?
enum CodingKeys: String, CodingKey {
case lat, lon, timezone
case timezoneOffset = "timezone_offset"
case current, minutely, hourly, daily
}
// We round them down a bit as openweather provides up to two decimal point precision
mutating func processTemperatures() {
/*
guard main != nil else {
return
}
if PrefsInfo.weather.degree == .celsius {
main!.temp = main!.temp.rounded(toPlaces: 1)
main!.feelsLike = main!.feelsLike.rounded(toPlaces: 1)
} else {
main!.temp = main!.temp.rounded()
main!.feelsLike = main!.feelsLike.rounded()
}*/
}
}
// MARK: - OCCurrent
struct OCCurrent: Codable {
let dt, sunrise, sunset: Int?
let temp, feelsLike: Double?
let pressure, humidity: Int?
let dewPoint, uvi: Double?
let clouds, visibility: Int?
let windSpeed: Double?
let windDeg: Int?
let weather: [OWWeather]?
let windGust, pop: Double?
let rain: OCRain?
enum CodingKeys: String, CodingKey {
case dt, sunrise, sunset, temp
case feelsLike = "feels_like"
case pressure, humidity
case dewPoint = "dew_point"
case uvi, clouds, visibility
case windSpeed = "wind_speed"
case windDeg = "wind_deg"
case weather
case windGust = "wind_gust"
case pop, rain
}
}
// MARK: - OCRain
struct OCRain: Codable {
let the1H: Double?
enum CodingKeys: String, CodingKey {
case the1H = "1h"
}
}
/*
// MARK: - OCWeather
struct OCWeather: Codable {
let id: Int?
let main: String?
let weatherDescription: String?
let icon: String?
enum CodingKeys: String, CodingKey {
case id, main
case weatherDescription = "description"
case icon
}
}*/
// MARK: - OCDaily
struct OCDaily: Codable {
let dt, sunrise, sunset: Int?
let temp: OCTemp?
let feelsLike: OCFeelsLike?
let pressure, humidity: Int?
let dewPoint, windSpeed: Double?
let windDeg: Int?
let weather: [OWWeather]?
let clouds: Int?
let pop, uvi, rain: Double?
enum CodingKeys: String, CodingKey {
case dt, sunrise, sunset, temp
case feelsLike = "feels_like"
case pressure, humidity
case dewPoint = "dew_point"
case windSpeed = "wind_speed"
case windDeg = "wind_deg"
case weather, clouds, pop, uvi, rain
}
}
// MARK: - OCFeelsLike
struct OCFeelsLike: Codable {
let day, night, eve, morn: Double?
}
// MARK: - OCTemp
struct OCTemp: Codable {
let day, min, max, night: Double?
let eve, morn: Double?
}
// MARK: - OCMinutely
struct OCMinutely: Codable {
let dt, precipitation: Int?
}
struct OneCall {
static var testJson = ""
static func getUnits() -> String {
if PrefsInfo.weather.degree == .celsius {
return "metric"
} else {
return "imperial"
}
}
static func getShortcodeLanguage() -> String {
// Those are the languages supported by OpenWeather
let weatherLanguages = ["af", "al", "ar", "az", "bg", "ca", "cz", "da", "de", "el", "en",
"eu", "fa", "fi", "fr", "gl", "he", "hi", "hr", "hu", "id", "it",
"ja", "kr", "la", "lt", "mk", "no", "nl", "pl", "pt", "pt_br", "ro",
"ru", "sv", "sk", "sl", "es", "sr", "th", "tr", "uk", "vi", "zh_cn",
"zh_tw", "zu" ]
if PrefsAdvanced.ciOverrideLanguage == "" {
let bestMatchedLanguage = Bundle.preferredLocalizations(from: weatherLanguages, forPreferences: Locale.preferredLanguages).first
if let match = bestMatchedLanguage {
debugLog("Best matched language : \(match)")
return match
}
} else {
debugLog("Overrode matched language : \(PrefsAdvanced.ciOverrideLanguage)")
return PrefsAdvanced.ciOverrideLanguage
}
// We fallback here if nothing works
return "en"
}
static func makeUrl(lat: String, lon: String) -> String {
return "https://api.openweathermap.org/data/2.5/onecall"
+ "?lat=\(lat)&lon=\(lon)"
+ "&units=\(getUnits())"
+ "&lang=\(getShortcodeLanguage())"
+ "&APPID=\(APISecrets.openWeatherAppId)"
}
/*
static func makeUrl(location: String) -> String {
let nloc = location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
return "https://api.openweathermap.org/data/2.5/onecall"
+ "?q=\(nloc)"
+ "&units=\(getUnits())"
+ "&lang=\(getShortcodeLanguage())"
+ "&APPID=\(APISecrets.openWeatherAppId)"
}*/
// swiftlint:disable:next cyclomatic_complexity
static func fetch(completion: @escaping(Result<OCOneCall, NetworkError>) -> Void) {
guard testJson == "" else {
let jsonData = testJson.data(using: .utf8)!
do {
let openWeather = try newJSONDecoder().decode(OCOneCall.self, from: jsonData)
completion(.success(openWeather))
} catch {
completion(.failure(.unknown))
}
return
}
if PrefsInfo.weather.locationMode == .useCurrent {
let location = Locations.sharedInstance
location.getCoordinates(failure: { (_) in
completion(.failure(.unknown))
}, success: { (coordinates) in
let lat = String(format: "%.2f", coordinates.latitude)
let lon = String(format: "%.2f", coordinates.longitude)
debugLog("=== OC: Starting locationMode")
fetchData(from: makeUrl(lat: lat, lon: lon)) { result in
switch result {
case .success(let jsonString):
let jsonData = jsonString.data(using: .utf8)!
if var openWeather = try? newJSONDecoder().decode(OCOneCall.self, from: jsonData) {
openWeather.processTemperatures()
completion(.success(openWeather))
} else {
completion(.failure(.unknown))
}
case .failure(_):
completion(.failure(.unknown))
}
}
})
} else {
// Urgh, please use location services...
debugLog("=== OC: Starting manual mode")
GeoCoding.fetch { result in
switch result {
case .success(let geoLocation):
fetchData(from: makeUrl(lat: geoLocation.lat, lon: geoLocation.lon)) { result in
switch result {
case .success(let jsonString):
let jsonData = jsonString.data(using: .utf8)!
if var openWeather = try? newJSONDecoder().decode(OCOneCall.self, from: jsonData) {
openWeather.processTemperatures()
completion(.success(openWeather))
} else {
completion(.failure(.unknown))
}
case .failure(_):
completion(.failure(.unknown))
}
}
case .failure(let error):
debugLog(error.localizedDescription)
completion(.failure(.unknown))
}
}
}
}
private static func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
// check the URL is OK, otherwise return with a failure
guard let url = URL(string: urlString) else {
completion(.failure(.badURL))
return
}
URLSession.shared.dataTask(with: url) { data, _, error in
// the task has completed – push our work back to the main thread
DispatchQueue.main.async {
if let data = data {
// success: convert the data to a string and send it back
let stringData = String(decoding: data, as: UTF8.self)
completion(.success(stringData))
} else if error != nil {
// any sort of network failure
completion(.failure(.requestFailed))
} else {
// this ought not to be possible, yet here we are
completion(.failure(.unknown))
}
}
}.resume()
}
}
================================================
FILE: Aerial/Source/Models/API/OpenWeather.swift
================================================
//
// OpenWeather.swift
// Aerial
//
// Created by Guillaume Louel on 04/03/2021.
// Copyright © 2021 Guillaume Louel. All rights reserved.
//
// 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 openWeather = try? newJSONDecoder().decode(OWeather.self, from: jsonData)
import Foundation
enum NetworkError: Error {
case badURL
case requestFailed
case unknown
case cityNotFound
}
// MARK: - OpenWeather
struct OWeather: Codable {
let coord: OWCoord?
let weather: [OWWeather]?
let base: String?
var main: OWMain?
let visibility: Int?
let wind: OWWind?
let clouds: OWClouds?
let dt: Int?
let sys: OWSys?
let timezone, id: Int?
let name: String?
let cod: Int?
// We round them down a bit as openweather provides up to two decimal point precision
mutating func processTemperatures() {
guard main != nil else {
return
}
if PrefsInfo.weather.degree == .celsius {
main!.temp = main!.temp.rounded(toPlaces: 1)
main!.feelsLike = main!.feelsLike.rounded(toPlaces: 1)
} else {
main!.temp = main!.temp.rounded()
main!.feelsLike = main!.feelsLike.rounded()
}
}
}
// MARK: - OWClouds
struct OWClouds: Codable {
let all: Int?
}
// MARK: - OWCoord
struct OWCoord: Codable {
let lon, lat: Double?
}
// MARK: - OWMain
struct OWMain: Codable {
var temp: Double
var feelsLike: Double
var tempMin, tempMax, pressure, humidity: Double
enum CodingKeys: String, CodingKey {
case temp
case feelsLike = "feels_like"
case tempMin = "temp_min"
case tempMax = "temp_max"
case pressure, humidity
}
}
// MARK: - OWSys
struct OWSys: Codable {
let type, id: Int?
let country: String
let sunrise, sunset: Int
}
// MARK: - OWWeather
struct OWWeather: Codable {
let id: Int
let main, weatherDescription, icon: String
enum CodingKeys: String, CodingKey {
case id, main
case weatherDescription = "description"
case icon
}
}
// MARK: - OWWind
struct OWWind: Codable {
let speed: Double
let deg: Int
let gust: Double?
}
struct OpenWeather {
static var testJson = ""
static func getUnits() -> String {
if PrefsInfo.weather.degree == .celsius {
return "metric"
} else {
return "imperial"
}
}
static func getShortcodeLanguage() -> String {
// Those are the languages supported by OpenWeather
let weatherLanguages = ["af", "al", "ar", "az", "bg", "ca", "cz", "da", "de", "el", "en",
"eu", "fa", "fi", "fr", "gl", "he", "hi", "hr", "hu", "id", "it",
"ja", "kr", "la", "lt", "mk", "no", "nl", "pl", "pt", "pt_br", "ro",
"ru", "sv", "sk", "sl", "es", "sr", "th", "tr", "uk", "vi", "zh_cn",
"zh_tw", "zu" ]
if PrefsAdvanced.ciOverrideLanguage == "" {
let bestMatchedLanguage = Bundle.preferredLocalizations(from: weatherLanguages, forPreferences: Locale.preferredLanguages).first
if let match = bestMatchedLanguage {
debugLog("Best matched language : \(match)")
return match
}
} else {
debugLog("Overrode matched language : \(PrefsAdvanced.ciOverrideLanguage)")
return PrefsAdvanced.ciOverrideLanguage
}
// We fallback here if nothing works
return "en"
}
static func makeUrl(lat: String, lon: String) -> String {
return "https://api.openweathermap.org/data/2.5/weather"
+ "?lat=\(lat)&lon=\(lon)"
+ "&units=\(getUnits())"
+ "&lang=\(getShortcodeLanguage())"
+ "&APPID=\(APISecrets.openWeatherAppId)"
}
static func makeUrl(location: String) -> String {
let nloc = location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
return "https://api.openweathermap.org/data/2.5/weather"
+ "?q=\(nloc)"
+ "&units=\(getUnits())"
+ "&lang=\(getShortcodeLanguage())"
+ "&APPID=\(APISecrets.openWeatherAppId)"
}
static func fetch(completion: @escaping(Result<OWeather, NetworkError>) -> Void) {
guard testJson == "" else {
let jsonData = testJson.data(using: .utf8)!
if var openWeather = try? newJSONDecoder().decode(OWeather.self, from: jsonData) {
openWeather.processTemperatures()
completion(.success(openWeather))
} else {
completion(.failure(.cityNotFound))
}
return
}
if PrefsInfo.weather.locationMode == .useCurrent {
let location = Locations.sharedInstance
location.getCoordinates(failure: { (_) in
completion(.failure(.unknown))
}, success: { (coordinates) in
let lat = String(format: "%.2f", coordinates.latitude)
let lon = String(format: "%.2f", coordinates.longitude)
debugLog("=== OW: Starting locationMode")
fetchData(from: makeUrl(lat: lat, lon: lon)) { result in
switch result {
case .success(let jsonString):
let jsonData = jsonString.data(using: .utf8)!
do {
var openWeather = try newJSONDecoder().decode(OWeather.self, from: jsonData)
openWeather.processTemperatures()
completion(.success(openWeather))
} catch {
debugLog("=== OW: JSON decoding failed: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
debugLog("=== OW: Missing key '\(key.stringValue)' at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
case .typeMismatch(let type, let context):
debugLog("=== OW: Type mismatch for \(type) at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
case .valueNotFound(let type, let context):
debugLog("=== OW: Missing value for \(type) at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
case .dataCorrupted(let context):
debugLog("=== OW: Data corrupted at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
@unknown default:
debugLog("=== OW: Unknown decoding error")
}
}
completion(.failure(.cityNotFound))
}
case .failure(let error):
completion(.failure(.unknown))
}
}
})
} else {
// Just in case, we add a failsafe
if PrefsInfo.weather.locationString == "" {
PrefsInfo.weather.locationString = "Paris, FR"
}
debugLog("=== OW: Starting manual mode")
fetchData(from: makeUrl(location: PrefsInfo.weather.locationString)) { result in
switch result {
case .success(let jsonString):
let jsonData = jsonString.data(using: .utf8)!
do {
var openWeather = try newJSONDecoder().decode(OWeather.self, from: jsonData)
openWeather.processTemperatures()
completion(.success(openWeather))
} catch {
debugLog("=== OW: JSON decoding failed: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
debugLog("=== OW: Missing key '\(key.stringValue)' at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
case .typeMismatch(let type, let context):
debugLog("=== OW: Type mismatch for \(type) at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
case .valueNotFound(let type, let context):
debugLog("=== OW: Missing value for \(type) at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
case .dataCorrupted(let context):
debugLog("=== OW: Data corrupted at: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
@unknown default:
debugLog("=== OW: Unknown decoding error")
}
}
completion(.failure(.cityNotFound))
}
case .failure(_):
completion(.failure(.unknown))
}
}
}
}
private static func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
// check the URL is OK, otherwise return with a failure
guard let url = URL(string: urlString) else {
completion(.failure(.badURL))
return
}
URLSession.shared.dataTask(with: url) { data, _, error in
// the task has completed – push our work back to the main thread
DispatchQueue.main.async {
if let data = data {
// success: convert the data to a string and send it back
let stringData = String(decoding: data, as: UTF8.self)
completion(.success(stringData))
} else if error != nil {
// any sort of network failure
completion(.failure(.requestFailed))
} else {
// this ought not to be possible, yet here we are
completion(.failure(.unknown))
}
}
}.resume()
}
}
================================================
FILE: Aerial/Source/Models/Aerial.swift
================================================
//
// Aerial.swift
// Aerial
//
// Contains some common helpers used throughout the code
//
// Created by Guillaume Louel on 17/07/2020.
// Copyright © 2020 Guillaume Louel. All rights reserved.
//
import Cocoa
class Aerial: NSObject {
static let helper = Aerial()
var windowController: PanelWindowController?
// We use this to track whether we run as a screen saver or an app
var appMode = false
// We also track darkmode here now
var darkMode = false
// And we track if we are running under Aerial's Companion
var underCompanion = false
let userName = NSUserName()
// Track our version number for logs and stuff
var version: String = {
if let version = Bundle(identifier: "com.johncoates.Aerial-Test")?.infoDictionary?["CFBundleShortVersionString"] as? String {
return "Version " + version
} else if let version = Bundle(identifier: "com.JohnCoates.Aerial")?.infoDictionary?["CFBundleShortVersionString"] as? String {
return "Version " + version
}
return "Version ?"
}()
// Using HDR in the panel will crash System Settings in macOS 13. This is fixed in macOS 13.4 🎉
func canHDR() -> Bool {
if #available(OSX 13.0, *) {
if #unavailable(OSX 13.4) {
return false
}
}
return true
}
// Are we running under Aerial Companion ? Desktop mode/Fullscreen mode
// Xcode debug mode is also considered as running under Companion
func checkCompanion() {
logToConsole("Checking for companion")
if appMode {
underCompanion = true
logToConsole("> Running in appMode, simming Companion!")
} else {
for bundle in Bundle.allBundles {
if let bundleId = bundle.bundleIdentifier {
if bundleId.contains("AerialUpdater") {
underCompanion = true
logToConsole("> Running under Aerial Companion!")
}
}
}
}
}
func computeDarkMode(view: NSView) {
if #available(OSX 10.14, *) {
//debugLog("Best match appearance : \(view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]))")
//debugLog("Effective Appearence : \(view.effectiveAppearance)")
darkMode = view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
} else {
darkMode = false
}
}
// Language detection
func getPreferredLanguage() -> String {
let printOutputLocale: NSLocale = NSLocale(localeIdentifier: Locale.preferredLanguages[0])
if let deviceLanguageName: String = printOutputLocale.displayName(forKey: .identifier, value: Locale.preferredLanguages[0]) {
if #available(OSX 10.12, *) {
return "Preferred language: \(deviceLanguageName) [\(printOutputLocale.languageCode)]"
} else {
return "Preferred language: \(deviceLanguageName)"
}
} else {
return ""
}
}
// Alerts
func showErrorAlert(question: String, text: String, button: String = "OK") {
let alert = NSAlert()
alert.messageText = question
alert.informativeText = text
alert.alertStyle = .critical
alert.icon = NSImage(named: NSImage.cautionName)
alert.addButton(withTitle: button)
alert.runModal()
}
func showAlert(question: String, text: String, button1: String = "OK", button2: String = "Cancel") -> Bool {
let alert = NSAlert()
alert.messageText = question
alert.informativeText = text
alert.alertStyle = .warning
alert.icon = NSImage(named: NSImage.cautionName)
alert.addButton(withTitle: button1)
alert.addButton(withTitle: button2)
return alert.runModal() == NSApplication.ModalResponse.alertFirstButtonReturn
}
func showInfoAlert(title: String, text: String, button1: String = "OK", caution: Bool = false) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = text
alert.alertStyle = .warning
if caution {
alert.icon = NSImage(named: NSImage.cautionName)
} else {
alert.icon = NSImage(named: NSImage.infoName)
}
alert.addButton(withTitle: button1)
alert.runModal()
}
// Symbol/icon generation
// Symbol as a CALayer
func getSymbolLayer(_ named: String, size: CGFloat) -> CALayer {
let imglayer = CALayer()
imglayer.contents = Aerial.helper.getSymbol(named)
imglayer.frame.size = CGSize(width: size,
height: size)
return imglayer
}
// Symbol as a NSImage
func getSymbol(_ named: String) -> NSImage? {
// Use SFSymbols if available
if #available(macOS 11.0, *) {
if let image = NSImage(systemSymbolName: named, accessibilityDescription: named) {
image.isTemplate = true
// return image
let config = NSImage.SymbolConfiguration(pointSize: 100, weight: .regular)
return image.withSymbolConfiguration(config)?.tinting(with: .white)
}
}
if let imagePath = Bundle(for: PanelWindowController.self).path(
forResource: fallbackSymbol(named),
ofType: "pdf") {
return NSImage(contentsOfFile: imagePath)
}
return nil
}
func getMiniSymbol(_ named: String, tint: NSColor = .labelColor) -> NSImage? {
if let symbol = getSymbol(named) {
return resize(image: symbol, w: Int(symbol.size.width)/10, h: Int(symbol.size.height)/10).tinting(with: tint)
} else {
return nil
}
}
// TODO: move to extension of NSImage...
// swiftlint:disable:next identifier_name
func resize(image: NSImage, w: Int, h: Int) -> NSImage {
let destSize = NSSize(width: CGFloat(w), height: CGFloat(h))
let newImage = NSImage(size: destSize)
newImage.lockFocus()
image.draw(in: NSRect(x: 0, y: 0,
width: destSize.width,
height: destSize.height),
from: NSRect(x: 0, y: 0, width: image.size.width, height: image.size.height),
operation: NSCompositingOperation.sourceOver, fraction: CGFloat(1))
newImage.unlockFocus()
newImage.size = destSize
return NSImage(data: newImage.tiffRepresentation!)!
}
func getAccentedSymbol(_ named: String) -> NSImage? {
if #available(OSX 10.14, *) {
return getSymbol(named)?.tinting(with: .controlAccentColor)
} else {
// Fallback on earlier versions
return getSymbol(named)?.tinting(with: .systemBlue)
}
}
// This is a list of fallback symbols, until we can use those from SF Symbols 2,
// we export from SF Symbols 1...
private func fallbackSymbol(_ forName: String) -> String {
switch forName {
case "cloud":
return "regular.cloud"
case "sun.max":
return "regular.sun.max"
case "sun.min":
return "regular.sun.min"
case "moon.stars":
return "regular.moon.stars"
case "leaf":
return "flame"
case "dial.min":
return "dial"
case "internaldrive":
return "arrow.down.circle"
case "display.2":
return "tv"
case "wrench.and.screwdriver":
return "wrench"
default:
return forName
}
}
// Launch a process through shell and capture/return output
func shell(launchPath: String, arguments: [String] = []) -> (String?, Int32) {
let task = Process()
task.launchPath = launchPath
task.arguments = arguments
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
if #available(OSX 10.13, *) {
do {
try task.run()
} catch {
// handle errors
debugLog("Error: \(error.localizedDescription)")
}
} else {
// A non existing command will crash 10.12
task.launch()
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
task.waitUntilExit()
return (output, task.terminationStatus)
}
func shell(_ command:String, args: [String] = []) -> String
{
let task = Process()
var arguments = ["-c"]
arguments.append(command)
arguments += args
task.launchPath = "/bin/bash"
task.arguments = arguments
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
task.waitUntilExit()
return output ?? ""
/* let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: String.Encoding.utf8)!
*/ /*if output.count > 0 {
//remove newline character.
let lastIndex = output.index(before: output.endIndex)
return String(output[output.startIndex ..< lastIndex])
}*/
//return output
}
// Launch a process through shell and capture/return output
func shell(executableURL: String, arguments: [String] = []) -> (String?, Int32) {
let task = Process()
task.executableURL = URL(fileURLWithPath: executableURL)
task.arguments = arguments
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
if #available(OSX 10.13, *) {
do {
try task.run()
} catch {
// handle errors
debugLog("Error: \(error.localizedDescription)")
}
} else {
// A non existing command will crash 10.12
task.launch()
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
task.waitUntilExit()
return (output, task.terminationStatus)
}
/*
func trySettings() {
let date = Date()
let dateFormatter = DateFormatter()
dateFormatter.timeStyle = .long
dateFormatter.dateStyle = .none
let time = dateFormatter.string(from: date)
let bundleID = "/Users/guillaume/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Preferences/com.glouel.synctest"
// Test 1
CFPreferencesSetValue("underCompanion" as CFString, (underCompanion ? "under" : "notunder") as CFString, bundleID as CFString as CFString, kCFPreferencesCurrentUser, kCFPreferencesAnyHost)
CFPreferencesSetValue("lastRun" as CFString, time as CFString, bundleID as CFString as CFString, kCFPreferencesCurrentUser, kCFPreferencesAnyHost)
let val = CFPreferencesAppSynchronize(bundleID as CFString)
print("value : " + String(val))
// Test 2
let bundleID2 = "/Users/guillaume/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Preferences/com.glouel.synctest2"
let userDefaults = UserDefaults(suiteName: bundleID2)
userDefaults?.setValue(time, forKey: "lastRun")
userDefaults?.synchronize()
userDefaults?.setValue(underCompanion ? "under" : "notunder", forKey: "underCompanion")
userDefaults?.setValue(time, forKey: "lastRun")
userDefaults?.synchronize()
/*let (result, _) = shell(launchPath: "/usr/bin/defaults", arguments: ["read", "~/Library/Preferences/com.glouel.synctest","lastRun"])
debugLog(result!)
print(result!)*/
}*/
func getPreferencesDirectory() -> String {
// Grab an array of Application Support paths
let libPaths = NSSearchPathForDirectoriesInDomains(
.libraryDirectory,
.userDomainMask,
true)
if !libPaths.isEmpty {
if underCompanion {
return libPaths.first! + "/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Preferences/"
} else {
return libPaths.first! + "/Preferences/"
}
} else {
return "/Users/" + Aerial.helper.userName + "/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Preferences/"
}
}
// Starting with 3.1.0beta2, existing settings are moved from Preferences/ByHost to Preferences
// This allows the sharing of preferences between regular screen saver and companion-hosted screensaver
func migratePreferences() {
// First check if the new settings already exists !
let baseContainerPrefPath = getPreferencesDirectory()
let newBundleFile = baseContainerPrefPath + "com.glouel.Aerial.plist"
if FileManager.default.fileExists(atPath: newBundleFile) {
// We are done
logToConsole("!!! New prefs already exists")
} else {
logToConsole("!!! New prefs does NOT exist")
//Look for ByHost
let byHostPath = baseContainerPrefPath + "ByHost/"
if FileManager.default.fileExists(atPath: byHostPath) {
logToConsole("ByHost exists")
var oldPlist = ""
// Try and find the old plist
do {
let directoryContents = try FileManager.default.contentsOfDirectory(atPath: byHostPath)
for directoryContent in directoryContents {
if directoryContent.starts(with: "com.JohnCoates.Aerial") {
// We found it !
oldPlist = directoryContent
break
}
}
} catch {
logToConsole(error.localizedDescription)
}
// Did we get it ?
if oldPlist != "" {
logToConsole("plist found " + oldPlist)
do {
try FileManager.default.copyItem(atPath: byHostPath + oldPlist, toPath: newBundleFile)
logToConsole("plist moved")
} catch {
logToConsole(error.localizedDescription)
}
}
}
}
}
// Mute me maybe
func maybeMuteSound() {
if !appMode && !underCompanion && PrefsAdvanced.muteGlobalSound{
Sound.output.isMuted = true
}
}
func maybeUnmuteSound() {
if !appMode && !underCompanion && PrefsAdvanced.muteGlobalSound {
Sound.output.isMuted = false
}
}
}
================================================
FILE: Aerial/Source/Models/AerialVideo.swift
================================================
//
// AerialVideo.swift
// Aerial
//
// Created by John Coates on 10/23/15.
// Copyright © 2015 John Coates. All rights reserved.
//
import Cocoa
import AVFoundation
enum Manifests: String {
case tvOS10 = "tvos10.json", tvOS11 = "tvos11.json", tvOS12 = "tvos12.json", tvOS13 = "tvos13.json", tvOS13Strings = "TVIdleScreenStrings13.bundle", customVideos = "customvideos.json"
}
final class AerialVideo: CustomStringConvertible, Equatable {
static func ==(lhs: AerialVideo, rhs: AerialVideo) -> Bool {
return lhs.id == rhs.id // TODO && lhs.url1080pHEVC == rhs.url1080pHEVC
}
let id: String
let name: String
let secondaryName: String
let type: String
let timeOfDay: String
let scene: SourceScene
var urls: [VideoFormat: String]
let source: Source
// var sources: [Manifests]
let poi: [String: String]
let communityPoi: [String: String]
var duration: Double
var arrayPosition = 1
var contentLength = 0
var contentLengthChecked = false
var isVertical: Bool
var isAvailableOffline: Bool {
return VideoCache.isAvailableOffline(video: self)
}
// MARK: - Public getter
var url: URL {
return getClosestAvailable(wanted: PrefsVideos.videoFormat)
}
// Returns the closest video we have in the manifests
private func getClosestAvailable(wanted: VideoFormat) -> URL {
if urls[wanted] != "" && urls[wanted] != nil {
return getURL(string: urls[wanted]!)
} else {
// Fallback
if urls.keys.contains(.v4KHEVC), urls[.v4KHEVC] != "" {
return getURL(string: urls[.v4KHEVC]!)
} else if urls.keys.contains(.v4KSDR240), urls[.v4KSDR240] != "" {
// macOS manifest only have those
return getURL(string: urls[.v4KSDR240]!)
} else if urls.keys.contains(.v1080pHEVC), urls[.v1080pHEVC] != "" {
return getURL(string: urls[.v1080pHEVC]!)
} else if urls.keys.contains(.v1080pH264), urls[.v1080pH264] != "" { // Last resort
return getURL(string: urls[.v1080pH264]!)
} else {
errorLog("getClosestAvailable failed back hard to 4KHDR")
// Something went very wrong if we are here
return getURL(string: urls[.v4KHDR]!)
}
}
}
private func getURL(string: String) -> URL {
if string.starts(with: "/") {
return URL(fileURLWithPath: string)
} else {
return URL(string: string)!
}
}
// swiftlint:disable cyclomatic_complexity
// MARK: - Init
init(id: String,
name: String,
secondaryName: String,
type: String,
timeOfDay: String,
scene: String,
urls: [VideoFormat: String],
source: Source,
poi: [String: String],
communityPoi: [String: String]
) {
self.id = id
// We override names for known space videos
if SourceInfo.seaVideos.contains(id) {
self.name = "Sea"
if secondaryName != "" {
self.secondaryName = secondaryName
} else {
self.secondaryName = name
}
} else if SourceInfo.spaceVideos.contains(id) {
self.name = "Space"
if secondaryName != "" {
self.secondaryName = secondaryName
} else {
self.secondaryName = name
}
} else {
// We align to the new jsons...
if name == "New York City" {
self.name = "New York"
} else {
self.name = name
}
self.secondaryName = secondaryName // We may have a secondary name from our merges too now !
}
self.type = type
// We override timeOfDay based on our own list
if let val = SourceInfo.timeInformation[id] {
self.timeOfDay = val
} else {
self.timeOfDay = timeOfDay
}
switch scene {
case "sea":
self.scene = .sea
case "space":
self.scene = .space
case "city":
self.scene = .city
case "countryside":
self.scene = .countryside
case "beach":
self.scene = .beach
default:
self.scene = .nature
}
self.urls = urls
self.source = source
// self.sources = [manifest]
self.poi = poi
self.communityPoi = communityPoi
// Default stuff, we double check those below
self.duration = 0
self.isVertical = false
updateDuration() // We need to have the video duration
}
func updateDuration() {
// We need to retrieve video duration from the cached files.
// This is a workaround as currently, the VideoCache infrastructure
// relies on AVAsset with an external URL all the time, even when
// working on a cached copy which makes the native duration retrieval fail
//
// And... we also check the orientation now too ;)
let fileManager = FileManager.default
if let duration = PrefsVideos.durationCache[self.id] {
// debugLog("Using cache duration : \(duration)")
self.duration = duration
return
}
// With custom videos, we may already store the local path
// If so, check it
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)
self.isVertical = asset.isVertical()
} else {
errorLog("Custom video is missing : \(self.url.path)")
self.duration = 0
}
} else {
// If not, iterate through all possible versions to see if any is cached
for format in VideoFormat.allCases {
// swiftlint:disable:next for_where
if urls[format] != "" {
let path = VideoList.instance.localPathFor(video: self)
if fileManager.fileExists(atPath: path) {
let asset = AVAsset(url: URL(fileURLWithPath: path))
self.duration = CMTimeGetSeconds(asset.duration)
// debugLog("Caching video duration")
PrefsVideos.durationCache[self.id] = self.duration
return
}
}
}
}
}
/// Check if a video has HDR files or not
func hasHDR() -> Bool {
if urls[.v1080pHDR] != "" || urls[.v4KHDR] != "" {
return true
} else {
return false
}
}
/// Check if what we are playing is HDR or not
func isHDR() -> Bool {
if urls[.v1080pHDR] != "" {
if url == URL(string: urls[.v1080pHDR]!) {
return true
}
}
if urls[.v4KHDR] != "" {
if url == URL(string: urls[.v4KHDR]!) {
return true
}
}
return false
}
func getCurrentFormat() -> String {
let wanted = PrefsVideos.videoFormat
if urls[wanted] != "" {
switch wanted {
case .v4KHDR:
return "4K HDR"
case .v1080pH264:
return "1080p"
case .v1080pHEVC:
return "1080p"
case .v1080pHDR:
return "1080p HDR"
case .v4KHEVC:
return "4K"
case .v4KSDR240:
return "4K 240FPS"
}
} else {
return getBestFormat()
}
}
private func getBestFormat() -> String {
if urls[.v4KHDR] != "" {
return "4K HDR"
} else if urls[.v4KHEVC] != "" {
return "4K"
} else {
return "1080p"
}
}
var description: String {
return """
id=\(id),
name=\(name),
type=\(type),
timeofDay=\(timeOfDay),
urls=\(urls)
"""
}
}
================================================
FILE: Aerial/Source/Models/Cache/AssetLoaderDelegate.swift
================================================
//
// AssetLoaderDelegate.swift
// Aerial
//
// This class adapted from https://github.com/renjithn/AVAssetResourceLoader-Video-Example
import Foundation
import AVKit
import AVFoundation
/// Returns an AVURLAsset that is automatically cached. If already cached
/// then returns the cached asset.
func cachedOrCachingAsset(_ URL: Foundation.URL) -> AVURLAsset {
let assetLoader = AssetLoaderDelegate(URL: URL)
let asset = AVURLAsset(url: assetLoader.URLWithCustomScheme)
let queue = DispatchQueue.main
asset.resourceLoader.setDelegate(assetLoader, queue: queue)
objc_setAssociatedObject(asset, "assetLoader", assetLoader, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN)
// debugLog("\(asset)")
return asset
}
final class AssetLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, VideoLoaderDelegate {
let URL: Foundation.URL
var videoLoaders: [VideoLoader] = []
let videoCache: VideoCache
var URLWithCustomScheme: Foundation.URL {
var components = URLComponents(url: URL, resolvingAgainstBaseURL: false)!
components.scheme = "streaming"
return components.url!
}
init(URL: Foundation.URL) {
self.URL = URL
videoCache = VideoCache(URL: URL)
}
deinit {
debugLog("AssetLoaderDelegate deinit")
}
// MARK: - Video Loader Delegate
func videoLoader(_ videoLoader: VideoLoader, receivedResponse response: URLResponse) {
videoCache.receivedContentLength(Int(response.expectedContentLength))
}
func videoLoader(_ videoLoader: VideoLoader, receivedData data: Data, forRange range: NSRange) {
videoCache.receivedData(data, atRange: range)
}
// MARK: - Asset Resource Loader Delegate
func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest) {
// debugLog("cancelled load request: \(loadingRequest)")
var remove: VideoLoader?
for loader in videoLoaders {
if loader.loadingRequest != loadingRequest {
continue
}
if let connection = loader.connection {
connection.cancel()
}
remove = loader
break
}
if let removeLoader = remove {
if let index = videoLoaders.firstIndex(of: removeLoader) {
videoLoaders.remove(at: index)
}
}
}
func resourceLoader(_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource
loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
// check if cache can fulfill this without a request
if videoCache.canFulfillLoadingRequest(loadingRequest) {
if videoCache.fulfillLoadingRequest(loadingRequest) {
// debugLog("fullfilling loading request")
return true
}
}
// assign request to VideoLoader
// debugLog("request to loader \(loadingRequest)")
let videoLoader = VideoLoader(url: URL, loadingRequest: loadingRequest, delegate: self)
videoLoaders.append(videoLoader)
return true
}
}
================================================
FILE: Aerial/Source/Models/Cache/Cache.swift
================================================
//
// Cache.swift
// Aerial
//
// Created by Guillaume Louel on 06/06/2020.
// Copyright © 2020 Guillaume Louel. All rights reserved.
//
import Cocoa
import CoreWLAN
import AVKit
/**
Aerial's new Cache management
Everything Cache related is moved here.
- Note: Where is our cache ?
Starting with 2.0, Aerial is putting its files in two locations :
- `~/Library/Application Support/Aerial/` : Contains manifests files and strings bundles for each source, in their own directory
- `~/Library/Application Support/Aerial/Cache/` : Contains (only) the cached videos
Users of version 1.x.x will automatically see their video files migrated to the correct location.
In Catalina, those paths live inside a user's container :
`~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Application Support/`
- Attention: Shared by multiple users writable locations are no longer possible, because sandboxing is awesome !
*/
// swiftlint:disable:next type_body_length
struct Cache {
/**
Returns the SSID of the Wi-Fi network the user is currently connected to.
- Note: Returns an empty string if not connected to Wi-Fi
*/
static var ssid: String {
return CWWiFiClient.shared().interface(withName: nil)?.ssid() ?? ""
}
static var processedSupportPath = ""
/**
Returns Aerial's Application Support path.
+ On macOS 10.14 and earlier : `~/Library/Application Support/Aerial/`
+ Starting with 10.15 : `~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Application Support/Aerial/`
- Note: Returns `/` on failure.
In some rare instances those system folders may not exist in the container, in this case Aerial can't work.
*/
static var supportPath: String {
// Dont' redo the thing all the time
if processedSupportPath != "" {
return processedSupportPath
}
var appPath = ""
if PrefsCache.overrideCache {
debugLog("Cache Override")
if !Aerial.helper.underCompanion, #available(macOS 12, *) {
if let bookmarkData = PrefsCache.supportBookmarkData {
do {
var isStale = false
let bookmarkUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
//debugLog("Bookmark is stale : \(isStale)")
appPath = bookmarkUrl.path
do {
let url = try NSURL.init(resolvingBookmarkData: bookmarkData, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: nil)
url.startAccessingSecurityScopedResource()
} catch let error as NSError {
errorLog("Bookmark Access Failed: \(error.description)")
}
} catch let error {
errorLog("Can't process bookmark \(error)")
}
} else {
errorLog("Can't find supportBookmarkData on macOS 12")
}
} else {
if let customPath = PrefsCache.supportPath {
debugLog("Trying \(customPath)")
if FileManager.default.fileExists(atPath: customPath) {
appPath = customPath
} else {
errorLog("Could not find your custom Caches path, reverting to default settings")
}
} else {
errorLog("Empty path, reverting to default settings")
}
}
}
// This is the normal(ish) path
if appPath == "" {
// This is the normal path via screensaver
if !Aerial.helper.underCompanion {
// Grab an array of Application Support paths
let appSupportPaths = NSSearchPathForDirectoriesInDomains(
.applicationSupportDirectory,
.userDomainMask,
true)
if appSupportPaths.isEmpty {
errorLog("FATAL : app support does not exist!")
return "/"
}
appPath = appSupportPaths[0]
} else {
// If we are underCompanion, we need to add the container on 10.15+
// Grab an array of Application Support paths
if #available(OSX 10.15, *) {
let libPaths = NSSearchPathForDirectoriesInDomains(
.libraryDirectory,
.userDomainMask,
true)
appPath = libPaths.first! + "/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Application Support/"
} else {
let appSupportPaths = NSSearchPathForDirectoriesInDomains(
.applicationSupportDirectory,
.userDomainMask,
true)
if appSupportPaths.isEmpty {
errorLog("FATAL : app support does not exist!")
return "/"
}
appPath = appSupportPaths[0]
}
}
}
let appSupportDirectory = appPath as NSString
if aerialFolderExists(at: appSupportDirectory) {
processedSupportPath = appSupportDirectory.appendingPathComponent("Aerial")
return processedSupportPath
} else {
debugLog("Creating app support directory...")
let asPath = appSupportDirectory.appendingPathComponent("Aerial")
let fileManager = FileManager.default
do {
try fileManager.createDirectory(atPath: asPath,
withIntermediateDirectories: true, attributes: nil)
processedSupportPath = asPath
return asPath
} catch let error {
errorLog("FATAL : Couldn't create app support directory in User directory: \(error)")
return "/"
}
}
}
/**
Returns Aerial's Caches path.
+ On macOS 10.14 and earlier : `~/Library/Application Support/Aerial/Cache/`
+ Starting with 10.15 : `~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Application Support/Aerial/Cache/`
- Note: Returns `/` on failure.
In some rare instances those system folders may not exist in the container, in this case Aerial can't work.
Also note that the shared `Caches` folder, `/Library/Caches/Aerial/`, is no longer user writable in Catalina and will be ignored.
*/
static var path: String = {
var path = ""
/*if PrefsCache.overrideCache {
if #available(macOS 12, *) {
if let bookmarkData = PrefsCache.cacheBookmarkData {
do {
var isStale = false
let bookmarkUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
debugLog("Bookmark is stale : \(isStale)")
debugLog("\(bookmarkUrl)")
path = bookmarkUrl.path
debugLog("\(path)")
} catch {
errorLog("Can't process bookmark")
}
} else {
errorLog("Can't find cacheBookmarkData on macOS 12")
}
} else {
if let customPath = Preferences.sharedInstance.customCacheDirectory {
debugLog("Trying \(customPath)")
if FileManager.default.fileExists(atPath: customPath) {
path = customPath
} else {
errorLog("Could not find your custom Caches path, reverting to default settings")
}
} else {
errorLog("Empty path, reverting to default settings")
}
}
if path == "" {
PrefsCache.overrideCache = false
path = Cache.supportPath.appending("/Cache")
}
} else {*/
path = Cache.supportPath.appending("/Cache")
// }
if FileManager.default.fileExists(atPath: path as String) {
return path
} else {
do {
try FileManager.default.createDirectory(atPath: path,
withIntermediateDirectories: true, attributes: nil)
return path
} catch let error {
errorLog("FATAL : Couldn't create Cache directory in Aerial's AppSupport directory: \(error)")
return "/"
}
}
}()
static var pathUrl: URL = {
if #available(macOS 12, *) {
if PrefsCache.overrideCache {
if let bookmarkData = PrefsCache.cacheBookmarkData {
do {
var isStale = false
let bookmarkUrl = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
debugLog("Bookmark is stale : \(isStale)")
debugLog("\(bookmarkUrl)")
return bookmarkUrl
} catch {
errorLog("Can't process bookmark")
}
}
}
}
return URL(fileURLWithPath: path)
}()
/**
Returns Aerial's thumbnail cache path, creating it if needed.
+ On macOS 10.14 and earlier : `~/Library/Application Support/Aerial/Thumbnails/`
+ Starting with 10.15 : `~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Application Support/Aerial/Thumbnails/`
- Note: Returns `/` on failure.
*/
static var thumbnailsPath: String = {
let path = Cache.supportPath.appending("/Thumbnails")
if FileManager.default.fileExists(atPath: path as String) {
return path
} else {
do {
try FileManager.default.createDirectory(atPath: path,
withIntermediateDirectories: true, attributes: nil)
return path
} catch let error {
errorLog("FATAL : Couldn't create Thumbnails directory in Aerial's AppSupport directory: \(error)")
return "/"
}
}
}()
/**
Returns Aerial's former cache path, if it exists.
+ On macOS 10.14 and earlier : `~/Library/Caches/Aerial/`
+ Starting with 10.15 : `~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver/Data/Library/Caches/Aerial/`
- Note: Returns `nil` on failure.
*/
static private var formerCachePath: String? = {
// Grab an array of Cache paths
let cacheSupportPaths = NSSearchPathForDirectoriesInDomains(
.cachesDirectory,
.userDomainMask,
true)
if cacheSupportPaths.isEmpty {
errorLog("Couldn't find Caches paths!")
return nil
}
let cacheSupportDirectory = cacheSupportPaths[0] as NSString
if aerialFolderExists(at: cacheSupportDirectory) {
return cacheSupportDirectory.appendingPathComponent("Aerial")
} else {
do {
debugLog("trying to create \(cacheSupportDirectory.appendingPathComponent("Aerial"))")
try FileManager.default.createDirectory(atPath: cacheSupportDirectory.appendingPathComponent("Aerial"),
withIntermediateDirectories: true, attributes: nil)
return path
} catch {
errorLog("Could not create Aerial's Caches path")
}
return nil
}
}()
// MARK: - Migration from Aerial 1.x.x to 2.x.x
/**
Migrate files from previous versions of Aerial to the 2.x.x structure.
- Moves the video files from Application Support to the `Application Support/Aerial/Cache` sub directory.
- Moves the video files from Caches to the `Application Support/Aerial/Cache` sub directory
*/
static func migrate() {
if !PrefsCache.overrideCache {
migrateAppSupport()
migrateOldCache()
}
}
/**
Migrate video that may be at the root of /Application Support/Aerial/
*/
static private func migrateAppSupport() {
let supportURL = URL(fileURLWithPath: supportPath as String)
do {
let directoryContent = try FileManager.default.contentsOfDirectory(at: supportURL, includingPropertiesForKeys: nil)
let videoURLs = directoryContent.filter { $0.pathExtension == "mov" }
if !videoURLs.isEmpty {
debugLog("Starting migration of your video files from Application Support to the /Cache subfolder")
for videoURL in videoURLs {
debugLog("moving \(videoURL.lastPathComponent)")
let newURL = URL(fileURLWithPath: path.appending("/\(videoURL.lastPathComponent)"))
try FileManager.default.moveItem(at: videoURL, to: newURL)
}
debugLog("Migration done.")
}
} catch {
errorLog("Error during migration, please report")
errorLog(error.localizedDescription)
}
}
/**
Migrate video that may be at the root of a user's /Caches/Aerial/
*/
static private func migrateOldCache() {
if let formerCachePath = formerCachePath {
do {
let formerCacheURL = URL(fileURLWithPath: formerCachePath as String)
let directoryContent = try FileManager.default.contentsOfDirectory(at: formerCacheURL, includingPropertiesForKeys: nil)
let videoURLs = directoryContent.filter { $0.pathExtension == "mov" }
if !videoURLs.isEmpty {
debugLog("Starting migration of your video files from Caches to the /Cache subfolder of Application Support")
for videoURL in videoURLs {
debugLog("moving \(videoURL.lastPathComponent)")
let newURL = URL(fileURLWithPath: path.appending("/\(videoURL.lastPathComponent)"))
try FileManager.default.moveItem(at: videoURL, to: newURL)
}
debugLog("Migration done.")
}
} catch {
errorLog("Error during migration, please report")
errorLog(error.localizedDescription)
}
}
}
// Remove files in bad format or outdated
static func removeCruft() {
// TODO: kind of a temporary safety
if VideoList.instance.videos.count > 90 {
// First let's look at the cache
// let pathURL = URL(fileURLWithPath: path)
do {
guard pathUrl.startAccessingSecurityScopedResource() else {
errorLog("removeCruft couldn't access scoped resouce")
return
}
let directoryContent = try FileManager.default.contentsOfDirectory(at: pathUrl, includingPropertiesForKeys: nil)
debugLog("count : \(directoryContent.count)")
let videoURLs = directoryContent.filter { $0.pathExtension == "mov" }
for video in videoURLs {
let filename = video.lastPathComponent
debugLog("\(filename)")
var found = false
// swiftlint:disable for_where
for candidate in VideoList.instance.videos {
if candidate.url.lastPathComponent == filename {
found = true
}
}
if !found {
debugLog("This file is not in the correct format or outdated, removing : \(video)")
try? FileManager.default.removeItem(at: video)
}
}
pathUrl.stopAccessingSecurityScopedResource()
} catch {
errorLog("Error during removing of videos in wrong format, please report")
errorLog(error.localizedDescription)
}
// Also remove uncached cruft
removeUncachedCruft()
}
}
static func removeUncachedCruft() {
for source in SourceList.foundSources where !source.isCachable && source.type != .local {
debugLog("Checking cruft in \(source.name)")
let pathURL = URL(fileURLWithPath: supportPath.appending("/" + source.name))
let unprocessed = source.getUnprocessedVideos()
debugLog(pathURL.absoluteString)
do {
let directoryContent = try FileManager.default.contentsOfDirectory(at: pathURL, includingPropertiesForKeys: nil)
let videoURLs = directoryContent.filter { $0.pathExtension == "mov" }
for video in videoURLs {
let filename = video.lastPathComponent
var found = false
// swiftlint:disable for_where
for candidate in unprocessed {
if candidate.url.lastPathComponent == filename {
found = true
}
}
if !found {
debugLog("This file is not in the correct format or outdated, removing : \(video)")
try? FileManager.default.removeItem(at: video)
}
}
} catch {
errorLog("Error during removal of videos in wrong format, please report")
errorLog(error.localizedDescription)
}
}
}
/// This clears the whole cache. User beware !
static func clearCache() {
let pathURL = URL(fileURLWithPath: path)
do {
let directoryContent = try FileManager.default.contentsOfDirectory(at: pathURL, includingPropertiesForKeys: nil)
let videoURLs = directoryContent.filter { $0.pathExtension == "mov" }
for video in videoURLs {
try? FileManager.default.removeItem(at: video)
}
} catch {
errorLog("Error during removal of videos in wrong format, please report")
errorLog(error.localizedDescription)
}
}
static func clearNonCacheableSources() {
// Then we need to look at individual online sources
// let onlineVideos = VideoList.instance.videos.filter({ !$0.source.isCachable })
for source in SourceList.foundSources.filter({!$0.isCachable}) {
let pathSource = URL(fileURLWithPath: supportPath).appendingPathComponent(source.name)
if FileManager.default.fileExists(atPath: pathSource.path) {
do {
let directoryContent = try FileManager.default.contentsOfDirectory(at: pathSource, includingPropertiesForKeys: nil)
let videoURLs = directoryContent.filter { $0.pathExtension == "mov" }
for video in videoURLs {
debugLog("Removing file : \(video)")
try? FileManager.default.removeItem(at: video)
}
} catch {
errorLog("Error during removing of videos in wrong format, please report")
errorLog(error.localizedDescription)
}
}
}
}
// MARK: - About the cache
/**
Is our cache full ?
*/
static func isFull() -> Bool {
return size() > PrefsCache.cacheLimit
}
/**
Do we still have a bit of free space (0.5 GB)
*/
static func hasSomeFreeSpace() -> Bool {
return size() < PrefsCache.cacheLimit - 0.5
}
/**
Returns the cache size in GB as a string (eg. 5.1 GB)
*/
static func sizeString() -> String {
let pathURL = Foundation.URL(fileURLWithPath: path)
// check if the url is a directory
if (try? pathURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true {
var folderSize = 0
(FileManager.default.enumerator(at: pathURL, includingPropertiesForKeys: nil)?.allObjects as? [URL])?.lazy.forEach {
folderSize += (try? $0.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0
}
let byteCountFormatter = ByteCountFormatter()
byteCountFormatter.allowedUnits = .useGB
byteCountFormatter.countStyle = .file
let sizeToDisplay = byteCountFormatter.string(for: folderSize) ?? ""
return sizeToDisplay
}
// In case it fails somehow
return "No cache found"
}
// MARK: - Helpers
/**
Does an `/Aerial/` subfolder exist inside the given path
- parameter at: Source path
- returns: Path existance as a Bool.
*/
private static func aerialFolderExists(at: NSString) -> Bool {
let aerialFolder = at.appendingPathComponent("Aerial")
return FileManager.default.fileExists(atPath: aerialFolder as String)
}
/**
Returns cache size in GB
*/
static func size() -> Double {
let pathURL = URL(fileURLWithPath: path)
// check if the url is a directory
if (try? pathURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true {
var folderSize = 0
(FileManager.default.enumerator(at: pathURL, includingPropertiesForKeys: nil)?.allObjects as? [URL])?.lazy.forEach {
folderSize += (try? $0.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0
}
return Double(folderSize) / 1000000000
}
return 0
}
static func getDirectorySize(directory: String) -> Double {
if FileManager.default.fileExists(atPath: directory) {
let pathURL = URL(fileURLWithPath: directory)
// check if the url is a directory
if (try? pathURL.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true {
var folderSize = 0
(FileManager.default.enumerator(at: pathURL, includingPropertiesForKeys: nil)?.allObjects as? [URL])?.lazy.forEach {
folderSize += (try? $0.resourceValues(forKeys: [.totalFileAllocatedSizeKey]))?.totalFileAllocatedSize ?? 0
}
return Double(folderSize) / 1000000000
}
return 0
} else {
return 0
}
}
static func packsSize() -> Double {
var totalSize: Double = 0
for source in SourceList.foundSources where !source.isCachable {
let sourcePath = supportPath.appending("/" + source.name)
totalSize += getDirectorySize(directory: sourcePath)
}
return totalSize
}
// swiftlint:disable line_length
// MARK: Networking restrictions for cache
/**
Can we download a file ?
Depending on user's settings, the cache may be full or the user may not be on a trusted network.
- Note: If a user disabled cache management (full manual mode), this will always be true.
- parameter action: A closure with the action to be accomplished should the conditions be met.
*/
static func ensureDownload(action: @escaping () -> Void) {
// Do we manage the cache or not ?
if PrefsCache.enableManagement {
// Check network first
if !canNetwork() {
if !Aerial.helper.showAlert(question: "You are on a restricted WiFi network",
text: "Your current settings restrict downloads when not connected to a trusted network. Do you wish to proceed?\n\nReminder: You can change this setting in the Cache tab.",
button1: "Download Anyway",
button2: "Cancel") {
return
}
}
// Then cache status
if isFull() {
let formatter = NumberFormatter()
formatter.locale = Locale.current // USA: Locale(identifier: "en_US")
formatter.numberStyle = .decimal
let result = formatter.string(from: NSNumber(value: PrefsCache.cacheLimit.rounded(toPlaces: 1)))!
//print(result) // -> US$9,999.99
if !Aerial.helper.showAlert(question: "Your cache is full",
text: "Your cache limit is currently set to \(result) GB, and currently contains \(Cache.sizeString()) of files.\n\n Do you want to proceed with the download anyway?\n\nYou can manually increase or decrease your cache size in Settings > Cache.",
button1: "Download Anyway",
button2: "Cancel") {
return
}
}
}
// If all is fine then proceed
action()
}
/**
Can we safely use network ?
Depending on user's settings, they may not be on a trusted network.
- Note: If a user disabled cache management (full manual mode), this will always be true.
*/
static func canNetwork() -> Bool {
if !PrefsCache.enableManagement {
return true
}
if PrefsCache.restrictOnWiFi {
// If we are not connected to WiFi we allow
if Cache.ssid == "" || PrefsCache.allowedNetworks.contains(ssid) {
return true
} else {
return false
}
} else {
return true
}
}
static func outdatedVideos() -> [AerialVideo] {
guard PrefsCache.enableManagement else {
return []
}
var cutoffDate = Date()
switch PrefsCache.cachePeriodicity {
case .daily:
cutoffDate = Calendar.current.date(byAdding: .day, value: -1, to: cutoffDate)!
case .weekly:
cutoffDate = Calendar.current.date(byAdding: .day, value: -7, to: cutoffDate)!
case .monthly:
cutoffDate = Calendar.current.date(byAdding: .month, value: -1, to: cutoffDate)!
case .never:
return []
}
// Get a list of cached videos that are not favorites, and are from a cacheable source (not a pack)
// Yes this is getting a bit complicated
var evictable: [Date: AerialVideo] = [:]
let currentlyCached = VideoList.instance.videos.filter({ $0.isAvailableOffline && $0.source.isCachable && !PrefsVideos.favorites.contains($0.id)})
for video in currentlyCached {
let path = VideoCache.cachePath(forVideo: video)!
// swiftlint:disable:next force_try
let attributes = try! FileManager.default.attributesOfItem(atPath: path)
let creationDate = attributes[.creationDate] as! Date
if creationDate < cutoffDate {
evictable[creationDate] = video
}
}
return evictable.sorted { $0.key < $1.key }.map({ $0.value })
}
// swiftlint:disable:next cyclomatic_complexity
static func freeCache() {
guard PrefsCache.enableManagement else {
return
}
// Step 1 : Delete hidden videos
debugLog("Looking for hidden videos to delete...")
for video in VideoList.instance.videos.filter({ PrefsVideos.hidden.contains($0.id) && $0.isAvailableOffline }) {
debugLog("Deleting hidden video \(video.secondaryName)")
do {
let path = VideoList.instance.localPathFor(video: video)
try FileManager.default.removeItem(atPath: path)
} catch {
errorLog("Could not delete video : \(video.secondaryName)")
}
}
// We may be good ?
if hasSomeFreeSpace() {
return
}
// Step 2 : Delete videos that are out of rotation
let evictables = outdatedVideos()
if evictables.isEmpty {
debugLog("No outdated videos, we won't delete anything")
return
}
debugLog("Looking for outdated videos that aren't in rotation (candidates : \(evictables.count)")
outerLoop: for video in evictables {
if VideoList.instance.currentRotation().contains(video) {
// Outdated but in rotation, so keep it !
// debugLog("outdated but in rotation \(video.secondaryName)")
} else {
debugLog("Removing outdated video not in rotation \(video.secondaryName)")
do {
try FileManager.default.removeItem(atPath: VideoCache.cachePath(forVideo: video)!)
} catch {
errorLog("Could not delete video : \(video.secondaryName)")
}
if hasSomeFreeSpace() {
// Removed enough
break outerLoop
}
}
}
// Are we there yeeeet ?
if hasSomeFreeSpace() {
return
}
debugLog("Looking for outdated videos that may still be in rotation (candidates : \(evictables.count)")
var currentVideos = [AerialVideo]()
for view in AerialView.instanciatedViews {
if let video = view.currentVideo {
currentVideos.append(video)
}
}
outerLoop2: for video in evictables {
if currentVideos.contains(video) {
debugLog("\(video.secondaryName) is currently playing, trying another")
} else {
debugLog("Removing outdated video that was in rotation \(video.secondaryName)")
do {
try FileManager.default.removeItem(atPath: VideoCache.cachePath(forVideo: video)!)
} catch {
errorLog("Could not delete video : \(video.secondaryName)")
}
if hasSomeFreeSpace() {
// Removed enough
break outerLoop2
}
}
}
// At this point we can't do more
}
static func fillOrRollCache() {
guard PrefsCache.enableManagement && canNetwork() else {
return
}
// Grab a *shuffled* list of uncached in rotation videos
let rotation = VideoList.instance.currentRotation().filter { !$0.isAvailableOffline }.shuffled()
if rotation.isEmpty {
debugLog("> Current playlist is already fully cached, no download/rotation needed")
return
}
debugLog("> Fill or roll cache")
// Do we have some space to download at least a video (by default .5 GB) ?
if !hasSomeFreeSpace() {
freeCache()
if !hasSomeFreeSpace() {
debugLog("No free space to reclaim currently.")
return
}
}
debugLog("Uncached videos in rotation : \(rotation.count)")
// We may be satisfied already
if rotation.isEmpty {
return
}
// Queue the first video on the list
debugLog("Queuing video : \(rotation.first!.secondaryName)")
VideoManager.sharedInstance.queueDownload(rotation.first!)
}
}
================================================
FILE: Aerial/Source/Models/Cache/PoiStringProvider.swift
================================================
//
// PoiStringProvider.swift
// Aerial
//
// Created by Guillaume Louel on 13/10/2018.
// Copyright © 2018 John Coates. All rights reserved.
//
import Foundation
final class CommunityStrings {
let id: String
let name: String
let poi: [String: String]
init(id: String, name: String, poi: [String: String]) {
self.id = id
self.name = name
self.poi = poi
}
}
final class PoiStringProvider {
static let sharedInstance = PoiStringProvider()
var loadedDescriptions = false
var loadedDescriptionsWasLocalized = false
var stringBundle: Bundle?
var stringDict: [String: String]?
var communityStrings = [CommunityStrings]()
var communityLanguage = ""
// MARK: - Lifecycle
init() {
debugLog("Poi Strings Provider initialized")
loadBundle()
loadCommunity()
}
// MARK: - Bundle management
private func getBundleLanguages() -> [String] {
// Might want to improve that...
// This is a static list of what's supposed to be in the bundle
// swiftlint:disable:next line_length
return ["de", "he", "en_AU", "ar", "el", "ja", "en", "uk", "es_419", "zh_CN", "es", "pt_BR", "da", "it", "sk", "pt_PT", "ms", "sv", "cs", "ko", "no", "hu", "zh_HK", "tr", "pl", "zh_TW", "en_GB", "vi", "ru", "fr_CA", "fr", "fi", "id", "nl", "th", "pt", "ro", "hr", "hi", "ca"]
}
private func loadBundle() {
// Idle string bundle
var bundlePath = Cache.supportPath.appending("/macOS 26")
if PrefsAdvanced.ciOverrideLanguage == "" {
debugLog("Preferred languages : \(Locale.preferredLanguages)")
let bestMatchedLanguage = Bundle.preferredLocalizations(from: getBundleLanguages(), forPreferences: Locale.preferredLanguages).first
if let match = bestMatchedLanguage {
debugLog("Best matched language : \(match)")
bundlePath.append(contentsOf: "/TVIdleScreenStrings.bundle/" + match + ".lproj/")
} else {
debugLog("No match, reverting to english")
// We load the bundle and let system grab the closest available preferred language
// This no longer works in Catalina and defaults back to english
// as legacyScreenSaver.appex, our new "mainbundle" is english only
bundlePath.append(contentsOf: "/TVIdleScreenStrings.bundle")
}
} else {
let bestMatchedLanguage = Bundle.preferredLocalizations(from: getBundleLanguages(), forPreferences: [PrefsAdvanced.ciOverrideLanguage]).first
if let match = bestMatchedLanguage {
debugLog("Best matched language : \(match)")
bundlePath.append(contentsOf: "/TVIdleScreenStrings.bundle/" + match + ".lproj/")
} else {
debugLog("No match, reverting to english")
// We load the bundle and let system grab the closest available preferred language
// This no longer works in Catalina and defaults back to english
// as legacyScreenSaver.appex, our new "mainbundle" is english only
bundlePath.append(contentsOf: "/TVIdleScreenStrings.bundle")
}
/*debugLog("Language overriden to \(String(describing: bestMatchedLanguage))")
// Or we load the overriden one
bundlePath.append(contentsOf: "/TVIdleScreenStrings.bundle/" + PrefsAdvanced.ciOverrideLanguage + ".lproj/")*/
}
if let sb = Bundle.init(path: bundlePath) {
let dictPath = Cache.supportPath.appending("/macOS 26/TVIdleScreenStrings.bundle/en.lproj/Localizable.nocache.strings")
// We could probably only work with that...
if let sd = NSDictionary(contentsOfFile: dictPath) as? [String: String] {
self.stringDict = sd
}
self.stringBundle = sb
self.loadedDescriptions = true
} else {
errorLog("TVIdleScreenStrings.bundle is missing, please remove entries.json in Cache folder to fix the issue")
}
}
// Make sure we have the correct bundle loaded
private func ensureLoadedBundle() -> Bool {
if loadedDescriptions {
return true
} else {
loadBundle()
return loadedDescriptions
}
}
// Return the Localized (or english) string for a key from the Strings Bundle
func getString(key: String, video: AerialVideo) -> String {
guard ensureLoadedBundle() else { return "" }
/*let preferences = Preferences.sharedInstance
let locale: NSLocale = NSLocale(localeIdentifier: Locale.preferredLanguages[0])
if #available(OSX 10.12, *) {
if preferences.localizeDescriptions && locale.languageCode != communityLanguage && preferences.ciOverrideLanguage == "" {
return stringBundle!.localizedString(forKey: key, value: "", table: "Localizable.nocache")
}
}*/
if !video.communityPoi.isEmpty {
return key // We directly store the string in the key
} else {
return stringBundle!.localizedString(forKey: key, value: "", table: "Localizable.nocache")
}
}
// Return all POIs for an id
func fetchExtraPoiForId(id: String) -> [String: String]? {
guard let stringDict = stringDict, ensureLoadedBundle() else { return [:] }
var found = [String: String]()
for key in stringDict.keys where key.starts(with: id) {
found[String(key.split(separator: "_").last!)] = key // FIXME: crash if key doesn't have "_"
}
return found
}
//
func getPoiKeys(video: AerialVideo) -> [String: String] {
if !video.communityPoi.isEmpty {
return video.communityPoi
} else {
return video.poi
}
}
// Do we have any keys, anywhere, for said video ?
func hasPoiKeys(video: AerialVideo) -> Bool {
return (!video.poi.isEmpty && loadedDescriptions) ||
(!video.communityPoi.isEmpty && !getPoiKeys(video: video).isEmpty)
}
func getLocalizedNameKey(key: String) -> String {
guard ensureLoadedBundle() else { return "" }
return stringBundle!.localizedString(forKey: key, value: "", table: "Localizable.nocache")
}
// MARK: - Community data
// siftlint:disable:next cyclomatic_complexity
private func getCommunityPathForLocale() -> String {
let locale: NSLocale = NSLocale(localeIdentifier: Locale.preferredLanguages[0])
// Do we have a language override ?
if PrefsAdvanced.ciOverrideLanguage != "" {
let path = Bundle(for: PoiStringProvider.self).path(forResource: PrefsAdvanced.ciOverrideLanguage, ofType: "json")
if path != nil {
let fileManager = FileManager.default
if fileManager.fileExists(atPath: path!) {
debugLog("Community Language overriden to : \(PrefsAdvanced.ciOverrideLanguage)")
communityLanguage = PrefsAdvanced.ciOverrideLanguage
return path!
}
}
}
if #available(OSX 10.12, *) {
// First we look in the Cache Folder for a locale directory
let cacheDirectory = Cache.supportPath
var cacheResourcesString = cacheDirectory
cacheResourcesString.append(contentsOf: "/locale")
let cacheUrl = URL(fileURLWithPath: cacheResourcesString)
if cacheUrl.hasDirectoryPath {
debugLog("Aerial cache directory contains /locale")
let cc = locale.languageCode
debugLog("Looking for \(cc).json")
let fileUrl = URL(fileURLWithPath: cacheResourcesString.appending("/\(cc).json"))
debugLog(fileUrl.absoluteString)
let fileManager = FileManager.default
if fileManager.fileExists(atPath: fileUrl.path) {
debugLog("Locale description found")
communityLanguage = cc
return fileUrl.path
} else {
debugLog("Locale description not found")
}
}
debugLog("Defaulting to bundle")
let cc = locale.languageCode
let path = Bundle(for: PoiStringProvider.self).path(forResource: cc, ofType: "json")
if path != nil {
let fileManager = FileManager.default
if fileManager.fileExists(atPath: path!) {
communityLanguage = cc
return path!
}
}
}
// Fallback to english in bundle
communityLanguage = "en"
return Bundle(for: PoiStringProvider.self).path(forResource: "en", ofType: "json")!
}
// Load the community strings
private func loadCommunity() {
let bundlePath = getCommunityPathForLocale()
debugLog("path : \(bundlePath)")
do {
let data = try Data(contentsOf: URL(fileURLWithPath: bundlePath), options: .mappedIfSafe)
let batches = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
guard let batch = batches as? NSDictionary else {
errorLog("Community : Encountered unexpected content type for batch, please report !")
return
}
for item in batch {
let id = item.key as! String
let name = (item.value as! NSDictionary)["name"] as! String
let poi = (item.value as! NSDictionary)["pointsOfInterest"] as? [String: String]
communityStrings.append(CommunityStrings(id: id, name: name, poi: poi ?? [:]))
}
} catch {
// handle error
errorLog("Community JSON ERROR : \(error)")
}
debugLog("Community JSON : \(communityStrings.count) entries")
}
func getCommunityName(id: String) -> String? {
return communityStrings.first(where: { $0.id == id }).map { $0.name }
}
func getCommunityPoi(id: String) -> [String: String] {
return communityStrings.first(where: { $0.id == id }).map { $0.poi } ?? [:]
}
// Helpers for the main popup
// swiftlint:disable:next cyclomatic_complexity
func getLanguagePosition() -> Int {
// The list is alphabetized based on their english name in the UI
switch PrefsAdvanced.ciOverrideLanguage {
case "ar": // Arabic
return 1
case "zh_CN": // Chinese Simplified
return 2
case "zh_TW": // Chinese Traditional
return 3
case "nl": // Dutch
return 4
case "en": // English
return 5
case "fr": // French
return 6
case "de": // German
return 7
case "he": // Hebrew
return 8
case "hu": // Hungarian
return 9
case "it": // Italian
return 10
case "ja": // Japanese
return 11
case "ko": // Korean
return 12
case "pl": // Polish
return 13
case "pt": // Portuguese
return 14
case "pt_BR": // Portuguese (Brazil)
return 15
case "ru": // Russian
return 16
case "es": // Spanish
return 17
case "sv": // Swedish
return 18
case "tl": // Tagalog
return 19
default: // This is the default, preferred language
return 0
}
}
// swiftlint:disable:next cyclomatic_complexity
func getLanguageStringFromPosition(pos: Int) -> String {
switch pos {
case 1:
return "ar"
case 2:
return "zh_CN"
case 3:
return "zh_TW"
case 4:
return "nl"
case 5:
return "en"
case 6:
return "fr"
case 7:
return "de"
case 8:
return "he"
case 9:
return "hu"
case 10:
return "it"
case 11:
return "ja"
case 12:
return "ko"
case 13:
return "pl"
case 14:
return "pt"
case 15:
return "pt_BR"
case 16:
return "ru"
case 17:
return "es"
case 18:
return "sv"
case 19:
return "tl"
default:
return ""
}
}
}
================================================
FILE: Aerial/Source/Models/Cache/Thumbnails.swift
================================================
//
// Thumbnails.swift
// Aerial
//
// Created by Guillaume Louel on 20/07/2020.
// Copyright © 2020 Guillaume Louel. All rights reserved.
//
import Cocoa
import AVKit
struct Thumbnails {
static let thumbSize = CGSize.init(width: 192, height: 108)
/**
Generate thumbnails for the videos
When a video is not available offline, it will also save a larger version
of the first frame of the video, to be used later in the UI as a placeholder
*/
/*
static func generateAllThumbnails(forVideos videos: [AerialVideo]) {
print("starting thumb generation")
for video in videos {
if cached(forVideo: video) == nil {
DispatchQueue.global().async {
generate(forVideo: video)
}
}
}
print("/thumb generation")
}*/
/**
Generate a thumbnail for the video
When a video is not available offline, it will also save a larger version
of the first frame of the video, to be used later in the UI as a placeholder
*/
static func generate(forVideo video: AerialVideo) {
do {
var asset: AVURLAsset
if video.isAvailableOffline {
// If a video is already cached, we may still need to use an online fetch as there's a bug
// with AVAssetImageGenerator and Dolby Vision files
if (PrefsVideos.videoFormat == .v1080pHDR || PrefsVideos.videoFormat == .v4KHDR) && video.source.name.starts(with: "tvOS") {
// We workaround here by grabbing online a 1080 SDR instead
let urlHEVC = video.urls[.v1080pHEVC]
let url264 = video.urls[.v1080pH264]
if urlHEVC != nil && urlHEVC != "" {
asset = AVURLAsset(url: URL(string: urlHEVC!)!)
} else if url264 != nil && url264 != "" {
asset = AVURLAsset(url: URL(string: url264!)!)
} else {
// Well...
asset = AVURLAsset(url: video.url)
}
} else {
// let path = VideoCache.cachePath(forVideo: video)!
let path = VideoList.instance.localPathFor(video: video)
asset = AVURLAsset(url: URL(fileURLWithPath: path))
}
} else {
if (PrefsVideos.videoFormat == .v1080pHDR || PrefsVideos.videoFormat == .v4KHDR) && video.source.name.starts(with: "tvOS") {
// We workaround here by grabbing online a 1080 SDR instead
let urlHEVC = video.urls[.v1080pHEVC]
let url264 = video.urls[.v1080pH264]
if urlHEVC != nil && urlHEVC != "" {
asset = AVURLAsset(url: URL(string: urlHEVC!)!)
} else if url264 != nil && url264 != "" {
asset = AVURLAsset(url: URL(string: url264!)!)
} else {
// Well...
asset = AVURLAsset(url: video.url)
}
} else {
asset = AVURLAsset(url: video.url)
}
}
// maybe that doesn't work great with HDR, or a Big Sur thing ?
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
let cgImage = try imageGenerator.copyCGImage(at: .zero,
actualTime: nil)
let saveURL = URL(fileURLWithPath: getPath(forVideo: video))
try writeImage(image: NSImage(cgImage: cgImage, size: thumbSize),
usingType: .png,
withSizeInPixels: thumbSize,
to: saveURL)
let largeURL = URL(fileURLWithPath: getLargePath(forVideo: video))
let fullSize = CGSize.init(width: cgImage.width, height: cgImage.height)
try writeImage(image: NSImage(cgImage: cgImage, size: fullSize),
usingType: .jpeg,
withSizeInPixels: fullSize,
to: largeURL)
} catch {
errorLog(error.localizedDescription)
}
}
static private func unscaledBitmapImageRep(forImage image: NSImage) -> NSBitmapImageRep {
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(image.size.width),
pixelsHigh: Int(image.size.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
) else {
preconditionFailure()
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
image.draw(at: .zero, from: .zero, operation: .sourceOver, fraction: 1.0)
NSGraphicsContext.restoreGraphicsState()
return rep
}
static private func writeImage(
image: NSImage,
usingType type: NSBitmapImageRep.FileType,
withSizeInPixels size: NSSize?,
to url: URL) throws {
if let size = size {
image.size = size
}
let rep = unscaledBitmapImageRep(forImage: image)
guard let data = rep.representation(using: type, properties: [.compressionFactor: 0.8]) else {
preconditionFailure()
}
try data.write(to: url)
}
/**
*/
static func cached(forVideo video: AerialVideo) -> NSImage? {
let candidateThumb = getPath(forVideo: video)
if FileManager.default.fileExists(atPath: candidateThumb) {
return NSImage(contentsOfFile: candidateThumb)
} else {
return nil
}
}
static private func getPath(forVideo video: AerialVideo) -> String {
return Cache.thumbnailsPath.appending("/"+video.id+".png")
}
static private func getLargePath(forVideo video: AerialVideo) -> String {
return Cache.thumbnailsPath.appending("/"+video.id+"-large.jpg")
}
static func get(forVideo video: AerialVideo, _ completion: @escaping ((_ image: NSImage?) -> Void)) {
if let thumb = cached(forVideo: video) {
completion(thumb)
} else if video.isAvailableOffline {
DispatchQueue.global().async {
generate(forVideo: video)
// Completion on the main queue
DispatchQueue.main.async {
completion(cached(forVideo: video))
}
}
} else {
if Cache.canNetwork() {
DispatchQueue.global().async {
generate(forVideo: video)
DispatchQueue.main.async {
completion(cached(forVideo: video))
}
}
} else {
completion(nil)
}
}
}
static func getLarge(forVideo video: AerialVideo, _ completion: @escaping ((_ image: NSImage?) -> Void)) {
let candidateLarge = getLargePath(forVideo: video)
if FileManager.default.fileExists(atPath: candidateLarge) {
return completion(NSImage(contentsOfFile: candidateLarge))
} else {
// This may happen in a race...
return completion(nil)
}
}
static func getLargeURL(forVideo video: AerialVideo) -> URL? {
let candidateLarge = getLargePath(forVideo: video)
if FileManager.default.fileExists(atPath: candidateLarge) {
return URL(fileURLWithPath: candidateLarge)
} else {
// This may happen in a race...
return nil
}
}
}
================================================
FILE: Aerial/Source/Models/Cache/TimeMachine.swift
================================================
//
// TimeMachine.swift
// Aerial
//
// Created by Guillaume Louel on 13/09/2020.
// Copyright © 2020 Guillaume Louel. All rights reserved.
//
import Foundation
struct TimeMachine {
static func isExcluded() -> Bool {
let process: Process = Process()
debugLog("Checking if our path \(Cache.path) is excluded in Time Machine")
process.launchPath = "/usr/bin/tmutil"
process.arguments = ["isexcluded", Cache.path]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
debugLog(output ?? "No output from tmutil")
process.waitUntilExit()
// Now parse output if any, we're looking for "Excluded" string
// Tested on 10.14/10.16, should be "safe" even if it doesn't work on other oses
if let output = output {
return output.contains("Excluded")
} else {
return false
}
}
static func exclude() {
let process: Process = Process()
debugLog("Trying to exclude our path \(Cache.path) in Time Machine")
process.launchPath = "/usr/bin/tmutil"
process.arguments = ["addexclusion", Cache.path]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
debugLog(output ?? "No output from tmutil")
process.waitUntilExit()
}
static func reinclude() {
let process: Process = Process()
debugLog("Trying to reinclude our path \(Cache.path) in Time Machine")
process.launchPath = "/usr/bin/tmutil"
process.arguments = ["removeexclusion", Cache.path]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)
debugLog(output ?? "No output from tmutil")
process.waitUntilExit()
}
}
================================================
FILE: Aerial/Source/Models/Cache/VideoCache.swift
================================================
//
// VideoCache.swift
// Aerial
//
// Created by John Coates on 10/29/15.
// Copyright © 2015 John Coates. All rights reserved.
//
import Foundation
import AVFoundation
import ScreenSaver
final class VideoCache {
var videoData: Data
var mutableVideoData: NSMutableData?
var loading: Bool
var loadedRanges: [NSRange] = []
let URL: URL
static var computedCacheDirectory: String?
static var computedAppSupportDirectory: String?
// MARK: - Application Support directory
static var appSupportDirectory: String? {
// TODO : temporary for the migration
return Cache.supportPath
//
// // We only process this once if successful
// if computedAppSupportDirectory != nil {
// return computedAppSupportDirectory
// }
//
// var foundDirectory: String?
//
// let appSupportPaths = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory,
// .userDomainMask,
// true)
//
// if appSupportPaths.isEmpty {
// errorLog("Couldn't find appSupport paths!")
// return nil
// }
// let appSupportDirectory = appSupportPaths[0] as NSString
// if aerialFolderExists(at: appSupportDirectory) {
// debugLog("app support exists")
// foundDirectory = appSupportDirectory.appendingPathComponent("Aerial")
// } else {
// debugLog("creating app support directory")
// // We create in user appSupport which may be containairized
// // so ~/Library/Application Support/ on pre 10.15
// // or ~/Library/Containers/com.apple.ScreenSaver.Engine.legacyScreenSaver
// // /Data/Library/Application Support/
// foundDirectory = appSupportDirectory.appendingPathComponent("Aerial")
//
// let fileManager = FileManager.default
// if fileManager.fileExists(atPath: foundDirectory!) == false {
// do {
// try fileManager.createDirectory(atPath: foundDirectory!,
// withIntermediateDirectories: false, attributes: nil)
// } catch let error {
// errorLog("Couldn't create appSupport directory in User directory: \(error)")
// errorLog("FATAL : There's nothing more we can do at this point")
// return nil
// }
// }
// }
//
// // Cache the computed value
// computedAppSupportDirectory = foundDirectory
// return computedAppSupportDirectory
}
// MARK: - User Video cache directory
static var cacheDirectory: String? {
// TODO : Until refactor is done
return Cache.path
// // We only process this once if successful
// if computedCacheDirectory != nil {
// return computedCacheDirectory
// }
//
// var cacheDirectory: String?
// let preferences = Preferences.sharedInstance
//
// if let customCacheDirectory = preferences.customCacheDirectory {
// // We may have overriden the cache directory, but it may no longer exist !
// if FileManager.default.fileExists(atPath: customCacheDirectory as String) {
// debugLog("Using exiting customCacheDirectory : \(customCacheDirectory)")
// cacheDirectory = customCacheDirectory
// } /*else {
// // If it doesn't we need to reset that preference
// preferences.customCacheDirectory = nil
// }*/
// }
//
// if cacheDirectory == nil {
// let userCachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory,
// .userDomainMask,
// true)
// let localCachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory,
// .localDomainMask,
// true)
//
// if !localCachePaths.isEmpty {
// let localCacheDirectory = localCachePaths[0] as NSString
// if aerialFolderExists(at: localCacheDirectory) {
// debugLog("Using existing local cache /Library/Caches/Aerial")
// cacheDirectory = localCacheDirectory.appendingPathComponent("Aerial")
// }
// }
//
// if !userCachePaths.isEmpty && cacheDirectory == nil {
// let userCacheDirectory = userCachePaths[0] as NSString
//
// if aerialFolderExists(at: userCacheDirectory) {
// debugLog("Using existing user cache ~/Library/Caches/Aerial")
// cacheDirectory = userCacheDirectory.appendingPathComponent("Aerial")
// } else {
// debugLog("No local or user cache exists, using ~/Library/Application Support/Aerial")
// cacheDirectory = appSupportDirectory
// }
// }
// }
//
// // Cache the computed value
// computedCacheDirectory = cacheDirectory
//
// debugLog("cache to be used : \(String(describing: cacheDirectory))")
// return cacheDirectory
}
// MARK: - Helpers
static func aerialFolderExists(at: NSString) -> Bool {
let aerialFolder = at.appendingPathComponent("Aerial")
if FileManager.default.fileExists(atPath: aerialFolder as String) {
return true
} else {
return false
}
}
// Is a video cached (in either appSupport or cache)
static func isAvailableOffline(video: AerialVideo) -> Bool {
let fileManager = FileManager.default
if video.url.absoluteString.starts(with: "file") {
return fileManager.fileExists(atPath: video.url.path)
} else {
if video.source.isCachable {
guard let videoCachePath = cachePath(forVideo: video) else {
errorLog("Couldn't get video cache path!")
return false
}
if fileManager.fileExists(atPath: videoCachePath) {
do {
let fileUrl = Foundation.URL(fileURLWithPath: videoCachePath)
let resourceValues = try fileUrl.resourceValues(forKeys: [.fileSizeKey])
let fileSize = resourceValues.fileSize!
// Make sure the file is big enough to be a video and not some network failure
if fileSize > 500000 {
return true
}
} catch {
errorLog("File check throw")
}
}
return false
} else {
let path = sourcePathFor(video)
do {
let fileUrl = Foundation.URL(fileURLWithPath: path)
let resourceValues = try fileUrl.resourceValues(forKeys: [.fileSizeKey])
let fileSize = resourceValues.fileSize!
// Make sure the file is big enough to be a video and not some network failure
if fileSize > 500000 {
return true
}
} catch {
errorLog("File check throw")
}
return false
}
}
}
static func moveToTrash(video: AerialVideo) {
let videoCachePath = VideoList.instance.localPathFor(video: video)
guard videoCachePath != "" else {
errorLog("Couldn't get video cache path to trash!")
return
}
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)
}
static func cachePath(forFilename filename: String) -> String? {
guard let cacheDirectory = VideoCache.cacheDirectory, let appSupportDirectory = VideoCache.appSupportDirectory else {
return nil
}
// Let's compute both
let appSupportPath = appSupportDirectory as NSString
let appSupportVideoPath = appSupportPath.appendingPathComponent(filename)
let cacheDirectoryPath = cacheDirectory as NSString
let cacheVideoPath = cacheDirectoryPath.appendingPathComponent(filename)
// If the file exists in either dir, returns that
if FileManager.default.fileExists(atPath: appSupportVideoPath as String) {
return appSupportVideoPath
} else if FileManager.default.fileExists(atPath: cacheVideoPath as String) {
return cacheVideoPath
} else {
// File doesn't have to exist, this is also used to compute the save location
// So now with Catalina, considering containerization we need to use appSupport
// Pre catalina we return cache folder instead (no change for users)
return cacheVideoPath
/*
if #available(OSX 10.15, *) {
return appSupportVideoPath
} else {
return cacheVideoPath
}
*/
}
}
static func sourcePathFor(_ video: AerialVideo) -> String {
if video.url.isFileURL {
return video.url.path
} else {
return Cache.supportPath.appending("/" + video.source.name + "/" + video.url.lastPathComponent)
}
}
static func sourcePathFor(_ filename: String, video: AerialVideo) -> String {
return Cache.supportPath.appending("/" + video.source.name + "/" + filename)
}
init(URL: Foundation.URL) {
debugLog("initvideocache")
videoData = Data()
loading = true
self.URL = URL
loadCachedVideoIfPossible()
}
// MARK: - Data Adding
func receivedContentLength(_ contentLength: Int) {
if loading == false {
return
}
if mutableVideoData != nil {
return
}
mutableVideoData = NSMutableData(length: contentLength)
videoData = mutableVideoData! as Data
}
func receivedData(_ data: Data, atRange range: NSRange) {
guard let mutableVideoData = mutableVideoData else {
errorLog("Received data without having mutable video data")
return
}
mutableVideoData.replaceBytes(in: range, withBytes: (data as NSData).bytes)
loadedRanges.append(range)
consolidateLoadedRanges()
// debugLog("loaded ranges: \(loadedRanges)")
if loadedRanges.count == 1 {
let range = loadedRanges[0]
// debugLog("checking if range \(range) matches length \(mutableVideoData.length)")
if range.location == 0 && range.length == mutableVideoData.length {
// done loading, save
saveCachedVideo()
}
}
}
// MARK: - Save / Load Cache
var videoCachePath: String? {
let filename = URL.lastPathComponent
if let video = VideoList.instance.videoForFilename(filename) {
if !video.source.isCachable {
return VideoCache.sourcePathFor(filename, video: video)
}
}
return VideoCache.cachePath(forFilename: filename)
}
func saveCachedVideo() {
let fileManager = FileManager.default
guard let videoCachePath = videoCachePath else {
errorLog("Couldn't save cache file")
return
}
guard fileManager.fileExists(atPath: videoCachePath) == false else {
errorLog("Cache file \(videoCachePath) already exists.")
return
}
loading = false
if mutableVideoData == nil {
errorLog("Missing video data for save.")
return
}
do {
try mutableVideoData!.write(toFile: videoCachePath, options: .atomicWrite)
mutableVideoData = nil
videoData.removeAll()
} catch let error {
errorLog("Couldn't write cache file: \(error)")
}
}
func loadCachedVideoIfPossible() {
let fileManager = FileManager.default
guard let videoCachePath = self.videoCachePath else {
errorLog("Couldn't load cache file.")
return
}
if fileManager.fileExists(atPath: videoCachePath) == false {
return
}
guard let videoData = try? Data(contentsOf: Foundation.URL(fileURLWithPath: videoCachePath)) else {
errorLog("NSData failed to load cache file \(videoCachePath)")
return
}
self.videoData = videoData
loading = false
debugLog("cached video file with length: \(self.videoData.count)")
}
// MARK: - Fulfilling cache
func fulfillLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
guard let dataRequest = loadingRequest.dataRequest else {
errorLog("Missing data request for \(loadingRequest)")
return false
}
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = Int(dataRequest.requestedLength)
let data = videoData.subdata(in: requestedOffset..<requestedOffset + requestedLength)
DispatchQueue.main.async { () -> Void in
self.fillInContentInformation(loadingRequest)
dataRequest.respond(with: data)
loadingRequest.finishLoading()
}
return true
}
func fillInContentInformation(_ loadingRequest: AVAssetResourceLoadingRequest) {
guard let contentInformationRequest = loadingRequest.contentInformationRequest else {
return
}
let contentType: String = kUTTypeQuickTimeMovie as String
contentInformationRequest.isByteRangeAccessSupported = true
contentInformationRequest.contentType = contentType
contentInformationRequest.contentLength = Int64(videoData.count)
}
// MARK: - Cache Checking
// Whether the video cache can fulfill this request
func canFulfillLoadingRequest(_ loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
if !loading {
return true
}
guard let dataRequest = loadingRequest.dataRequest else {
errorLog("Missing data request for \(loadingRequest)")
return false
}
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = Int(dataRequest.requestedLength)
let requestedEnd = requestedOffset + requestedLength
for range in loadedRanges {
let rangeStart = range.location
let rangeEnd = range.location + range.length
if requestedOffset >= rangeStart && requestedEnd <= rangeEnd {
return true
}
}
return false
}
// MARK: - Consolidating
func consolidateLoadedRanges() {
var consolidatedRanges: [NSRange] = []
let sortedRanges = loadedRanges.sorted { $0.location < $1.location }
var previousRange: NSRange?
var lastIndex: Int?
for range in sortedRanges {
if let lastRange: NSRange = previousRange {
let lastRangeEndOffset = lastRange.location + lastRange.length
// check if range can be consumed by lastRange
// or if they're at each other's edges if it can be merged
if lastRangeEndOffset >= range.location {
let endOffset = range.location + range.length
// check if this range's end offset is larger than lastRange's
if endOffset > lastRangeEndOffset {
previousRange!.length = endOffset - lastRange.location
// replace lastRange in array with new value
consolidatedRanges.remove(at: lastIndex!)
consolidatedRanges.append(previousRange!)
continue
} else {
// skip adding this to the array, previous range is already bigger
// debugLog("skipping add of \(range), previous: \(previousRange)")
continue
}
}
}
lastIndex = consolidatedRanges.count
previousRange = range
consolidatedRanges.append(range)
}
loadedRanges = consolidatedRanges
}
}
================================================
FILE: Aerial/Source/Models/Cache/VideoDownload.swift
================================================
//
// VideoDownload.swift
// Aerial
//
// Created by John Coates on 10/31/15.
// Copyright © 2015 John Coates. All rights reserved.
//
import Foundation
protocol VideoDownloadDelegate: NSObjectProtocol {
func videoDownload(_ videoDownload: VideoDownload,
finished success: Bool, errorMessage: String?)
// bytes received for bytes/second count
func videoDownload(_ videoDownload: VideoDownload,
receivedBytes: Int, progress: Float)
}
final class VideoDownloadStream {
var connection: NSURLConnection
var response: URLResponse?
var contentInformationRequest: Bool = false
var downloadOffset = 0
init(connection: NSURLConnection) {
self.connection = connection
}
deinit {
connection.cancel()
}
}
final class VideoDownload: NSObject, NSURLConnectionDataDelegate {
var streams: [VideoDownloadStream] = []
weak var delegate: VideoDownloadDelegate!
let queue = DispatchQueue.main
let video: AerialVideo
var data: NSMutableData?
var downloadedData: Int = 0
var contentLength: Int = 0
init(video: AerialVideo, delegate: VideoDownloadDelegate) {
self.video = video
self.delegate = delegate
}
deinit {
//print("deinit VideoDownload")
}
func startDownload() {
// first start content information download
startDownloadForContentInformation()
}
// download a couple bytes to get the content length
func startDownloadForContentInformation() {
startDownloadForChunk(nil)
}
func cancel() {
for stream in streams {
stream.connection.cancel()
}
infoLog("Video download cancelled")
delegate.videoDownload(self, finished: false, errorMessage: nil)
}
func startDownloadForChunk(_ chunk: NSRange?) {
let request = NSMutableURLRequest(url: video.url as URL)
request.cachePolicy = NSURLRequest.CachePolicy.reloadIgnoringCacheData
if let requestedRange = chunk {
// set Range: bytes=startOffset-endOffset
let requestRangeField = "bytes=\(requestedRange.location)-\(requestedRange.location+requestedRange.length)"
request.setValue(requestRangeField, forHTTPHeaderField: "Range")
debugLog("Starting download for range \(requestRangeField)")
}
guard let connection = NSURLConnection(request: request as URLRequest,
delegate: self, startImmediately: false) else {
errorLog("Error creating connection with request: \(request)")
return
}
let stream = VideoDownloadStream(connection: connection)
if chunk == nil {
debugLog("Starting download for content information")
stream.contentInformationRequest = true
}
connection.start()
streams.append(stream)
}
func streamForConnection(_ connection: NSURLConnection) -> VideoDownloadStream? {
return streams.first(where: { $0.connection == connection })
}
func createStreamsBasedOnContentLength(_ contentLength: Int) {
self.contentLength = contentLength
// remove content length request stream
streams.removeFirst()
data = NSMutableData(length: contentLength)
// start 4 streams for maximum throughput
let streamCount = 1 // TODO
let pace = 0.2; // pace stream creation a little bit
let streamPiece = Int(floor(Double(contentLength) / Double(streamCount)))
debugLog("Starting \(streamCount) streams with \(streamPiece) each, for content length of \(contentLength)")
var offset = 0
var delayTime: Double = 0
// let queue = DispatchQueue.main
for idx in 0 ..< streamCount {
let isLastStream: Bool = idx == (streamCount - 1)
var range = NSRange(location: offset, length: streamPiece)
if isLastStream {
let bytesLeft = contentLength - offset
range = NSRange(location: offset, length: bytesLeft)
debugLog("last stream range: \(range)")
}
let delay = DispatchTime.now() + Double(Int64(delayTime * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
queue.asyncAfter(deadline: delay) {
self.startDownloadForChunk(range)
}
// increase delay
delayTime += pace
// increase offset
offset += range.length
}
}
func receiveDataForStream(_ stream: VideoDownloadStream, receivedData: Data) {
guard let videoData = self.data else {
errorLog("Aerial error: video data missing!")
return
}
let replaceRange = NSRange(location: stream.downloadOffset,
length: receivedData.count)
videoData.replaceBytes(in: replaceRange, withBytes: (receivedData as NSData).bytes)
stream.downloadOffset += receivedData.count
}
func finishedDownload() {
var tentativeCachePath: String?
if video.source.isCachable {
tentativeCachePath = VideoCache.cachePath(forVideo: video)
} else {
tentativeCachePath = VideoCache.sourcePathFor(video)
}
guard let videoCachePath = tentativeCachePath else {
errorLog("Couldn't save video because couldn't get cache path\n")
failedDownload("Couldn't get cache path")
return
}
if self.data == nil {
errorLog("video data missing!\n")
return
}
var success: Bool = true
var errorMessage: String?
do {
try self.data!.write(toFile: videoCachePath, options: .atomicWrite)
self.data = nil
} catch let error {
errorLog("Couldn't write cache file: \(error)")
errorMessage = "Couldn't write to cache file!"
success = false
}
// notify delegate
delegate.videoDownload(self, finished: success, errorMessage: errorMessage)
}
func failedDownload(_ errorMessage: String) {
delegate.videoDownload(self, finished: false, errorMessage: errorMessage)
}
// MARK: - NSURLConnection Delegate
func connection(_ connection: NSURLConnection, didReceive response: URLResponse) {
guard let stream = streamForConnection(connection) else {
errorLog("No matching stream for connection: \(connection) with response: \(response)")
return
}
stream.response = response as? HTTPURLResponse
if stream.contentInformationRequest == true {
connection.cancel()
queue.async(execute: { () -> Void in
let contentLength = Int(response.expectedContentLength)
self.createStreamsBasedOnContentLength(contentLength)
})
return
} else {
// get real offset of receiving data
queue.async(execute: { () -> Void in
guard let offset = self.startOffsetFromResponse(response) else {
errorLog("Couldn't get start offset from response: \(response)")
return
}
stream.downloadOffset = offset
})
}
}
func connection(_ connection: NSURLConnection, didReceive data: Data) {
guard let delegate = self.delegate else {
return
}
queue.async { () -> Void in
self.downloadedData += data.count
let progress: Float = Float(self.downloadedData) / Float(self.contentLength)
delegate.videoDownload(self, receivedBytes: data.count, progress: progress)
guard let stream = self.streamForConnection(connection) else {
errorLog("No matching stream for connection: \(connection)")
return
}
self.receiveDataForStream(stream, receivedData: data)
}
}
func connectionDidFinishLoading(_ connection: NSURLConnection) {
queue.async { () -> Void in
debugLog("connectionDidFinishLoading")
guard let stream = self.streamForConnection(connection) else {
errorLog("No matching stream for connection: \(connection)")
return
}
guard let index = self.streams.firstIndex(where: { $0.connection == stream.connection }) else {
errorLog("Couldn't find index of stream for finished connection!")
return
}
self.streams.remove(at: index)
if self.streams.isEmpty {
debugLog("Finished downloading!")
self.finishedDownload()
}
}
}
func connection(_ connection: NSURLConnection, didFailWithError error: Error) {
errorLog("Couldn't download video: \(error.localizedDescription)")
queue.async { () -> Void in
self.failedDownload("Connection fail: \(error.localizedDescription)")
}
}
func connection(_ connection: NSURLConnection, didReceive challenge: URLAuthenticationChallenge) {
errorLog("Didn't expect authentication challenge while downloading videos!")
queue.async { () -> Void in
self.failedDownload("Connection fail: Received authentication request!")
}
}
// MARK: - Range
func startOffsetFromResponse(_ response: URLResponse) -> Int? {
// get range response
var regex: NSRegularExpression!
do {
// Check to see if the server returned a valid byte-range
regex = try NSRegularExpression(pattern: "bytes (\\d+)-\\d+/\\d+",
options: NSRegularExpression.Options.caseInsensitive)
} catch let error as NSError {
errorLog("Error formatting regex: \(error)")
return nil
}
let httpResponse = response as! HTTPURLResponse
guard let contentRange = httpResponse.allHeaderFields["Content-Range"] as? NSString
gitextract_s15f9pm6/ ├── .codeclimate.yml ├── .gitignore ├── .gitmodules ├── .swiftlint.yml ├── .travis.yml ├── Aerial/ │ ├── App/ │ │ ├── AppDelegate.swift │ │ └── Resources/ │ │ ├── Assets.xcassets/ │ │ │ ├── Accent Color.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ └── FirstPanelBackground.colorset/ │ │ │ └── Contents.json │ │ ├── Base.lproj/ │ │ │ └── MainMenu.xib │ │ └── Info.plist │ └── Source/ │ ├── Controllers/ │ │ └── CustomVideoController.swift │ ├── Header.h │ ├── Models/ │ │ ├── API/ │ │ │ ├── Forecast.swift │ │ │ ├── GeoCoding.swift │ │ │ ├── OneCall.swift │ │ │ └── OpenWeather.swift │ │ ├── Aerial.swift │ │ ├── AerialVideo.swift │ │ ├── Cache/ │ │ │ ├── AssetLoaderDelegate.swift │ │ │ ├── Cache.swift │ │ │ ├── PoiStringProvider.swift │ │ │ ├── Thumbnails.swift │ │ │ ├── TimeMachine.swift │ │ │ ├── VideoCache.swift │ │ │ ├── VideoDownload.swift │ │ │ ├── VideoLoader.swift │ │ │ └── VideoManager.swift │ │ ├── CompanionBridge.swift │ │ ├── CustomVideoFolders+helpers.swift │ │ ├── CustomVideoFolders.swift │ │ ├── Downloads/ │ │ │ ├── AsynchronousOperation.swift │ │ │ ├── DownloadManager.swift │ │ │ └── FileHelpers.swift │ │ ├── ErrorLog.swift │ │ ├── Extensions/ │ │ │ ├── AVAsset+VideoOrientation.swift │ │ │ ├── AVPlayerItem+vibrance.swift │ │ │ ├── AVPlayerViewExtension.swift │ │ │ ├── DispatchQueue+Extension.swift │ │ │ ├── NSButton+icons.swift │ │ │ ├── NSImage+trim.swift │ │ │ └── NSMenuItem+icons.swift │ │ ├── Hardware/ │ │ │ ├── Battery.swift │ │ │ ├── Brightness.swift │ │ │ ├── DarkMode.swift │ │ │ ├── DisplayDetection.swift │ │ │ ├── HardwareDetection.swift │ │ │ ├── ISSoundAdditions/ │ │ │ │ ├── Sound.swift │ │ │ │ ├── SoundOutputManager+Goodies.swift │ │ │ │ ├── SoundOutputManager+Properties.swift │ │ │ │ └── SoundOutputManager.swift │ │ │ └── NightShift.swift │ │ ├── Locations.swift │ │ ├── ManifestLoader.swift │ │ ├── Music/ │ │ │ └── Music.swift │ │ ├── PlaybackSpeed.swift │ │ ├── Prefs/ │ │ │ ├── PrefsAdvanced.swift │ │ │ ├── PrefsCache.swift │ │ │ ├── PrefsDisplays.swift │ │ │ ├── PrefsInfo.swift │ │ │ ├── PrefsTime.swift │ │ │ ├── PrefsUpdates.swift │ │ │ └── PrefsVideos.swift │ │ ├── SeededGenerator.swift │ │ ├── Sources/ │ │ │ ├── Sidebar.swift │ │ │ ├── Source.swift │ │ │ ├── SourceInfo.swift │ │ │ ├── SourceList.swift │ │ │ └── VideoList.swift │ │ └── Time/ │ │ ├── Aerial-Bridging-Header.h │ │ ├── IOBridge.m │ │ ├── Solar.swift │ │ └── TimeManagement.swift │ └── Views/ │ ├── AerialPlayerItem.swift │ ├── AerialView+Brightness.swift │ ├── AerialView+Player.swift │ ├── AerialView.swift │ ├── Layers/ │ │ ├── AnimatableLayer.swift │ │ ├── AnimationLayer.swift │ │ ├── AnimationTextLayer.swift │ │ ├── BatteryIconLayer.swift │ │ ├── ClockLayer.swift │ │ ├── CountdownLayer.swift │ │ ├── DateLayer.swift │ │ ├── DownloadIndicatorLayer.swift │ │ ├── LayerManager.swift │ │ ├── LayerOffsets.swift │ │ ├── LocationLayer.swift │ │ ├── MessageLayer.swift │ │ ├── Music/ │ │ │ ├── ArtworkLayer.swift │ │ │ └── MusicLayer.swift │ │ ├── TimerLayer.swift │ │ └── Weather/ │ │ ├── ConditionLayer.swift │ │ ├── ConditionSymbolLayer.swift │ │ ├── ForecastLayer.swift │ │ ├── WeatherLayer.swift │ │ ├── WindDirectionLayer.swift │ │ └── YahooLogoLayer.swift │ ├── MainUI/ │ │ ├── AspectFillNSImageView.swift │ │ ├── NowPlayingCollectionView.swift │ │ ├── ShadowTextFieldCell.swift │ │ ├── SidebarOutlineView.swift │ │ └── VideoCellView.swift │ ├── PrefPanel/ │ │ ├── CheckCellView.swift │ │ ├── DisplayView.swift │ │ ├── InfoBatteryView.swift │ │ ├── InfoClockView.swift │ │ ├── InfoCommonView.swift │ │ ├── InfoContainerView.swift │ │ ├── InfoCountdownView.swift │ │ ├── InfoDateView.swift │ │ ├── InfoLocationView.swift │ │ ├── InfoMessageView.swift │ │ ├── InfoMusicView.swift │ │ ├── InfoSettingsTableSource.swift │ │ ├── InfoSettingsView.swift │ │ ├── InfoTableSource.swift │ │ ├── InfoTimerView.swift │ │ ├── InfoWeatherView.swift │ │ ├── VideoHeaderView.swift │ │ └── VideoViewItem.swift │ └── Sources/ │ ├── ActionCellView.swift │ ├── CheckboxCellView.swift │ ├── DescriptionCellView.swift │ └── SourceOutlineView.swift ├── Aerial copy-Info.plist ├── Aerial.xcodeproj/ │ ├── project.pbxproj │ ├── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings │ └── xcshareddata/ │ └── xcschemes/ │ └── Aerial.xcscheme ├── AerialApp copy-Info.plist ├── Documentation/ │ ├── AutoUpdates.md │ ├── ChangeLog.md │ ├── Contribute.md │ ├── CustomVideos.md │ ├── FAQs.md │ ├── HardwareDecoding.md │ ├── Installation.md │ ├── MoreVideos.md │ ├── OfflineMode.md │ ├── README.md │ └── Troubleshooting.md ├── LICENSE ├── Makefile ├── Podfile ├── Readme.md ├── Resources/ │ ├── Community/ │ │ ├── Readme.md │ │ ├── ar.json │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── he.json │ │ ├── hu.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── missingvideos.json │ │ ├── nl.json │ │ ├── pl.json │ │ ├── pt.json │ │ ├── pt_BR.json │ │ ├── ru.json │ │ ├── sv.json │ │ ├── tl.json │ │ ├── zh_CN.json │ │ └── zh_TW.json │ ├── MainUI/ │ │ ├── First time setup/ │ │ │ ├── CacheSetupViewController.swift │ │ │ ├── CacheSetupViewController.xib │ │ │ ├── FirstSetupWindowController.swift │ │ │ ├── FirstSetupWindowController.xib │ │ │ ├── NextViewController.swift │ │ │ ├── NextViewController.xib │ │ │ ├── RecapViewController.swift │ │ │ ├── RecapViewController.xib │ │ │ ├── TimeSetupViewController.swift │ │ │ ├── TimeSetupViewController.xib │ │ │ ├── VideoFormatViewController.swift │ │ │ ├── VideoFormatViewController.xib │ │ │ ├── WelcomeViewController.swift │ │ │ └── WelcomeViewController.xib │ │ ├── Infos panels/ │ │ │ ├── CreditsViewController.swift │ │ │ ├── CreditsViewController.xib │ │ │ ├── HelpViewController.swift │ │ │ ├── HelpViewController.xib │ │ │ ├── InfoViewController.swift │ │ │ └── InfoViewController.xib │ │ ├── PanelWindowController.swift │ │ ├── PanelWindowController.xib │ │ ├── Settings panels/ │ │ │ ├── AdvancedViewController.swift │ │ │ ├── AdvancedViewController.xib │ │ │ ├── BrightnessViewController.swift │ │ │ ├── BrightnessViewController.xib │ │ │ ├── CacheViewController.swift │ │ │ ├── CacheViewController.xib │ │ │ ├── Collection View/ │ │ │ │ ├── PlayingCollectionViewItem.swift │ │ │ │ └── PlayingCollectionViewItem.xib │ │ │ ├── CompanionCacheViewController.swift │ │ │ ├── CompanionCacheViewController.xib │ │ │ ├── DisplaysViewController.swift │ │ │ ├── DisplaysViewController.xib │ │ │ ├── FiltersViewController.swift │ │ │ ├── FiltersViewController.xib │ │ │ ├── NowPlayingViewController.swift │ │ │ ├── NowPlayingViewController.xib │ │ │ ├── OverlaysViewController.swift │ │ │ ├── OverlaysViewController.xib │ │ │ ├── SourcesViewController.swift │ │ │ ├── SourcesViewController.xib │ │ │ ├── TimeViewController.swift │ │ │ └── TimeViewController.xib │ │ ├── SidebarViewController.swift │ │ ├── SidebarViewController.xib │ │ ├── VideosViewController.swift │ │ └── VideosViewController.xib │ └── Old stuff/ │ ├── CustomVideos.xib │ └── Info.plist ├── Tests/ │ ├── Info.plist │ └── PreferencesTests.swift ├── appcast.xml ├── beta-appcast.xml ├── issue_template.md └── lokalise.example.cfg
Condensed preview — 225 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (2,520K chars).
[
{
"path": ".codeclimate.yml",
"chars": 91,
"preview": "engines:\n tailor:\n enabled: true\n\nratings:\n paths:\n - \"**.swift\"\n exclude_paths: []\n"
},
{
"path": ".gitignore",
"chars": 160,
"preview": "lokalise.cfg\n.DS_Store\nxcuserdata/\ncompile/\nbuild/\nDerivedData/\n*.xccheckout\nrelease/\ndebug.plist\nExamples/debug.html\nAe"
},
{
"path": ".gitmodules",
"chars": 142,
"preview": "[submodule \"Extern/OAuthSwift\"]\n\tpath = Extern/OAuthSwift\n\turl = https://github.com/OAuthSwift/OAuthSwift.git\n\tbranch = "
},
{
"path": ".swiftlint.yml",
"chars": 910,
"preview": "disabled_rules:\n # Allow force-casting (e.g. `x as! UICollectionViewCell`).\n # We may want to re-enable and address th"
},
{
"path": ".travis.yml",
"chars": 211,
"preview": "language: objective-c\nosx_image: xcode11.2\nbefore_install:\n - pod repo update\nafter_success:\n - bash <(curl -s https:/"
},
{
"path": "Aerial/App/AppDelegate.swift",
"chars": 640,
"preview": "//\n// AppDelegate.swift\n// Aerial Test\n//\n// Created by John Coates on 10/23/15.\n// Copyright © 2015 John Coates. Al"
},
{
"path": "Aerial/App/Resources/Assets.xcassets/Accent Color.colorset/Contents.json",
"chars": 695,
"preview": "{\n \"colors\" : [\n {\n \"color\" : {\n \"color-space\" : \"srgb\",\n \"components\" : {\n \"alpha\" : \"1"
},
{
"path": "Aerial/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json",
"chars": 965,
"preview": "{\n \"images\" : [\n {\n \"idiom\" : \"mac\",\n \"scale\" : \"1x\",\n \"size\" : \"16x16\"\n },\n {\n \"idiom\" : "
},
{
"path": "Aerial/App/Resources/Assets.xcassets/Contents.json",
"chars": 63,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
},
{
"path": "Aerial/App/Resources/Assets.xcassets/FirstPanelBackground.colorset/Contents.json",
"chars": 686,
"preview": "{\n \"colors\" : [\n {\n \"color\" : {\n \"color-space\" : \"srgb\",\n \"components\" : {\n \"alpha\" : \"1"
},
{
"path": "Aerial/App/Resources/Base.lproj/MainMenu.xib",
"chars": 23922,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Aerial/App/Resources/Info.plist",
"chars": 1811,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Aerial/Source/Controllers/CustomVideoController.swift",
"chars": 23234,
"preview": "//\n// CustomVideoController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 21/05/2019.\n// Copyright © 2019 John"
},
{
"path": "Aerial/Source/Header.h",
"chars": 197,
"preview": "//\n// Header.h\n// Aerial\n//\n// Created by Guillaume Louel on 26/07/2023.\n// Copyright © 2023 Guillaume Louel. All ri"
},
{
"path": "Aerial/Source/Models/API/Forecast.swift",
"chars": 8270,
"preview": "//\n// Forecast.swift\n// Aerial\n//\n// Created by Guillaume Louel on 26/04/2021.\n// Copyright © 2021 Guillaume Louel. "
},
{
"path": "Aerial/Source/Models/API/GeoCoding.swift",
"chars": 4018,
"preview": "//\n// GeoCoding.swift\n// Aerial\n//\n// Created by Guillaume Louel on 22/04/2021.\n// Copyright © 2021 Guillaume Louel."
},
{
"path": "Aerial/Source/Models/API/OneCall.swift",
"chars": 9527,
"preview": "//\n// OWOneCall.swift\n// Aerial\n//\n// Created by Guillaume Louel on 23/03/2021.\n// Copyright © 2021 Guillaume Louel."
},
{
"path": "Aerial/Source/Models/API/OpenWeather.swift",
"chars": 10754,
"preview": "//\n// OpenWeather.swift\n// Aerial\n//\n// Created by Guillaume Louel on 04/03/2021.\n// Copyright © 2021 Guillaume Loue"
},
{
"path": "Aerial/Source/Models/Aerial.swift",
"chars": 15318,
"preview": "//\n// Aerial.swift\n// Aerial\n//\n// Contains some common helpers used throughout the code\n//\n// Created by Guillaume "
},
{
"path": "Aerial/Source/Models/AerialVideo.swift",
"chars": 8347,
"preview": "//\n// AerialVideo.swift\n// Aerial\n//\n// Created by John Coates on 10/23/15.\n// Copyright © 2015 John Coates. All rig"
},
{
"path": "Aerial/Source/Models/Cache/AssetLoaderDelegate.swift",
"chars": 3232,
"preview": "//\n// AssetLoaderDelegate.swift\n// Aerial\n//\n\n// This class adapted from https://github.com/renjithn/AVAssetResourceLo"
},
{
"path": "Aerial/Source/Models/Cache/Cache.swift",
"chars": 32202,
"preview": "//\n// Cache.swift\n// Aerial\n//\n// Created by Guillaume Louel on 06/06/2020.\n// Copyright © 2020 Guillaume Louel. All"
},
{
"path": "Aerial/Source/Models/Cache/PoiStringProvider.swift",
"chars": 12755,
"preview": "//\n// PoiStringProvider.swift\n// Aerial\n//\n// Created by Guillaume Louel on 13/10/2018.\n// Copyright © 2018 John Coa"
},
{
"path": "Aerial/Source/Models/Cache/Thumbnails.swift",
"chars": 8013,
"preview": "//\n// Thumbnails.swift\n// Aerial\n//\n// Created by Guillaume Louel on 20/07/2020.\n// Copyright © 2020 Guillaume Louel"
},
{
"path": "Aerial/Source/Models/Cache/TimeMachine.swift",
"chars": 2293,
"preview": "//\n// TimeMachine.swift\n// Aerial\n//\n// Created by Guillaume Louel on 13/09/2020.\n// Copyright © 2020 Guillaume Loue"
},
{
"path": "Aerial/Source/Models/Cache/VideoCache.swift",
"chars": 17403,
"preview": "//\n// VideoCache.swift\n// Aerial\n//\n// Created by John Coates on 10/29/15.\n// Copyright © 2015 John Coates. All righ"
},
{
"path": "Aerial/Source/Models/Cache/VideoDownload.swift",
"chars": 10922,
"preview": "//\n// VideoDownload.swift\n// Aerial\n//\n// Created by John Coates on 10/31/15.\n// Copyright © 2015 John Coates. All r"
},
{
"path": "Aerial/Source/Models/Cache/VideoLoader.swift",
"chars": 8953,
"preview": "//\n// VideoLoader.swift\n// Aerial\n//\n// Created by John Coates on 10/29/15.\n// Copyright © 2015 John Coates. All rig"
},
{
"path": "Aerial/Source/Models/Cache/VideoManager.swift",
"chars": 6209,
"preview": "//\n// VideoManager.swift\n// Aerial\n//\n// Created by Guillaume Louel on 08/10/2018.\n// Copyright © 2018 John Coates. "
},
{
"path": "Aerial/Source/Models/CompanionBridge.swift",
"chars": 2591,
"preview": "//\n// CompanionBridge.swift\n// Aerial\n//\n// Created by Guillaume Louel on 09/10/2023.\n// Copyright © 2023 Guillaume "
},
{
"path": "Aerial/Source/Models/CustomVideoFolders+helpers.swift",
"chars": 1272,
"preview": "//\n// CustomVideoFolders+helpers.swift\n// Aerial\n//\n// Created by Guillaume Louel on 24/05/2019.\n// Copyright © 2019"
},
{
"path": "Aerial/Source/Models/CustomVideoFolders.swift",
"chars": 5805,
"preview": "// This file was generated from JSON Schema using quicktype, do not modify it directly.\n// To parse the JSON, add this f"
},
{
"path": "Aerial/Source/Models/Downloads/AsynchronousOperation.swift",
"chars": 2936,
"preview": "//\n// AsynchronousOperation.swift\n// Aerial\n//\n// Created by Guillaume Louel on 03/10/2018.\n// Copyright © 2018 John"
},
{
"path": "Aerial/Source/Models/Downloads/DownloadManager.swift",
"chars": 6867,
"preview": "//\n// DownloadManager.swift\n// Aerial\n//\n// Created by Guillaume Louel on 03/10/2018.\n// Copyright © 2018 John Coate"
},
{
"path": "Aerial/Source/Models/Downloads/FileHelpers.swift",
"chars": 1109,
"preview": "//\n// FileHelpers.swift\n// Aerial\n//\n// Created by Guillaume Louel on 08/07/2020.\n// Copyright © 2020 Guillaume Loue"
},
{
"path": "Aerial/Source/Models/ErrorLog.swift",
"chars": 5637,
"preview": "//\n// ErrorLog.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/10/2018.\n// Copyright © 2018 John Coates. All "
},
{
"path": "Aerial/Source/Models/Extensions/AVAsset+VideoOrientation.swift",
"chars": 1987,
"preview": "//\n// AVAsset+VideoOrientation.swift\n// AVAsset+VideoOrientation\n//\n// Created by Guillaume Louel on 26/08/2021.\n// "
},
{
"path": "Aerial/Source/Models/Extensions/AVPlayerItem+vibrance.swift",
"chars": 2010,
"preview": "//\n// AVPlayerItem+vibrance.swift\n// Aerial\n//\n// Created by Guillaume Louel on 02/08/2020.\n// Copyright © 2020 Guil"
},
{
"path": "Aerial/Source/Models/Extensions/AVPlayerViewExtension.swift",
"chars": 628,
"preview": "//\n// AVPlayerViewExtension.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/10/2018.\n// Copyright © 2018 John"
},
{
"path": "Aerial/Source/Models/Extensions/DispatchQueue+Extension.swift",
"chars": 616,
"preview": "//\n// DispatchQueue+Extension.swift\n// Aerial\n//\n// Created by Guillaume Louel on 28/12/2021.\n// Copyright © 2021 Gu"
},
{
"path": "Aerial/Source/Models/Extensions/NSButton+icons.swift",
"chars": 481,
"preview": "//\n// NSButton+icons.swift\n// Aerial\n//\n// Created by Guillaume Louel on 01/08/2020.\n// Copyright © 2020 Guillaume L"
},
{
"path": "Aerial/Source/Models/Extensions/NSImage+trim.swift",
"chars": 3055,
"preview": "//\n// NSImage+trim.swift\n// Aerial\n//\n// Created by Guillaume Louel on 23/04/2020.\n// Copyright © 2020 Guillaume Lou"
},
{
"path": "Aerial/Source/Models/Extensions/NSMenuItem+icons.swift",
"chars": 329,
"preview": "//\n// NSMenuItem+icons.swift\n// Aerial\n//\n// Created by Guillaume Louel on 30/07/2020.\n// Copyright © 2020 Guillaume"
},
{
"path": "Aerial/Source/Models/Hardware/Battery.swift",
"chars": 1945,
"preview": "//\n// Battery.swift\n// Aerial\n//\n// Created by Guillaume Louel on 06/12/2019.\n// Copyright © 2019 John Coates. All r"
},
{
"path": "Aerial/Source/Models/Hardware/Brightness.swift",
"chars": 883,
"preview": "//\n// Brightness.swift\n// Aerial\n//\n// Created by Guillaume Louel on 18/12/2019.\n// Copyright © 2019 Guillaume Louel"
},
{
"path": "Aerial/Source/Models/Hardware/DarkMode.swift",
"chars": 735,
"preview": "//\n// DarkMode.swift\n// Aerial\n//\n// Created by Guillaume Louel on 19/12/2019.\n// Copyright © 2019 Guillaume Louel. "
},
{
"path": "Aerial/Source/Models/Hardware/DisplayDetection.swift",
"chars": 18431,
"preview": "//\n// DisplayDetection.swift\n// Aerial\n//\n// Created by Guillaume Louel on 09/05/2019.\n// Copyright © 2019 John Coat"
},
{
"path": "Aerial/Source/Models/Hardware/HardwareDetection.swift",
"chars": 6296,
"preview": "//\n// HardwareDetection.swift\n// Aerial\n//\n// Created by Guillaume Louel on 03/06/2019.\n// Copyright © 2019 John Coa"
},
{
"path": "Aerial/Source/Models/Hardware/ISSoundAdditions/Sound.swift",
"chars": 475,
"preview": "//\n// SoundOutputManager.swift\n//\n//\n// Created by Alessio Moiso on 08.03.22.\n//\n\n/// Entry point to access and modify"
},
{
"path": "Aerial/Source/Models/Hardware/ISSoundAdditions/SoundOutputManager+Goodies.swift",
"chars": 1977,
"preview": "//\n// File.swift\n// \n//\n// Created by Alessio Moiso on 09.03.22.\n//\n\npublic extension Sound.SoundOutputManager {\n //"
},
{
"path": "Aerial/Source/Models/Hardware/ISSoundAdditions/SoundOutputManager+Properties.swift",
"chars": 1277,
"preview": "//\n// SoundOutputManager+Properties.swift\n// \n//\n// Created by Alessio Moiso on 09.03.22.\n//\nimport CoreAudio\n\npublic"
},
{
"path": "Aerial/Source/Models/Hardware/ISSoundAdditions/SoundOutputManager.swift",
"chars": 8361,
"preview": "//\n// SoundOutputManager.swift\n// \n//\n// Created by Alessio Moiso on 08.03.22.\n//\n\nimport CoreAudio\nimport AudioToolb"
},
{
"path": "Aerial/Source/Models/Hardware/NightShift.swift",
"chars": 4194,
"preview": "//\n// NightShift.swift\n// Aerial\n//\n// Created by Guillaume Louel on 19/12/2019.\n// Copyright © 2019 Guillaume Louel"
},
{
"path": "Aerial/Source/Models/Locations.swift",
"chars": 5768,
"preview": "//\n// Location.swift\n// Aerial\n//\n// Created by Guillaume Louel on 24/05/2020.\n// Copyright © 2020 Guillaume Louel. "
},
{
"path": "Aerial/Source/Models/ManifestLoader.swift",
"chars": 50209,
"preview": "//\n// ManifestLoader.swift\n// Aerial\n// WARNING : This is the old deprecated stuff\n//\n// Created by John Coates on 1"
},
{
"path": "Aerial/Source/Models/Music/Music.swift",
"chars": 6028,
"preview": "//\n// Music.swift\n// Aerial\n//\n// Created by Guillaume Louel on 29/06/2021.\n// Copyright © 2021 Guillaume Louel. All"
},
{
"path": "Aerial/Source/Models/PlaybackSpeed.swift",
"chars": 713,
"preview": "//\n// PlaybackSpeed.swift\n// Aerial\n//\n// Created by Guillaume Louel on 08/07/2021.\n// Copyright © 2021 Guillaume Lo"
},
{
"path": "Aerial/Source/Models/Prefs/PrefsAdvanced.swift",
"chars": 1213,
"preview": "//\n// PrefsAdvanced.swift\n// Aerial\n//\n// Created by Guillaume Louel on 23/04/2020.\n// Copyright © 2020 Guillaume Lo"
},
{
"path": "Aerial/Source/Models/Prefs/PrefsCache.swift",
"chars": 2184,
"preview": "//\n// PrefsCache.swift\n// Aerial\n//\n// Created by Guillaume Louel on 03/06/2020.\n// Copyright © 2020 Guillaume Louel"
},
{
"path": "Aerial/Source/Models/Prefs/PrefsDisplays.swift",
"chars": 4614,
"preview": "//\n// PrefsDisplays.swift\n// Aerial\n//\n// Created by Guillaume Louel on 21/01/2020.\n// Copyright © 2020 Guillaume Lo"
},
{
"path": "Aerial/Source/Models/Prefs/PrefsInfo.swift",
"chars": 25806,
"preview": "//\n// PrefsInfo.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/12/2019.\n// Copyright © 2019 Guillaume Louel."
},
{
"path": "Aerial/Source/Models/Prefs/PrefsTime.swift",
"chars": 2565,
"preview": "//\n// PrefsTime.swift\n// Aerial\n//\n// Created by Guillaume Louel on 21/01/2020.\n// Copyright © 2020 Guillaume Louel."
},
{
"path": "Aerial/Source/Models/Prefs/PrefsUpdates.swift",
"chars": 1213,
"preview": "//\n// PrefsUpdates.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/02/2020.\n// Copyright © 2020 Guillaume Lou"
},
{
"path": "Aerial/Source/Models/Prefs/PrefsVideos.swift",
"chars": 6818,
"preview": "//\n// PrefsVideos.swift\n// Aerial\n//\n// Created by Guillaume Louel on 23/12/2019.\n// Copyright © 2019 Guillaume Loue"
},
{
"path": "Aerial/Source/Models/SeededGenerator.swift",
"chars": 885,
"preview": "//\n// SeededGenerator.swift\n// Aerial\n//\n// Created by Guillaume Louel on 21/05/2019.\n// Copyright © 2019 John Coate"
},
{
"path": "Aerial/Source/Models/Sources/Sidebar.swift",
"chars": 6059,
"preview": "//\n// Sidebar.swift\n// Aerial\n//\n// Created by Guillaume Louel on 15/07/2020.\n// Copyright © 2020 Guillaume Louel. A"
},
{
"path": "Aerial/Source/Models/Sources/Source.swift",
"chars": 22336,
"preview": "//\n// Source.swift\n// Aerial\n//\n// Created by Guillaume Louel on 01/07/2020.\n// Copyright © 2020 Guillaume Louel. Al"
},
{
"path": "Aerial/Source/Models/Sources/SourceInfo.swift",
"chars": 23369,
"preview": "//\n// SourceInfo.swift\n// Aerial\n//\n// Created by Guillaume Louel on 08/07/2020.\n// Copyright © 2020 Guillaume Louel"
},
{
"path": "Aerial/Source/Models/Sources/SourceList.swift",
"chars": 22844,
"preview": "//\n// SourceList.swift\n// Aerial\n//\n// Created by Guillaume Louel on 01/07/2020.\n// Copyright © 2020 Guillaume Louel"
},
{
"path": "Aerial/Source/Models/Sources/VideoList.swift",
"chars": 22824,
"preview": "//\n// VideoList.swift\n// Aerial\n//\n// Created by Guillaume Louel on 08/07/2020.\n// Copyright © 2020 Guillaume Louel."
},
{
"path": "Aerial/Source/Models/Time/Aerial-Bridging-Header.h",
"chars": 227,
"preview": "//\n// Use this file to import your target's public headers that you would like to expose to Swift.\n//\n\n// We need this "
},
{
"path": "Aerial/Source/Models/Time/IOBridge.m",
"chars": 258,
"preview": "//\n// IOBridge.m\n// Aerial\n//\n// Created by Guillaume Louel on 26/10/2018.\n// Copyright © 2018 John Coates. All righ"
},
{
"path": "Aerial/Source/Models/Time/Solar.swift",
"chars": 12016,
"preview": "//\n// Solar.swift\n// SolarExample\n//\n// Created by Chris Howell on 16/01/2016.\n// Copyright © 2016 Chris Howell. All"
},
{
"path": "Aerial/Source/Models/Time/TimeManagement.swift",
"chars": 14045,
"preview": "//\n// TimeManagement.swift\n// Aerial\n//\n// Created by Guillaume Louel on 05/10/2018.\n// Copyright © 2018 John Coates"
},
{
"path": "Aerial/Source/Views/AerialPlayerItem.swift",
"chars": 469,
"preview": "//\n// AerialPlayerItem.swift\n// Aerial\n//\n// Created by Ethan Setnik on 11/22/17.\n// Copyright © 2017 John Coates. A"
},
{
"path": "Aerial/Source/Views/AerialView+Brightness.swift",
"chars": 2975,
"preview": "//\n// AerialView+Brightness.swift\n// Aerial\n//\n// Created by Guillaume Louel on 06/12/2019.\n// Copyright © 2019 Guil"
},
{
"path": "Aerial/Source/Views/AerialView+Player.swift",
"chars": 7958,
"preview": "//\n// AerialView+Player.swift\n// Aerial\n//\n// Created by Guillaume Louel on 06/12/2019.\n// Copyright © 2019 Guillaum"
},
{
"path": "Aerial/Source/Views/AerialView.swift",
"chars": 36481,
"preview": "//\n// AerialView.swift\n// Aerial\n//\n// Created by John Coates on 10/22/15.\n// Copyright © 2015 John Coates. All righ"
},
{
"path": "Aerial/Source/Views/Layers/AnimatableLayer.swift",
"chars": 9763,
"preview": "//\n// AnimatableLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/04/2020.\n// Copyright © 2020 Guillaume "
},
{
"path": "Aerial/Source/Views/Layers/AnimationLayer.swift",
"chars": 2549,
"preview": "//\n// AnimationLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/04/2020.\n// Copyright © 2020 Guillaume L"
},
{
"path": "Aerial/Source/Views/Layers/AnimationTextLayer.swift",
"chars": 6305,
"preview": "//\n// AnimationLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 11/12/2019.\n// Copyright © 2019 Guillaume L"
},
{
"path": "Aerial/Source/Views/Layers/BatteryIconLayer.swift",
"chars": 4922,
"preview": "//\n// BatteryIconLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 01/05/2020.\n// Copyright © 2020 Guillaume"
},
{
"path": "Aerial/Source/Views/Layers/ClockLayer.swift",
"chars": 2983,
"preview": "//\n// ClockLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 12/12/2019.\n// Copyright © 2019 Guillaume Louel"
},
{
"path": "Aerial/Source/Views/Layers/CountdownLayer.swift",
"chars": 4273,
"preview": "//\n// CountdownLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 13/02/2020.\n// Copyright © 2020 Guillaume L"
},
{
"path": "Aerial/Source/Views/Layers/DateLayer.swift",
"chars": 3405,
"preview": "//\n// DateLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 23/03/2020.\n// Copyright © 2020 Guillaume Louel."
},
{
"path": "Aerial/Source/Views/Layers/DownloadIndicatorLayer.swift",
"chars": 2228,
"preview": "//\n// UpdatesLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 11/02/2020.\n// Copyright © 2020 Guillaume Lou"
},
{
"path": "Aerial/Source/Views/Layers/LayerManager.swift",
"chars": 8762,
"preview": "//\n// LayerManager.swift\n// Aerial\n//\n// Created by Guillaume Louel on 12/12/2019.\n// Copyright © 2019 Guillaume Lou"
},
{
"path": "Aerial/Source/Views/Layers/LayerOffsets.swift",
"chars": 823,
"preview": "//\n// LayerOffsets.swift\n// Aerial\n//\n// Created by Guillaume Louel on 11/12/2019.\n// Copyright © 2019 Guillaume Lou"
},
{
"path": "Aerial/Source/Views/Layers/LocationLayer.swift",
"chars": 5794,
"preview": "//\n// LocationLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 11/12/2019.\n// Copyright © 2019 Guillaume Lo"
},
{
"path": "Aerial/Source/Views/Layers/MessageLayer.swift",
"chars": 4250,
"preview": "//\n// MessageLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 12/12/2019.\n// Copyright © 2019 Guillaume Lou"
},
{
"path": "Aerial/Source/Views/Layers/Music/ArtworkLayer.swift",
"chars": 2418,
"preview": "//\n// ArtworkLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 30/06/2021.\n// Copyright © 2021 Guillaume Lou"
},
{
"path": "Aerial/Source/Views/Layers/Music/MusicLayer.swift",
"chars": 5871,
"preview": "//\n// MusicLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 11/06/2021.\n// Copyright © 2021 Guillaume Louel"
},
{
"path": "Aerial/Source/Views/Layers/TimerLayer.swift",
"chars": 3949,
"preview": "//\n// TimerLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 19/03/2020.\n// Copyright © 2020 Guillaume Louel"
},
{
"path": "Aerial/Source/Views/Layers/Weather/ConditionLayer.swift",
"chars": 11349,
"preview": "//\n// ConditionLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/04/2020.\n// Copyright © 2020 Guillaume L"
},
{
"path": "Aerial/Source/Views/Layers/Weather/ConditionSymbolLayer.swift",
"chars": 10548,
"preview": "//\n// ConditionSymbolLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 24/04/2020.\n// Copyright © 2020 Guill"
},
{
"path": "Aerial/Source/Views/Layers/Weather/ForecastLayer.swift",
"chars": 19529,
"preview": "//\n// ForecastLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 23/03/2021.\n// Copyright © 2021 Guillaume Lo"
},
{
"path": "Aerial/Source/Views/Layers/Weather/WeatherLayer.swift",
"chars": 9599,
"preview": "//\n// WeatherLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/04/2020.\n// Copyright © 2020 Guillaume Lou"
},
{
"path": "Aerial/Source/Views/Layers/Weather/WindDirectionLayer.swift",
"chars": 685,
"preview": "//\n// WindDirectionLayer.swift\n// Aerial\n//\n// Created by Guillaume Louel on 05/03/2021.\n// Copyright © 2021 Guillau"
},
{
"path": "Aerial/Source/Views/Layers/Weather/YahooLogoLayer.swift",
"chars": 750,
"preview": "//\n// YahooLogoLayer.swift\n// Aerial\n// CALayer for Yahoo logo (attribution is required for API access)\n//\n// Cr"
},
{
"path": "Aerial/Source/Views/MainUI/AspectFillNSImageView.swift",
"chars": 1091,
"preview": "//\n// AspectFillNSImageView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 20/07/2020.\n// Copyright © 2020 Guil"
},
{
"path": "Aerial/Source/Views/MainUI/NowPlayingCollectionView.swift",
"chars": 696,
"preview": "//\n// NowPlayingCollectionView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 18/08/2022.\n// Copyright © 2022 G"
},
{
"path": "Aerial/Source/Views/MainUI/ShadowTextFieldCell.swift",
"chars": 218,
"preview": "//\n// ShadowTextFieldCell.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/07/2020.\n// Copyright © 2020 Guilla"
},
{
"path": "Aerial/Source/Views/MainUI/SidebarOutlineView.swift",
"chars": 728,
"preview": "//\n// SidebarOutlineView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 02/08/2020.\n// Copyright © 2020 Guillau"
},
{
"path": "Aerial/Source/Views/MainUI/VideoCellView.swift",
"chars": 1956,
"preview": "//\n// VideoCellView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/07/2020.\n// Copyright © 2020 Guillaume Lo"
},
{
"path": "Aerial/Source/Views/PrefPanel/CheckCellView.swift",
"chars": 3822,
"preview": "//\n// CheckCellView.swift\n// Aerial\n//\n// Created by John Coates on 10/24/15.\n// Copyright © 2015 John Coates. All r"
},
{
"path": "Aerial/Source/Views/PrefPanel/DisplayView.swift",
"chars": 10664,
"preview": "//\n// DisplayView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 09/05/2019.\n// Copyright © 2019 John Coates. A"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoBatteryView.swift",
"chars": 525,
"preview": "//\n// InfoBatteryView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 27/12/2019.\n// Copyright © 2019 Guillaume "
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoClockView.swift",
"chars": 2063,
"preview": "//\n// InfoClockView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 18/12/2019.\n// Copyright © 2019 Guillaume Lo"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoCommonView.swift",
"chars": 7288,
"preview": "//\n// InfoCommonView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/12/2019.\n// Copyright © 2019 Guillaume L"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoContainerView.swift",
"chars": 402,
"preview": "//\n// InfoContainerView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/12/2019.\n// Copyright © 2019 Guillaum"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoCountdownView.swift",
"chars": 2260,
"preview": "//\n// InfoCountdownView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 12/02/2020.\n// Copyright © 2020 Guillaum"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoDateView.swift",
"chars": 1348,
"preview": "//\n// InfoDateView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 23/03/2020.\n// Copyright © 2020 Guillaume Lou"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoLocationView.swift",
"chars": 520,
"preview": "//\n// InfoLocationView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 19/12/2019.\n// Copyright © 2019 Guillaume"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoMessageView.swift",
"chars": 2712,
"preview": "//\n// InfoMessageView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 18/12/2019.\n// Copyright © 2019 Guillaume "
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoMusicView.swift",
"chars": 300,
"preview": "//\n// InfoMusicView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 11/06/2021.\n// Copyright © 2021 Guillaume Lo"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoSettingsTableSource.swift",
"chars": 1722,
"preview": "//\n// InfoSettingsTableSource.swift\n// Aerial\n//\n// Created by Guillaume Louel on 14/02/2020.\n// Copyright © 2020 Gu"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoSettingsView.swift",
"chars": 3364,
"preview": "//\n// InfoSettingsView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 14/02/2020.\n// Copyright © 2020 Guillaume"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoTableSource.swift",
"chars": 5262,
"preview": "//\n// InfoTableSource.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/12/2019.\n// Copyright © 2019 Guillaume "
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoTimerView.swift",
"chars": 1847,
"preview": "//\n// InfoTimerView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 19/03/2020.\n// Copyright © 2020 Guillaume Lo"
},
{
"path": "Aerial/Source/Views/PrefPanel/InfoWeatherView.swift",
"chars": 8354,
"preview": "//\n// InfoWeatherView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 25/03/2020.\n// Copyright © 2020 Guillaume "
},
{
"path": "Aerial/Source/Views/PrefPanel/VideoHeaderView.swift",
"chars": 489,
"preview": "//\n// VideoHeaderView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 14/07/2020.\n// Copyright © 2020 Guillaume "
},
{
"path": "Aerial/Source/Views/PrefPanel/VideoViewItem.swift",
"chars": 1351,
"preview": "//\n// VideoViewItem.swift\n// Aerial\n//\n// Created by Guillaume Louel on 13/07/2020.\n// Copyright © 2020 Guillaume Lo"
},
{
"path": "Aerial/Source/Views/Sources/ActionCellView.swift",
"chars": 1526,
"preview": "//\n// ActionCellView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 31/07/2020.\n// Copyright © 2020 Guillaume L"
},
{
"path": "Aerial/Source/Views/Sources/CheckboxCellView.swift",
"chars": 1181,
"preview": "//\n// CheckboxCellView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 09/07/2020.\n// Copyright © 2020 Guillaume"
},
{
"path": "Aerial/Source/Views/Sources/DescriptionCellView.swift",
"chars": 3115,
"preview": "//\n// DescriptionCellView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 09/07/2020.\n// Copyright © 2020 Guilla"
},
{
"path": "Aerial/Source/Views/Sources/SourceOutlineView.swift",
"chars": 720,
"preview": "//\n// SourceOutlineView.swift\n// Aerial\n//\n// Created by Guillaume Louel on 16/08/2020.\n// Copyright © 2020 Guillaum"
},
{
"path": "Aerial copy-Info.plist",
"chars": 1565,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Aerial.xcodeproj/project.pbxproj",
"chars": 291404,
"preview": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 46;\n\tobjects = {\n\n/* Begin PBXBuildFile section *"
},
{
"path": "Aerial.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
"chars": 135,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n version = \"1.0\">\n <FileRef\n location = \"self:\">\n </FileRef"
},
{
"path": "Aerial.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
"chars": 238,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Aerial.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings",
"chars": 181,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Aerial.xcodeproj/xcshareddata/xcschemes/Aerial.xcscheme",
"chars": 3130,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n LastUpgradeVersion = \"1010\"\n version = \"1.3\">\n <BuildAction\n "
},
{
"path": "AerialApp copy-Info.plist",
"chars": 1811,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "Documentation/AutoUpdates.md",
"chars": 5341,
"preview": "# About auto-updates\n\nStarting with version 1.4.8, Aerial now includes the open source project [Sparkle](https://sparkl"
},
{
"path": "Documentation/ChangeLog.md",
"chars": 11772,
"preview": "# Aerial change log\n\n## [1.8.0](https://github.com/JohnCoates/Aerial/releases/tag/v1.8.0) - February 18, 2020\n\n- New up"
},
{
"path": "Documentation/Contribute.md",
"chars": 1846,
"preview": "# Contributing to Aerial\n\n(If you want to help with translations, please check [this page here](https://github.com/JohnC"
},
{
"path": "Documentation/CustomVideos.md",
"chars": 3453,
"preview": "# Add your own videos to Aerial\n\nStarting with version 1.5.0 of Aerial, you can now add your own videos to the playlist"
},
{
"path": "Documentation/FAQs.md",
"chars": 10517,
"preview": "# Frequently Asked Questions\n\nThis guide is meant to help you get started and answer some of the most common questions. "
},
{
"path": "Documentation/HardwareDecoding.md",
"chars": 3006,
"preview": "# Which format should I pick ? \n\nYou have a choice of video formats, which you can set as a preference in Settings/Advan"
},
{
"path": "Documentation/Installation.md",
"chars": 4746,
"preview": "# Installation, setup and uninstallation\n\n## Installation instructions\n\nAerial now includes an auto-update mechanism usi"
},
{
"path": "Documentation/MoreVideos.md",
"chars": 3822,
"preview": "# Community Videos \n \nThe videos below have been shared with the project by artists, so they can be enjoyed in Aerial by"
},
{
"path": "Documentation/OfflineMode.md",
"chars": 2738,
"preview": "# Offline Mode\n\nIf you want to use Aerial on a Mac behind a firewall or with no network access, the easiest way startin"
},
{
"path": "Documentation/README.md",
"chars": 570,
"preview": "# Welcome to Aerial's documentation\n\nThis documentation is still a work in progress, if you have any further question d"
},
{
"path": "Documentation/Troubleshooting.md",
"chars": 11104,
"preview": "# Troubleshooting\n\n**Are you using Little Snitch or another firewall ?** Aerial requires network access for it to work, "
},
{
"path": "LICENSE",
"chars": 1078,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2015 John Coates\n\nPermission is hereby granted, free of charge, to any person obtai"
},
{
"path": "Makefile",
"chars": 651,
"preview": ".DEFAULT_GOAL := default\n\nXCODEBUILD := xcodebuild\nBUILD_FLAGS = -scheme $(SCHEME)\n\nSCHEME ?= $(TARGET)\nTARGET ?= Aerial"
},
{
"path": "Podfile",
"chars": 995,
"preview": "# Uncomment the next line to define a global platform for your project\nplatform :macos, '10.9'\n\ntarget 'Aerial' do\n # C"
},
{
"path": "Readme.md",
"chars": 3996,
"preview": "<p align=\"center\">\n <img src=\"https://cloud.githubusercontent.com/assets/499192/10754100/c0e1cc4c-7c95-11e5-9d3b-842d3a"
},
{
"path": "Resources/Community/Readme.md",
"chars": 2859,
"preview": "# Translations of the community strings\n\nAerial features overlay descriptions of the main geographical features displaye"
},
{
"path": "Resources/Community/ar.json",
"chars": 8670,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"كوريا واليابان في الليل\"\n },\n \"B1B5DDC5-73C8-4920"
},
{
"path": "Resources/Community/de.json",
"chars": 8711,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Korea und Japan bei Nacht\"\n },\n \"B1B5DDC5-73C8-49"
},
{
"path": "Resources/Community/en.json",
"chars": 10651,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Korea and Japan Night\"\n },\n \"B1B5DDC5-73C8-4920-8"
},
{
"path": "Resources/Community/es.json",
"chars": 8780,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Korea y Japon de noche\"\n },\n \"B1B5DDC5-73C8-4920-"
},
{
"path": "Resources/Community/fr.json",
"chars": 10962,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Corée et Japon\"\n },\n \"B1B5DDC5-73C8-4920-8133-BAC"
},
{
"path": "Resources/Community/he.json",
"chars": 8364,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"קוריאה ולילה ביפן\"\n },\n \"B1B5DDC5-73C8-4920-8133-"
},
{
"path": "Resources/Community/hu.json",
"chars": 10832,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Korea és Japán éjszaka\"\n },\n \"B1B5DDC5-73C8-4920-"
},
{
"path": "Resources/Community/it.json",
"chars": 8705,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Corea e Giappone di notte\"\n },\n \"B1B5DDC5-73C8-49"
},
{
"path": "Resources/Community/ja.json",
"chars": 7782,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"韓国と日本の夜\"\n },\n \"B1B5DDC5-73C8-4920-8133-BACCE38A08"
},
{
"path": "Resources/Community/ko.json",
"chars": 7767,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"한국과 일본의 밤\"\n },\n \"B1B5DDC5-73C8-4920-8133-BACCE38A"
},
{
"path": "Resources/Community/missingvideos.json",
"chars": 995,
"preview": "{\n \"assets\" : [\n {\n \"pointsOfInterest\" : {\n \"0\" : \"HK_H004_C001_0\"\n },\n "
},
{
"path": "Resources/Community/nl.json",
"chars": 8573,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Korea en Japan Nacht\"\n },\n \"B1B5DDC5-73C8-4920-81"
},
{
"path": "Resources/Community/pl.json",
"chars": 8692,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Korea i Japonia\"\n },\n \"B1B5DDC5-73C8-4920-8133-BA"
},
{
"path": "Resources/Community/pt.json",
"chars": 8844,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Coreia e Japão a noite\"\n },\n \"B1B5DDC5-73C8-4920-"
},
{
"path": "Resources/Community/pt_BR.json",
"chars": 8844,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Coreia e Japão a noite\"\n },\n \"B1B5DDC5-73C8-4920-"
},
{
"path": "Resources/Community/ru.json",
"chars": 10965,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Корея и Япония ночью\"\n },\n \"B1B5DDC5-73C8-4920-81"
},
{
"path": "Resources/Community/sv.json",
"chars": 8557,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Koreahalvön och Japan på natten\"\n },\n \"B1B5DDC5-7"
},
{
"path": "Resources/Community/tl.json",
"chars": 10868,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"Korea at Japan sa Gabi\"\n },\n \"B1B5DDC5-73C8-4920-"
},
{
"path": "Resources/Community/zh_CN.json",
"chars": 7424,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"韩国和日本的夜晚\"\n },\n \"B1B5DDC5-73C8-4920-8133-BACCE38A0"
},
{
"path": "Resources/Community/zh_TW.json",
"chars": 7416,
"preview": "{\n \"009BA758-7060-4479-8EE8-FB9B40C8FB97\": {\n \"name\": \"韓國和日本的夜晚\"\n },\n \"B1B5DDC5-73C8-4920-8133-BACCE38A0"
},
{
"path": "Resources/MainUI/First time setup/CacheSetupViewController.swift",
"chars": 1192,
"preview": "//\n// CacheSetupViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 12/08/2020.\n// Copyright © 2020 G"
},
{
"path": "Resources/MainUI/First time setup/CacheSetupViewController.xib",
"chars": 19700,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/First time setup/FirstSetupWindowController.swift",
"chars": 3772,
"preview": "//\n// FirstSetupWindowController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 29/07/2020.\n// Copyright © 2020"
},
{
"path": "Resources/MainUI/First time setup/FirstSetupWindowController.xib",
"chars": 1905,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/First time setup/NextViewController.swift",
"chars": 1081,
"preview": "//\n// NextViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 29/07/2020.\n// Copyright © 2020 Guillau"
},
{
"path": "Resources/MainUI/First time setup/NextViewController.xib",
"chars": 4661,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/First time setup/RecapViewController.swift",
"chars": 1133,
"preview": "//\n// RecapViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 12/08/2020.\n// Copyright © 2020 Guilla"
},
{
"path": "Resources/MainUI/First time setup/RecapViewController.xib",
"chars": 21979,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/First time setup/TimeSetupViewController.swift",
"chars": 4385,
"preview": "//\n// TimeSetupViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 12/08/2020.\n// Copyright © 2020 Gu"
},
{
"path": "Resources/MainUI/First time setup/TimeSetupViewController.xib",
"chars": 29521,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/First time setup/VideoFormatViewController.swift",
"chars": 3403,
"preview": "//\n// VideoFormatViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 11/08/2020.\n// Copyright © 2020 "
},
{
"path": "Resources/MainUI/First time setup/VideoFormatViewController.xib",
"chars": 37539,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/First time setup/WelcomeViewController.swift",
"chars": 619,
"preview": "//\n// WelcomeViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 29/07/2020.\n// Copyright © 2020 Guil"
},
{
"path": "Resources/MainUI/First time setup/WelcomeViewController.xib",
"chars": 6838,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Infos panels/CreditsViewController.swift",
"chars": 1201,
"preview": "//\n// CreditsViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 25/07/2020.\n// Copyright © 2020 Guil"
},
{
"path": "Resources/MainUI/Infos panels/CreditsViewController.xib",
"chars": 14655,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Infos panels/HelpViewController.swift",
"chars": 1142,
"preview": "//\n// HelpViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 25/07/2020.\n// Copyright © 2020 Guillau"
},
{
"path": "Resources/MainUI/Infos panels/HelpViewController.xib",
"chars": 12562,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Infos panels/InfoViewController.swift",
"chars": 770,
"preview": "//\n// InfoViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/07/2020.\n// Copyright © 2020 Guillau"
},
{
"path": "Resources/MainUI/Infos panels/InfoViewController.xib",
"chars": 11439,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/PanelWindowController.swift",
"chars": 10818,
"preview": "//\n// PanelWindowController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 15/07/2020.\n// Copyright © 2020 Guil"
},
{
"path": "Resources/MainUI/PanelWindowController.xib",
"chars": 2060,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Settings panels/AdvancedViewController.swift",
"chars": 12526,
"preview": "//\n// AdvancedViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 18/07/2020.\n// Copyright © 2020 Gui"
},
{
"path": "Resources/MainUI/Settings panels/AdvancedViewController.xib",
"chars": 64003,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Settings panels/BrightnessViewController.swift",
"chars": 3560,
"preview": "//\n// BrightnessViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 19/07/2020.\n// Copyright © 2020 G"
},
{
"path": "Resources/MainUI/Settings panels/BrightnessViewController.xib",
"chars": 13672,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Settings panels/CacheViewController.swift",
"chars": 11941,
"preview": "//\n// CacheViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 18/07/2020.\n// Copyright © 2020 Guilla"
},
{
"path": "Resources/MainUI/Settings panels/CacheViewController.xib",
"chars": 50692,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Settings panels/Collection View/PlayingCollectionViewItem.swift",
"chars": 4993,
"preview": "//\n// PlayingCollectionViewItem.swift\n// Aerial\n//\n// Created by Guillaume Louel on 18/11/2021.\n// Copyright © 2021 "
},
{
"path": "Resources/MainUI/Settings panels/Collection View/PlayingCollectionViewItem.xib",
"chars": 14006,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.Cocoa.XIB\" version=\"3.0\" toolsVersion"
},
{
"path": "Resources/MainUI/Settings panels/CompanionCacheViewController.swift",
"chars": 1028,
"preview": "//\n// CompanionCacheViewController.swift\n// Aerial\n//\n// Created by Guillaume Louel on 17/07/2022.\n// Copyright © 20"
}
]
// ... and 25 more files (download for full content)
About this extraction
This page contains the full source code of the JohnCoates/Aerial GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 225 files (2.3 MB), approximately 605.5k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.