Full Code of JohnCoates/Aerial for AI

master 43daa78e8f71 cached
225 files
2.3 MB
605.5k tokens
1 requests
Download .txt
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 
Download .txt
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.

Copied to clipboard!