~zanneth/Amalgamation

8b61ca5b47c454b76c580d6a0642793ada808a76 — Charles Magahern 4 years ago 08929b4 + cd466ec
DDR Card Draw Feature

Merge branch 'carddraw'
M Amalgamation.xcodeproj/project.pbxproj => Amalgamation.xcodeproj/project.pbxproj +40 -0
@@ 39,6 39,14 @@
		0CB1D61B200DDD2A009515DA /* ddrsummer2017.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CB1D61A200DDD2A009515DA /* ddrsummer2017.json */; };
		0CB1D61D200DE1F0009515DA /* TournamentStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB1D61C200DE1F0009515DA /* TournamentStatsView.swift */; };
		0CBA3C9720D0EC5600CAA62E /* PaddedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBA3C9620D0EC5600CAA62E /* PaddedLabel.swift */; };
		0CBCD39521549E4E000412C2 /* DDR.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBCD39421549E4E000412C2 /* DDR.swift */; };
		0CBCD39721549FBC000412C2 /* ddrmatch.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CBCD39621549FBC000412C2 /* ddrmatch.json */; };
		0CBCD3992154D319000412C2 /* DDRCardDrawViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBCD3982154D319000412C2 /* DDRCardDrawViewController.swift */; };
		0CBCD39B2155FE28000412C2 /* DDRCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBCD39A2155FE28000412C2 /* DDRCardView.swift */; };
		0CBCD39D2156191D000412C2 /* DispatchExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBCD39C2156191D000412C2 /* DispatchExtensions.swift */; };
		0CBCD39F2156C8FD000412C2 /* DDRCardDrawServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBCD39E2156C8FD000412C2 /* DDRCardDrawServer.swift */; };
		0CBCD3A12156CA02000412C2 /* ServerOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBCD3A02156CA02000412C2 /* ServerOperation.swift */; };
		0CBCD3A32156D263000412C2 /* TournamentScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBCD3A22156D263000412C2 /* TournamentScreen.swift */; };
		0CBDE8452082CE9000DDFB91 /* GeometryExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBDE8442082CE9000DDFB91 /* GeometryExtensions.swift */; };
		0CCC540820283C860086E340 /* PoissonUniform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCC540720283C860086E340 /* PoissonUniform.swift */; };
		0CE5B8CE20C3B60D00ED8742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE5B8CD20C3B60D00ED8742 /* SettingsViewController.swift */; };


@@ 84,6 92,14 @@
		0CB1D61A200DDD2A009515DA /* ddrsummer2017.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = ddrsummer2017.json; sourceTree = "<group>"; };
		0CB1D61C200DE1F0009515DA /* TournamentStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentStatsView.swift; sourceTree = "<group>"; };
		0CBA3C9620D0EC5600CAA62E /* PaddedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddedLabel.swift; sourceTree = "<group>"; };
		0CBCD39421549E4E000412C2 /* DDR.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDR.swift; sourceTree = "<group>"; };
		0CBCD39621549FBC000412C2 /* ddrmatch.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ddrmatch.json; sourceTree = "<group>"; };
		0CBCD3982154D319000412C2 /* DDRCardDrawViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDRCardDrawViewController.swift; sourceTree = "<group>"; };
		0CBCD39A2155FE28000412C2 /* DDRCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDRCardView.swift; sourceTree = "<group>"; };
		0CBCD39C2156191D000412C2 /* DispatchExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchExtensions.swift; sourceTree = "<group>"; };
		0CBCD39E2156C8FD000412C2 /* DDRCardDrawServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DDRCardDrawServer.swift; sourceTree = "<group>"; };
		0CBCD3A02156CA02000412C2 /* ServerOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerOperation.swift; sourceTree = "<group>"; };
		0CBCD3A22156D263000412C2 /* TournamentScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentScreen.swift; sourceTree = "<group>"; };
		0CBDE8442082CE9000DDFB91 /* GeometryExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeometryExtensions.swift; sourceTree = "<group>"; };
		0CCC540720283C860086E340 /* PoissonUniform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PoissonUniform.swift; sourceTree = "<group>"; };
		0CE5B8CD20C3B60D00ED8742 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };


@@ 164,6 180,7 @@
		0C27C3E21F8AEFA5003A1D96 /* Source */ = {
			isa = PBXGroup;
			children = (
				0CBCD39321549E18000412C2 /* Rhythm Game Support */,
				0C7A0CF91F8AF0CF00674AE7 /* Application */,
				0C7A0D021F8AFFCD00674AE7 /* Challonge */,
				0C7A0CFB1F8AF2CF00674AE7 /* Models */,


@@ 236,13 253,16 @@
			children = (
				0CF36018201F06D6009D4CE9 /* CollectionExtensions.swift */,
				0C2E322A2026FA5F00FD0867 /* ColorExtensions.swift */,
				0CBCD39C2156191D000412C2 /* DispatchExtensions.swift */,
				0C4181AD2042365300D14B33 /* FibonacciSphere.swift */,
				0CBDE8442082CE9000DDFB91 /* GeometryExtensions.swift */,
				0CA5FB41206B615600BB5803 /* MathUtil.swift */,
				0CCC540720283C860086E340 /* PoissonUniform.swift */,
				0C7A0D0A1F8B06FE00674AE7 /* Semaphore.swift */,
				0CBCD3A02156CA02000412C2 /* ServerOperation.swift */,
				0C7A0D061F8B009300674AE7 /* StandardIO.swift */,
				0C10EF54200D7F9A00285991 /* Theme.swift */,
				0CBCD3A22156D263000412C2 /* TournamentScreen.swift */,
				0C28C329209EDC0A006F9FDE /* Transitionable.swift */,
				0C7A0D071F8B009300674AE7 /* TVSupport.swift */,
			);


@@ 253,10 273,22 @@
			isa = PBXGroup;
			children = (
				0CB1D61A200DDD2A009515DA /* ddrsummer2017.json */,
				0CBCD39621549FBC000412C2 /* ddrmatch.json */,
			);
			path = "Test Data";
			sourceTree = "<group>";
		};
		0CBCD39321549E18000412C2 /* Rhythm Game Support */ = {
			isa = PBXGroup;
			children = (
				0CBCD39421549E4E000412C2 /* DDR.swift */,
				0CBCD39E2156C8FD000412C2 /* DDRCardDrawServer.swift */,
				0CBCD3982154D319000412C2 /* DDRCardDrawViewController.swift */,
				0CBCD39A2155FE28000412C2 /* DDRCardView.swift */,
			);
			path = "Rhythm Game Support";
			sourceTree = "<group>";
		};
/* End PBXGroup section */

/* Begin PBXNativeTarget section */


@@ 316,6 348,7 @@
			buildActionMask = 2147483647;
			files = (
				0CAEB90B1F8B0C8100B0B246 /* Localizable.strings in Resources */,
				0CBCD39721549FBC000412C2 /* ddrmatch.json in Resources */,
				0CB1D618200DCCE4009515DA /* Exan-Regular.ttf in Resources */,
				0C10EF52200D7DCC00285991 /* Karla-BoldItalic.ttf in Resources */,
				0C10EF53200D7DCC00285991 /* Karla-Bold.ttf in Resources */,


@@ 333,16 366,21 @@
			isa = PBXSourcesBuildPhase;
			buildActionMask = 2147483647;
			files = (
				0CBCD39B2155FE28000412C2 /* DDRCardView.swift in Sources */,
				0CBCD39521549E4E000412C2 /* DDR.swift in Sources */,
				0CB1D61D200DE1F0009515DA /* TournamentStatsView.swift in Sources */,
				0CF36019201F06D6009D4CE9 /* CollectionExtensions.swift in Sources */,
				0CAEB90E1F8B0E7A00B0B246 /* StandardIO.swift in Sources */,
				0C7A0D041F8AFFE900674AE7 /* ChallongeServer.swift in Sources */,
				0CBCD3992154D319000412C2 /* DDRCardDrawViewController.swift in Sources */,
				0CBCD39D2156191D000412C2 /* DispatchExtensions.swift in Sources */,
				0C27C3EC1F8AEFB8003A1D96 /* TournamentViewController.swift in Sources */,
				0C2E322D2027022F00FD0867 /* VisualizationViewController.swift in Sources */,
				0C3212B020170730000FD6D0 /* TournamentBracketView.swift in Sources */,
				0CCC540820283C860086E340 /* PoissonUniform.swift in Sources */,
				0CAEB90F1F8B0E7E00B0B246 /* TVSupport.swift in Sources */,
				0C3212B220170788000FD6D0 /* TournamentMatchView.swift in Sources */,
				0CBCD3A32156D263000412C2 /* TournamentScreen.swift in Sources */,
				0C10EF55200D7F9A00285991 /* Theme.swift in Sources */,
				0CE85C552022E1F000CC726D /* TournamentBracketAnnotationsView.swift in Sources */,
				0C28C32C209FE960006F9FDE /* InterstitialView.swift in Sources */,


@@ 350,6 388,8 @@
				0CBA3C9720D0EC5600CAA62E /* PaddedLabel.swift in Sources */,
				0CBDE8452082CE9000DDFB91 /* GeometryExtensions.swift in Sources */,
				0C27C3EB1F8AEFB6003A1D96 /* AppDelegate.swift in Sources */,
				0CBCD39F2156C8FD000412C2 /* DDRCardDrawServer.swift in Sources */,
				0CBCD3A12156CA02000412C2 /* ServerOperation.swift in Sources */,
				0CE5B8D020C3C30800ED8742 /* SavedTournament.swift in Sources */,
				0C7A0CFD1F8AF2E400674AE7 /* Participant.swift in Sources */,
				0C7A0CFF1F8AF73D00674AE7 /* Match.swift in Sources */,

A Amalgamation/Resources/Assets.xcassets/CardDraw.imageset/CardDraw.pdf => Amalgamation/Resources/Assets.xcassets/CardDraw.imageset/CardDraw.pdf +0 -0
A Amalgamation/Resources/Assets.xcassets/CardDraw.imageset/Contents.json => Amalgamation/Resources/Assets.xcassets/CardDraw.imageset/Contents.json +21 -0
@@ 0,0 1,21 @@
{
  "images" : [
    {
      "idiom" : "universal",
      "filename" : "CardDraw.pdf",
      "scale" : "1x"
    },
    {
      "idiom" : "universal",
      "scale" : "2x"
    },
    {
      "idiom" : "universal",
      "scale" : "3x"
    }
  ],
  "info" : {
    "version" : 1,
    "author" : "xcode"
  }
}
\ No newline at end of file

A Amalgamation/Resources/Test Data/ddrmatch.json => Amalgamation/Resources/Test Data/ddrmatch.json +88 -0
@@ 0,0 1,88 @@
{
    "match" : {
        "playerOneName" : "iamchris4life",
        "playerTwoName" : "rogerclark",
        "cardDraws" : [
            {
                "id" : 1337,
                "title" : "Pluto Relinquish",
                "artist" : "2MB",
                "bpm" : "800",
                "chart" : {
                    "plurality" : "single",
                    "difficulty" : "challenge",
                    "rating" : 15
                }
            },
            {
                "id" : 573,
                "title" : "MAX 300",
                "artist" : "Ω",
                "bpm" : "300",
                "vetoed" : true,
                "chart" : {
                    "plurality" : "single",
                    "difficulty" : "expert",
                    "rating" : 12
                }
            },
            {
                "id" : 420,
                "title" : "Love You More",
                "artist" : "BEMANI Sound Team 'Sota F.'",
                "bpm" : "175",
                "chart" : {
                    "plurality" : "single",
                    "difficulty" : "expert",
                    "rating" : 16
                }
            },
            
            {
                "id" : 1337,
                "title" : "Pluto Relinquish",
                "artist" : "2MB",
                "bpm" : "800",
                "chart" : {
                    "plurality" : "single",
                    "difficulty" : "challenge",
                    "rating" : 15
                }
            },
            {
                "id" : 573,
                "title" : "MAX 300",
                "artist" : "Ω",
                "bpm" : "300",
                "chart" : {
                    "plurality" : "single",
                    "difficulty" : "expert",
                    "rating" : 12
                }
            },
            {
                "id" : 420,
                "title" : "お米の美味しい炊き方、そしてお米を食べることによるその効果。",
                "artist" : "BEMANI Sound Team 'Sota F.'",
                "bpm" : "175",
                "vetoed" : true,
                "chart" : {
                    "plurality" : "single",
                    "difficulty" : "expert",
                    "rating" : 16
                }
            },
            {
                "id" : 420,
                "title" : "Love You More",
                "artist" : "BEMANI Sound Team 'Sota F.'",
                "bpm" : "175",
                "chart" : {
                    "plurality" : "single",
                    "difficulty" : "expert",
                    "rating" : 16
                }
            }
        ]
    }
}

M Amalgamation/Source/Challonge/ChallongeServer.swift => Amalgamation/Source/Challonge/ChallongeServer.swift +0 -49
@@ 106,55 106,6 @@ class ChallongeServer
    }
}

internal class ServerOperation : Operation
{
    var baseURL:             URL
    var session:             URLSession
    internal(set) var error: Error?
    
    init(baseURL: URL, session: URLSession)
    {
        self.baseURL = baseURL
        self.session = session
    }
    
    func fetchData(_ url: URL) -> Data?
    {
        var fetchedData: Data?
        
        let semaphore = Semaphore(value: 0)
        let task = self.session.dataTask(with: url, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) -> Void in
            fetchedData = data
            self.error = error
            
            semaphore.signal()
        })
        task.resume()
        
        semaphore.wait()
        return fetchedData
    }
    
    func fetchResponse(_ request: URLRequest) -> (Data?, URLResponse?)
    {
        var fetchedData: Data?
        var fetchedResponse: URLResponse?
        
        let semaphore = Semaphore(value: 0)
        let task = self.session.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) in
            fetchedResponse = response
            fetchedData = data
            self.error = error
            
            semaphore.signal()
        })
        task.resume()
        
        semaphore.wait()
        return (fetchedData, fetchedResponse)
    }
}

internal class LoadTournamentOperation : ServerOperation
{
    var tournamentStringID: String = ""

A Amalgamation/Source/Rhythm Game Support/DDR.swift => Amalgamation/Source/Rhythm Game Support/DDR.swift +47 -0
@@ 0,0 1,47 @@
//
//  DDR.swift
//  Amalgamation
//
//  Created by Charles Magahern on 9/20/18.
//

import Foundation

struct DDRChart : Codable
{
    enum Plurality : String, Codable
    {
        case single
        case double
    }
    
    enum Difficulty : String, Codable
    {
        case beginner
        case basic
        case difficult
        case expert
        case challenge
    }
    
    var plurality:  Plurality = .single
    var difficulty: Difficulty = .beginner
    var rating:     Int = 0
}

struct DDRSong : Codable
{
    var id:     Int?
    var title:  String = ""
    var artist: String = ""
    var bpm:    String?
    var chart:  DDRChart?
    var vetoed: Bool?
}

struct DDRMatch : Codable
{
    var playerOneName:  String?
    var playerTwoName:  String?
    var cardDraws:      [DDRSong] = []
}

A Amalgamation/Source/Rhythm Game Support/DDRCardDrawServer.swift => Amalgamation/Source/Rhythm Game Support/DDRCardDrawServer.swift +106 -0
@@ 0,0 1,106 @@
//
//  DDRCardDrawServer.swift
//  Amalgamation
//
//  Created by Charles Magahern on 9/22/18.
//

import Foundation

struct DDRCardDrawError : Error
{
    enum Code
    {
        case unknown
        case connectionFailure
        case noMatchAvailable
        case parseFailure
    }
    
    let code: Code
    
    init(_ code: Code)
    {
        self.code = code
    }
}

class DDRCardDrawServer
{
    private var _urlSession:        URLSession
    private var _operationQueue:    OperationQueue
    
    fileprivate static let _baseURL = URL(string: "http://mephisto.zanneth.com:5730")!
    
    init()
    {
        let config = URLSessionConfiguration.default
        _urlSession = URLSession(configuration: config)
        
        _operationQueue = OperationQueue()
        _operationQueue.maxConcurrentOperationCount = 1
    }
    
    func fetchCurrentMatch(completion: @escaping (DDRMatch?, Error?) -> Void)
    {
        let operation = LoadDDRMatchOperation(baseURL: DDRCardDrawServer._baseURL, session: _urlSession)
        
        weak var weakOp = operation
        operation.completionBlock = {
            guard let strongOp = weakOp else { completion(nil, nil) ; return }
            if let error = strongOp.error {
                self._logError("failed to load matches", error: error)
            }
            
            completion(strongOp.ddrMatch, strongOp.error)
        }
        
        _operationQueue.addOperation(operation)
    }
    
    // MARK: Internal
    
    internal func _logError(_ description: String, error: Error)
    {
        #if DEBUG
            StandardErrorOutputStream.shared.write("ERROR: \(description) \(error.localizedDescription)\n")
        #endif
    }
}

internal class LoadDDRMatchOperation : ServerOperation
{
    internal(set) var ddrMatch: DDRMatch?
    
    override func main()
    {
        let urlRequest = URLRequest(url: self.baseURL)
        let (data, response) = self.fetchResponse(urlRequest)
        
        var errorCode: DDRCardDrawError.Code?
        if let httpResponse = response as? HTTPURLResponse {
            switch (httpResponse.statusCode) {
            case 200:
                errorCode = nil
            case 404:
                errorCode = .noMatchAvailable
            default:
                errorCode = .unknown
            }
        } else {
            errorCode = .connectionFailure
        }
        
        if let errorCode = errorCode {
            self.error = DDRCardDrawError(errorCode)
        } else {
            do {
                let decoder = JSONDecoder()
                let jsonObject = try decoder.decode([String : DDRMatch].self, from: data!)
                self.ddrMatch = jsonObject["match"]
            } catch {
                self.error = DDRCardDrawError(.parseFailure)
            }
        }
    }
}

A Amalgamation/Source/Rhythm Game Support/DDRCardDrawViewController.swift => Amalgamation/Source/Rhythm Game Support/DDRCardDrawViewController.swift +343 -0
@@ 0,0 1,343 @@
//
//  DDRCardDrawViewController.swift
//  Amalgamation
//
//  Created by Charles Magahern on 9/21/18.
//

import Foundation
import UIKit

class DDRCardDrawViewController : UIViewController, CustomTournamentScreen, Transitionable
{
    private let _server:               DDRCardDrawServer = DDRCardDrawServer()
    private var _cachedUpdate:         DDRMatch?
    
    private let _kanjiLabel:           UILabel = UILabel()
    private let _playerOneLabel:       UILabel = UILabel()
    private let _playerTwoLabel:       UILabel = UILabel()
    private let _versusLabel:          UILabel = UILabel()
    
    private var _cardViews:            [DDRCardView] = []
    private var _transitionInProgress: Bool = false
    private var _active:               Bool = false
    
    // MARK: API
    
    var ddrMatch: DDRMatch?
    {
        didSet
        {
            _reloadPlayersUI()
            _reloadCardViews()
        }
    }
    
    func reloadData()
    {
        _server.fetchCurrentMatch { (match: DDRMatch?, error: Error?) in
            DispatchQueue.main.async {
                self._cachedUpdate = match
                
                if !self._transitionInProgress {
                    self._reloadFromCachedResult()
                }
            }
        }
    }
    
    // MARK: UIViewController
    
    override func viewDidLoad()
    {
        super.viewDidLoad()
        
        let theme = Theme.mainTheme
        let headerFont = theme.headerTextAttributes[NSAttributedStringKey.font] as! UIFont
        let centeredParagraphStyle = NSMutableParagraphStyle()
        centeredParagraphStyle.alignment = .center
        
        let kanjiFontDescriptor = headerFont.fontDescriptor.withSymbolicTraits(.traitBold)!
        let kanjiFont = UIFont(descriptor: kanjiFontDescriptor, size: 72.0)
        let kanjiAttributes: TextAttributes = [
            NSAttributedStringKey.paragraphStyle : centeredParagraphStyle,
            NSAttributedStringKey.font : kanjiFont,
            NSAttributedStringKey.foregroundColor : UIColor.white
        ]
        _kanjiLabel.attributedText = NSAttributedString(string: "試合状態", attributes: kanjiAttributes)
        
        let view = self.view
        view?.addSubview(_kanjiLabel)
        view?.addSubview(_playerOneLabel)
        view?.addSubview(_playerTwoLabel)
        view?.addSubview(_versusLabel)
    }
    
    override func viewDidLayoutSubviews()
    {
        super.viewDidLayoutSubviews()
        
        let bounds = self.view.bounds
        let kanjiPlayerNamesVPadding = CGFloat(20.0)
        let playerUICardsVPadding = bounds.size.height / 5.0
        
        // pre-compute sizes
        let kanjiLabelSize = _kanjiLabel.sizeThatFits(bounds.size)
        let collinearLabels = [_playerOneLabel, _versusLabel, _playerTwoLabel]
        var playerLabelsBoundingSize = CGSize(width: 0.0, height: 0.0)
        
        for label in collinearLabels {
            let labelSize = label.sizeThatFits(bounds.size)
            playerLabelsBoundingSize.width += labelSize.width
            playerLabelsBoundingSize.height = max(playerLabelsBoundingSize.height, labelSize.height)
        }
        
        let cardViewsWidth = rint(bounds.size.width / 9.0)
        let cardViewsSize = CGSize(
            width: cardViewsWidth,
            height: rint(cardViewsWidth * 1.61803) // golden ratio
        )
        
        let viewsTotalHeight = (
            kanjiLabelSize.height +
            kanjiPlayerNamesVPadding +
            playerLabelsBoundingSize.height +
            playerUICardsVPadding +
            cardViewsSize.height
        )
        let viewsOriginY = rint(bounds.origin.y + (bounds.size.height / 2.0 - viewsTotalHeight / 2.0))
        
        // layout kanji label
        let kanjiLabelFrame = CGRect(
            x: rint(bounds.origin.x + (bounds.size.width / 2.0 - kanjiLabelSize.width / 2.0)),
            y: viewsOriginY,
            width: kanjiLabelSize.width,
            height: kanjiLabelSize.height
        )
        _kanjiLabel.frame = kanjiLabelFrame
        
        // layout player labels
        let labelsHPadding = CGFloat(8.0)
        let labelsOriginX = rint(bounds.origin.x + (bounds.size.width / 2.0 - playerLabelsBoundingSize.width / 2.0))
        var previousLabel: UILabel?
        
        for label in collinearLabels {
            let labelSize = label.sizeThatFits(bounds.size)
            let originX = ((previousLabel != nil) ? previousLabel!.frame.maxX + labelsHPadding : labelsOriginX)
            let labelFrame = CGRect(
                x: originX,
                y: kanjiLabelFrame.maxY + kanjiPlayerNamesVPadding,
                width: labelSize.width,
                height: labelSize.height
            )
            label.frame = labelFrame
            previousLabel = label
        }
        
        // layout card views
        let cardViewsHPadding = CGFloat(20.0)
        let cardViewsCount = CGFloat(_cardViews.count)
        let cardViewsTotalWidth = (cardViewsCount * cardViewsSize.width) + ((cardViewsCount - 1.0) * cardViewsHPadding)
        let cardViewsOriginX = rint(bounds.origin.x + (bounds.size.width / 2.0 - cardViewsTotalWidth / 2.0))
        let cardViewsOriginY = rint(previousLabel!.frame.maxY + playerUICardsVPadding)
        
        var lastCardViewFrame: CGRect?
        for cardView in _cardViews {
            let originX = ((lastCardViewFrame != nil) ? lastCardViewFrame!.maxX + cardViewsHPadding : cardViewsOriginX)
            let cardViewFrame = CGRect(
                x: originX,
                y: cardViewsOriginY,
                width: cardViewsSize.width,
                height: cardViewsSize.height
            )
            
            cardView.frame = cardViewFrame
            lastCardViewFrame = cardViewFrame
        }
    }
    
    // MARK: CustomTournamentScreen
    
    var shouldShowScreen: Bool
    {
        get
        {
            if (self.ddrMatch == nil) {
                self.reloadData()
            }
            return (self.ddrMatch != nil)
        }
    }
        
    // MARK: Transitionable
    
    func prepareToTransitionToActive()
    {
        self.reloadData()
    }
    
    func transitionToActive(transition: Transition, completion: @escaping (Bool) -> Void)
    {
        _transitionToActive(active: true, transition: transition, completion: completion)
    }
    
    func transitionToInactive(transition: Transition, completion: @escaping (Bool) -> Void)
    {
        _transitionToActive(active: false, transition: transition, completion: completion)
    }
    
    // MARK: Internal
    
    internal func _reloadPlayersUI()
    {
        let unknownPlayerString = NSLocalizedString("MATCH_UNKNOWN_PLAYER_NAME", comment: "")
        let playerOneName = (self.ddrMatch?.playerOneName ?? unknownPlayerString).uppercased()
        let playerTwoName = (self.ddrMatch?.playerTwoName ?? unknownPlayerString).uppercased()
        
        let theme = Theme.mainTheme
        var versusTextAttributes = theme.headerTextAttributes
        let headerTextFont = versusTextAttributes[NSAttributedStringKey.font] as! UIFont
        let boldHeaderTextFontDesc = headerTextFont.fontDescriptor.withSymbolicTraits(.traitBold)!
        versusTextAttributes[NSAttributedStringKey.font] = UIFont(descriptor: boldHeaderTextFontDesc, size: 55.0)
        
        var playerNameAttributes = versusTextAttributes
        playerNameAttributes[NSAttributedStringKey.foregroundColor] = theme.colorPalette.primaryColor
        
        _playerOneLabel.attributedText = NSAttributedString(string: playerOneName, attributes: playerNameAttributes)
        _playerTwoLabel.attributedText = NSAttributedString(string: playerTwoName, attributes: playerNameAttributes)
        _versusLabel.attributedText = NSAttributedString(string: "vs.", attributes: versusTextAttributes)
        
        self.view.setNeedsLayout()
    }
    
    internal func _reloadCardViews()
    {
        _cardViews.forEach { $0.removeFromSuperview() }
        _cardViews.removeAll()
        
        let songs = self.ddrMatch?.cardDraws ?? []
        for song in songs {
            let cardView = DDRCardView()
            cardView.song = song
            cardView.layer.opacity = (_active ? 1.0 : 0.0)
            
            _cardViews.append(cardView)
            self.view.addSubview(cardView)
        }
        
        self.view.setNeedsLayout()
    }
    
    internal func _reloadFromCachedResult()
    {
        self.ddrMatch = _cachedUpdate
    }
    
    internal func _inTransaction_transitionPlayersUI(active: Bool, transition: Transition)
    {
        let playersUILabels = [_kanjiLabel, _playerOneLabel, _playerTwoLabel, _versusLabel]
        playersUILabels.forEach { $0.layer.removeAllAnimations() }
        
        let playerLabelsOffset = CGFloat(20.0)
        var animations: [CAAnimation] = []
        
        let fadeAnimation = CABasicAnimation(keyPath: "opacity")
        fadeAnimation.fromValue = CGFloat(active ? 0.0 : 1.0)
        fadeAnimation.toValue = CGFloat(active ? 1.0 : 0.0)
        animations.append(fadeAnimation)
        
        let playerOneAnimation = CABasicAnimation(keyPath: "transform.translation.x")
        playerOneAnimation.fromValue = (active ? -playerLabelsOffset : 0.0)
        playerOneAnimation.toValue = (active ? 0.0 : -playerLabelsOffset)
        animations.append(playerOneAnimation)
        
        let playerTwoAnimation = CABasicAnimation(keyPath: "transform.translation.x")
        playerTwoAnimation.fromValue = (active ? playerLabelsOffset : 0.0)
        playerTwoAnimation.toValue = (active ? 0.0 : playerLabelsOffset)
        animations.append(playerTwoAnimation)
        
        playersUILabels.forEach { $0.layer.opacity = (active ? 0.0 : 1.0) }
        
        // set common properties on the animations
        for animation in animations {
            animation.duration = transition.duration
            animation.beginTime = CACurrentMediaTime() + transition.delay
            animation.timingFunction = CAMediaTimingFunction(name: (active ? kCAMediaTimingFunctionEaseOut : kCAMediaTimingFunctionEaseIn))
        }
        
        playersUILabels.forEach { $0.layer.add(fadeAnimation, forKey: nil) }
        _playerOneLabel.layer.add(playerOneAnimation, forKey: nil)
        _playerTwoLabel.layer.add(playerTwoAnimation, forKey: nil)
        
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.secondsFromNow(transition.delay)) {
            playersUILabels.forEach { $0.layer.opacity = (active ? 1.0 : 0.0) }
        }
    }
    
    internal func _inTransaction_transitionCardViews(active: Bool, transition: Transition)
    {
        _cardViews.forEach { $0.layer.removeAllAnimations() }
        
        let cardViewsOffset = CGFloat(100.0)
        let timingFunction = CAMediaTimingFunction(name: (active ? kCAMediaTimingFunctionEaseOut : kCAMediaTimingFunctionEaseIn))
        var animations: [CAAnimation] = []
        
        let fadeAnimation = CABasicAnimation(keyPath: "opacity")
        fadeAnimation.fromValue = CGFloat(active ? 0.0 : 1.0)
        fadeAnimation.toValue = CGFloat(active ? 1.0 : 0.0)
        fadeAnimation.duration = transition.duration
        fadeAnimation.timingFunction = timingFunction
        fadeAnimation.fillMode = kCAFillModeForwards
        animations.append(fadeAnimation)
        
        let transformAnimation = CABasicAnimation(keyPath: "transform.translation.y")
        transformAnimation.fromValue = (active ? cardViewsOffset : 0.0)
        transformAnimation.toValue = (active ? 0.0 : cardViewsOffset)
        transformAnimation.duration = transition.duration
        transformAnimation.timingFunction = timingFunction
        transformAnimation.fillMode = kCAFillModeForwards
        animations.append(transformAnimation)
        
        _cardViews.forEach { $0.layer.opacity = (active ? 0.0 : 1.0) }
        
        let delayStagger = 0.1
        var delayOffset = 0.0
        for cardView in _cardViews {
            let beginTime = CACurrentMediaTime() + transition.delay + delayOffset
            
            let cardTransformAnimation = transformAnimation.copy() as! CABasicAnimation
            cardTransformAnimation.beginTime = beginTime
            
            let cardFadeAnimation = fadeAnimation.copy() as! CABasicAnimation
            cardFadeAnimation.beginTime = beginTime
            
            cardView.layer.add(cardFadeAnimation, forKey: nil)
            cardView.layer.add(cardTransformAnimation, forKey: nil)
            
            delayOffset += delayStagger
            
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.secondsFromNow(transition.delay + delayOffset)) {
                cardView.layer.opacity = (active ? 1.0 : 0.0)
            }
        }
    }
    
    internal func _transitionToActive(active: Bool, transition: Transition, completion: @escaping (Bool) -> Void)
    {
        _transitionInProgress = true
        
        CATransaction.begin()
        CATransaction.setCompletionBlock {
            completion(true)
            
            self._active = active
            self._transitionInProgress = false
            self._reloadFromCachedResult()
        }
        
        _inTransaction_transitionPlayersUI(active: active, transition: transition)
        _inTransaction_transitionCardViews(active: active, transition: transition)
        
        CATransaction.commit()
    }
}

A Amalgamation/Source/Rhythm Game Support/DDRCardView.swift => Amalgamation/Source/Rhythm Game Support/DDRCardView.swift +179 -0
@@ 0,0 1,179 @@
//
//  DDRCardView.swift
//  Amalgamation
//
//  Created by Charles Magahern on 9/21/18.
//

import Foundation
import UIKit

class DDRCardView : UIView
{
    private let _containerView:     UIView = UIView()
    private let _titleLabel:        UILabel = UILabel()
    private let _artistLabel:       UILabel = UILabel()
    private let _bpmLabel:          UILabel = UILabel()
    private let _difficultyLabel:   UILabel = UILabel()
    
    override init(frame: CGRect)
    {
        super.init(frame: frame)
        
        self.addSubview(_containerView)
        
        let textAttributes = Theme.mainTheme.bodyTextAttributes
        let font = textAttributes[NSAttributedStringKey.font] as! UIFont
        let textColor = textAttributes[NSAttributedStringKey.foregroundColor] as! UIColor
        
        // set common properties, add labels as subviews
        for label in [_titleLabel, _artistLabel, _bpmLabel, _difficultyLabel] {
            label.font = font
            label.textColor = textColor
            
            _containerView.addSubview(label)
        }
        
        let boldFontDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold)!
        _titleLabel.numberOfLines = 2
        _titleLabel.font = UIFont(descriptor: boldFontDescriptor, size: 28.0)
        _titleLabel.textAlignment = .center
        
        _artistLabel.numberOfLines = 2
        _artistLabel.font = font.withSize(22.0)
        _artistLabel.textAlignment = .center
    }
    
    required init?(coder aDecoder: NSCoder)
    {
        fatalError("unsupported")
    }
    
    // MARK: Accessors
    
    var song: DDRSong?
    {
        didSet
        {
            _titleLabel.text = song?.title
            _artistLabel.text = song?.artist
            _bpmLabel.text = String(format: "%@ BPM", (song?.bpm ?? "?"))
            _difficultyLabel.text = song?.chart?.difficultyDescription
            
            _containerView.backgroundColor = song?.chart?.difficultyColor
            
            if song?.vetoed ?? false {
                _containerView.alpha = 0.5
            } else {
                _containerView.alpha = 1.0
            }
            
            self.setNeedsLayout()
        }
    }
    
    // MARK: UIView
    
    override func layoutSubviews()
    {
        super.layoutSubviews()
        
        let bounds = self.bounds
        let edgePadding = CGFloat(5.0)
        let titleArtistPadding = CGFloat(5.0)
        
        _containerView.frame = bounds
        
        let titleLabelSize = _titleLabel.sizeThatFits(bounds.size)
        let artistLabelSize = _artistLabel.sizeThatFits(bounds.size)
        let titleArtistTotalHeight = (
            titleLabelSize.height +
            titleArtistPadding +
            artistLabelSize.height
        )
        
        let titleLabelFrame = CGRect(
            x: rint(bounds.size.width / 2.0 - titleLabelSize.width / 2.0),
            y: rint(bounds.size.height / 2.0 - titleArtistTotalHeight / 2.0),
            width: titleLabelSize.width,
            height: titleLabelSize.height
        )
        _titleLabel.frame = titleLabelFrame
        
        let artistLabelFrame = CGRect(
            x: rint(bounds.size.width / 2.0 - artistLabelSize.width / 2.0),
            y: titleLabelFrame.maxY + titleArtistPadding,
            width: artistLabelSize.width,
            height: artistLabelSize.height
        )
        _artistLabel.frame = artistLabelFrame
        
        let bpmLabelSize = _bpmLabel.sizeThatFits(bounds.size)
        let bpmLabelFrame = CGRect(
            x: edgePadding,
            y: rint(bounds.size.height - edgePadding - bpmLabelSize.height),
            width: bpmLabelSize.width,
            height: bpmLabelSize.height
        )
        _bpmLabel.frame = bpmLabelFrame
        
        let difficultyLabelSize = _difficultyLabel.sizeThatFits(bounds.size)
        let difficultyLabelFrame = CGRect(
            x: rint(bounds.size.width - edgePadding - difficultyLabelSize.width),
            y: rint(bounds.size.height - edgePadding - bpmLabelSize.height),
            width: difficultyLabelSize.width,
            height: difficultyLabelSize.height
        )
        _difficultyLabel.frame = difficultyLabelFrame
    }
}

extension DDRChart
{
    var difficultyDescription: String
    {
        get
        {
            var difficultyKey: String!
            
            switch (self.difficulty) {
            case .beginner:
                difficultyKey = "DDR_DIFFICULTY_SHORT_BEGINNER"
            case .basic:
                difficultyKey = "DDR_DIFFICULTY_SHORT_BASIC"
            case .difficult:
                difficultyKey = "DDR_DIFFICULTY_SHORT_DIFFICULT"
            case .expert:
                difficultyKey = "DDR_DIFFICULTY_SHORT_EXPERT"
            case .challenge:
                difficultyKey = "DDR_DIFFICULTY_SHORT_CHALLENGE"
            }
            
            let difficultyName = NSLocalizedString(difficultyKey, comment: "")
            return String(format: "%d %@", self.rating, difficultyName)
        }
    }
    
    var difficultyColor: UIColor
    {
        get
        {
            var color: UIColor!
            
            switch (self.difficulty) {
            case .beginner:
                color = #colorLiteral(red: 0.1411764771, green: 0.3960784376, blue: 0.5647059083, alpha: 1)
            case .basic:
                color = #colorLiteral(red: 0.7254902124, green: 0.4784313738, blue: 0.09803921729, alpha: 1)
            case .difficult:
                color = #colorLiteral(red: 0.4392156899, green: 0.01176470611, blue: 0.1921568662, alpha: 1)
            case .expert:
                color = #colorLiteral(red: 0.2745098174, green: 0.4862745106, blue: 0.1411764771, alpha: 1)
            case .challenge:
                color = #colorLiteral(red: 0.3647058904, green: 0.06666667014, blue: 0.9686274529, alpha: 1)
            }
            
            return color
        }
    }
}

A Amalgamation/Source/Utility/DispatchExtensions.swift => Amalgamation/Source/Utility/DispatchExtensions.swift +16 -0
@@ 0,0 1,16 @@
//
//  DispatchExtensions.swift
//  Amalgamation
//
//  Created by Charles Magahern on 9/21/18.
//

import Foundation

extension DispatchTime
{
    static func secondsFromNow(_ seconds: Double) -> DispatchTime
    {
        return DispatchTime.now() + .milliseconds(Int(seconds * 1000.0))
    }
}

A Amalgamation/Source/Utility/ServerOperation.swift => Amalgamation/Source/Utility/ServerOperation.swift +57 -0
@@ 0,0 1,57 @@
//
//  ServerOperation.swift
//  Amalgamation
//
//  Created by Charles Magahern on 9/22/18.
//

import Foundation

class ServerOperation : Operation
{
    var baseURL:             URL
    var session:             URLSession
    internal(set) var error: Error?
    
    init(baseURL: URL, session: URLSession)
    {
        self.baseURL = baseURL
        self.session = session
    }
    
    func fetchData(_ url: URL) -> Data?
    {
        var fetchedData: Data?
        
        let semaphore = Semaphore(value: 0)
        let task = self.session.dataTask(with: url, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) -> Void in
            fetchedData = data
            self.error = error
            
            semaphore.signal()
        })
        task.resume()
        
        semaphore.wait()
        return fetchedData
    }
    
    func fetchResponse(_ request: URLRequest) -> (Data?, URLResponse?)
    {
        var fetchedData: Data?
        var fetchedResponse: URLResponse?
        
        let semaphore = Semaphore(value: 0)
        let task = self.session.dataTask(with: request, completionHandler: { (data: Data?, response: URLResponse?, error: Error?) in
            fetchedResponse = response
            fetchedData = data
            self.error = error
            
            semaphore.signal()
        })
        task.resume()
        
        semaphore.wait()
        return (fetchedData, fetchedResponse)
    }
}

A Amalgamation/Source/Utility/TournamentScreen.swift => Amalgamation/Source/Utility/TournamentScreen.swift +32 -0
@@ 0,0 1,32 @@
//
//  TournamentScreen.swift
//  Amalgamation
//
//  Created by Charles Magahern on 9/22/18.
//

import Foundation
import UIKit

protocol CustomTournamentScreen : Transitionable
{
    var shouldShowScreen: Bool { get }
}

struct TournamentScreenConfiguration
{
    var visibleMatchesRange: CountableClosedRange<Int>
    var interstitialView:    InterstitialView
}

struct TournamentScreen
{
    enum Content
    {
        case configuration(TournamentScreenConfiguration)
        case viewController(UIViewController & CustomTournamentScreen)
    }
    
    var interstitialView:   InterstitialView
    var content:            Content
}

M Amalgamation/Source/Utility/Transitionable.swift => Amalgamation/Source/Utility/Transitionable.swift +12 -0
@@ 16,6 16,18 @@ struct Transition

protocol Transitionable : class
{
    func prepareToTransitionToActive()
    func transitionToActive(transition: Transition, completion: @escaping (Bool) -> Void)
    
    func prepareToTransitionToInactive()
    func transitionToInactive(transition: Transition, completion: @escaping (Bool) -> Void)
}

extension Transitionable
{
    func prepareToTransitionToInactive()
    {}
    
    func prepareToTransitionToActive()
    {}
}

M Amalgamation/Source/View Controllers/TournamentViewController.swift => Amalgamation/Source/View Controllers/TournamentViewController.swift +152 -72
@@ 7,12 7,6 @@

import UIKit

internal struct TournamentScreenConfiguration
{
    var visibleMatchesRange: CountableClosedRange<Int>
    var interstitialView:    InterstitialView
}

class TournamentViewController : UIViewController, Transitionable
{
    enum ScreenState


@@ 32,10 26,12 @@ class TournamentViewController : UIViewController, Transitionable
    private let _tournamentBracketView:       TournamentBracketView         = TournamentBracketView()
    private let _visualizationViewController: VisualizationViewController   = VisualizationViewController()
    
    private var _currentScreenCustomView:     UIView?
    
    private var _lastUpdatedTournamentData:   Tournament?
    private var _tournamentDataUpdateTimer:   Timer?
    
    private var _screenConfigurations:        [TournamentScreenConfiguration] = [] // loaded in viewDidLoad()
    private var _screens:                     [TournamentScreen] = [] // loaded in viewDidLoad()
    private var _currentScreenIndex:          Int = -1
    private var _currentScreenState:          ScreenState = .screenVisible
    private var _screenTimer:                 Timer?


@@ 131,26 127,43 @@ class TournamentViewController : UIViewController, Transitionable
            view.addSubview(_competitivePlayersView)
            view.addSubview(_tournamentBracketView)
            view.addSubview(_visualizationViewController.view)

            
            // initialize screen configurations
            let mainBracketInterstitial = InterstitialView()
            mainBracketInterstitial.text = NSLocalizedString("MAIN_BRACKET_INTERSTITIAL", comment: "")
            mainBracketInterstitial.image = UIImage(named: "MainBracket")?.withRenderingMode(.alwaysTemplate)
            
            let mainBracketScreen = TournamentScreen(
                interstitialView: mainBracketInterstitial,
                content: TournamentScreen.Content.configuration(TournamentScreenConfiguration(
                    visibleMatchesRange: 0...Int.max,
                    interstitialView: mainBracketInterstitial
                ))
            )
            
            let losersBracketInterstitial = InterstitialView()
            losersBracketInterstitial.text = NSLocalizedString("LOSERS_BRACKET_INTERSTITIAL", comment: "")
            losersBracketInterstitial.image = UIImage(named: "LosersBracket")?.withRenderingMode(.alwaysTemplate)
            
            let mainBracketConfiguration = TournamentScreenConfiguration(
                visibleMatchesRange: 0...Int.max,
                interstitialView: mainBracketInterstitial
            let losersBracketScreen = TournamentScreen(
                interstitialView: losersBracketInterstitial,
                content: TournamentScreen.Content.configuration(TournamentScreenConfiguration(
                    visibleMatchesRange: Int.min...0,
                    interstitialView: losersBracketInterstitial
                ))
            )
            let losersBracketConfiguration = TournamentScreenConfiguration(
                visibleMatchesRange: Int.min...0,
                interstitialView: losersBracketInterstitial
            
            let ddrCardDrawInterstitial = InterstitialView()
            ddrCardDrawInterstitial.text = NSLocalizedString("DDR_CARD_DRAW_INTERSTITIAL", comment: "")
            ddrCardDrawInterstitial.image = UIImage(named: "CardDraw")?.withRenderingMode(.alwaysTemplate)
            
            let ddrCardDrawViewController = DDRCardDrawViewController()
            let ddrCardDrawScreen = TournamentScreen(
                interstitialView: ddrCardDrawInterstitial,
                content: TournamentScreen.Content.viewController(ddrCardDrawViewController)
            )
            
            _screenConfigurations = [mainBracketConfiguration, losersBracketConfiguration]
            _screens = [mainBracketScreen, losersBracketScreen, ddrCardDrawScreen]
            _setCurrentScreenIndex(index: 0, animated: false)

            // setup tap gesture recognizer for advancing state


@@ 261,6 274,9 @@ class TournamentViewController : UIViewController, Transitionable
        )
        _tournamentBracketView.frame = tournamentBracketViewFrame
        
        // custom screen view container (if exists)
        _currentScreenCustomView?.frame = tournamentBracketViewFrame
        
        // interstitial view (if exists)
        if let currentInterstitialView = _currentInterstitialView {
            let interstitialBoundsSize = CGSize(


@@ 370,7 386,7 @@ class TournamentViewController : UIViewController, Transitionable
        
        let interval = TournamentViewController._screenChangeTimerInterval
        _screenTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: { [weak self] (_) in
            self?._advanceToNextTournamentScreen()
            self?._advanceToNextAvailableTournamentScreen()
        })
    }
    


@@ 415,16 431,10 @@ class TournamentViewController : UIViewController, Transitionable
                                  loadMatches: true) { (tournament: Tournament?, error: Error?) in
                if let updatedTournament = tournament {
                    self?._lastUpdatedTournamentData = updatedTournament
                } else if let error = error {
                    StandardErrorOutputStream.shared.write("ERROR: \(error.localizedDescription)\n")
                } else {
                    let alertTitle = NSLocalizedString("TOURNAMENT_LOAD_ERROR_TITLE", comment: "")
                    let alertMessage = error?.localizedDescription ?? NSLocalizedString("UNKNOWN_ERROR", comment: "")
                    let alert = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert)
                    alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: { (action: UIAlertAction) in
                        self?.dismiss(animated: true, completion: nil)
                    }))
                    
                    self?.present(alert, animated: true, completion: nil)
                    self?.navigationController?.popViewController(animated: true) // pop to settings on error
                    StandardErrorOutputStream.shared.write("Unknown error occurred")
                }
            }
        }


@@ 455,72 465,142 @@ class TournamentViewController : UIViewController, Transitionable
        return countInRange
    }
    
    internal func _shouldShowScreen(_ screen: TournamentScreen) -> Bool
    {
        switch (screen.content) {
        case .configuration(let configuration):
            return (_tournamentMatchesCount(inRange: configuration.visibleMatchesRange) > 0)
        case .viewController(let viewController):
            return viewController.shouldShowScreen
        }
    }
    
    internal func _transitionableForScreen(_ screen: TournamentScreen) -> Transitionable
    {
        switch (screen.content) {
        case .configuration:
            return self._tournamentBracketView
        case .viewController(let viewController):
            return viewController
        }
    }
    
    internal func _transitionOutScreen(screen: TournamentScreen, completion: @escaping (Bool) -> Void)
    {
        let transitionable = _transitionableForScreen(screen)
        let outTransition = Transition(
            duration: Theme.mainTheme.transitionDuration,
            delay: 0.0,
            options: [.curveEaseIn, .beginFromCurrentState]
        )
        
        transitionable.prepareToTransitionToInactive()
        
        _currentScreenState = .screenExiting
        transitionable.transitionToInactive(transition: outTransition, completion: completion)
    }
    
    internal func _prepareScreen(_ screen: TournamentScreen)
    {
        switch (screen.content) {
        case .configuration(let configuration):
            self._currentScreenCustomView?.removeFromSuperview()
            self._tournamentBracketView.isHidden = false
            
            self._tournamentBracketView.visibleMatchesRange = configuration.visibleMatchesRange
            self._tournamentBracketView.scrollToLatestVisibleRound(animated: false)
        
        case .viewController(let viewController):
            self._tournamentBracketView.isHidden = true
        
            if viewController.parent == nil {
                self.addChildViewController(viewController)
            }
            
            self._currentScreenCustomView = viewController.view
            self.view.addSubview(viewController.view)
            self.view.setNeedsLayout()
        }
    }
    
    internal func _transitionInScreen(_ screen: TournamentScreen, setupCallback: @escaping () -> Void)
    {
        let transitionable = _transitionableForScreen(screen)
        
        let inTransition = Transition(
            duration: Theme.mainTheme.transitionDuration,
            delay: 0.0,
            options: [.curveEaseOut, .beginFromCurrentState]
        )
        
        let outTransition = Transition(
            duration: Theme.mainTheme.transitionDuration,
            delay: 0.0,
            options: [.curveEaseIn, .beginFromCurrentState]
        )
        
        _currentInterstitialView = screen.interstitialView
        _currentInterstitialView?.layoutIfNeeded()
        
        _currentScreenState = .interstitialEntering
        _currentInterstitialView?.transitionToActive(transition: inTransition, completion: { (_) in
            // while the interstitial is visible, reload remote data
            self._reloadTournamentDataUsingCache()
            transitionable.prepareToTransitionToActive()
            
            self._currentScreenState = .interstitialExiting
            self._currentInterstitialView?.transitionToInactive(transition: outTransition, completion: { (_) in
                self._currentInterstitialView = nil
                setupCallback()
                
                self._currentScreenState = .screenEntering
                transitionable.transitionToActive(transition: inTransition, completion: { (_) in
                    self._currentScreenState = .screenVisible
                })
            })
        })
    }
    
    internal func _setCurrentScreenIndex(index: Int, animated: Bool)
    {
        guard let nextScreenConfiguration = _screenConfigurations[safe: index] else { return }
        guard let nextScreen = _screens[safe: index] else { return }
        let currentScreen = _screens[safe: _currentScreenIndex]
        
        // don't show the next screen if there isn't any content visible
        if _tournamentMatchesCount(inRange: nextScreenConfiguration.visibleMatchesRange) == 0 {
        if !_shouldShowScreen(nextScreen) {
            return
        }
        
        let setupNextScreenBlock = {
            self._tournamentBracketView.visibleMatchesRange = nextScreenConfiguration.visibleMatchesRange
            self._tournamentBracketView.scrollToLatestVisibleRound(animated: false)
            self._prepareScreen(nextScreen)
            self._currentScreenIndex = index
        }
        
        if animated {
            let theme = Theme.mainTheme
            let outTransition = Transition(
                duration: theme.transitionDuration,
                delay: 0.0,
                options: [.curveEaseIn, .beginFromCurrentState]
            )
            let inTransition = Transition(
                duration: theme.transitionDuration,
                delay: 0.0,
                options: [.curveEaseOut, .beginFromCurrentState]
            )
            
            _currentScreenState = .screenExiting
            _tournamentBracketView.transitionToInactive(transition: outTransition) { (_) in
                self._currentInterstitialView = nextScreenConfiguration.interstitialView
                self._currentInterstitialView?.layoutIfNeeded()
                
                var interstitialTransitionIn = outTransition
                interstitialTransitionIn.duration = 0.5
                
                self._currentScreenState = .interstitialEntering
                self._currentInterstitialView?.transitionToActive(transition: inTransition, completion: { (_) in
                    // while the interstitial is visible, reload the tournament data
                    self._reloadTournamentDataUsingCache()
                    
                    var interstitialTransitionOut = interstitialTransitionIn
                    interstitialTransitionOut.delay = 0.1
                    
                    self._currentScreenState = .interstitialExiting
                    self._currentInterstitialView?.transitionToInactive(transition: interstitialTransitionOut, completion: { (_) in
                        self._currentInterstitialView = nil
                        setupNextScreenBlock()

                        self._currentScreenState = .screenEntering
                        self._tournamentBracketView.transitionToActive(transition: inTransition, completion: { (_) in
                            self._currentScreenState = .screenVisible
                        })
                    })
                })
            if let currentScreen = currentScreen {
                self._transitionOutScreen(screen: currentScreen) { (_) in
                    self._transitionInScreen(nextScreen, setupCallback: setupNextScreenBlock)
                }
            } else {
                self._transitionInScreen(nextScreen, setupCallback: setupNextScreenBlock)
            }
        } else {
            setupNextScreenBlock()
        }
    }
    
    internal func _advanceToNextTournamentScreen()
    internal func _advanceToNextAvailableTournamentScreen()
    {
        if _currentScreenState == .screenVisible {
            let nextIndex = (_currentScreenIndex + 1) % _screenConfigurations.count
            _setCurrentScreenIndex(index: nextIndex, animated: true)
            var nextScreen: TournamentScreen?
            var screenIndex = _currentScreenIndex
            
            repeat {
                screenIndex = (screenIndex + 1) % _screens.count
                nextScreen = _screens[screenIndex]
            } while (nextScreen != nil ? !_shouldShowScreen(nextScreen!) : false)
            
            _setCurrentScreenIndex(index: screenIndex, animated: true)
        }
    }
    


@@ 528,7 608,7 @@ class TournamentViewController : UIViewController, Transitionable
    {
        if _currentScreenState == .screenVisible {
            _resetTournamentScreenAdvanceTimer()
            _advanceToNextTournamentScreen()
            _advanceToNextAvailableTournamentScreen()
        }
    }
}

M Amalgamation/Source/Views/InterstitialView.swift => Amalgamation/Source/Views/InterstitialView.swift +2 -4
@@ 134,13 134,11 @@ class InterstitialView : UIView, Transitionable
        
        CATransaction.commit()
        
        let imageAnimDelayDispatch = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(transition.delay * 1000.0))
        DispatchQueue.main.asyncAfter(deadline: imageAnimDelayDispatch) {
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.secondsFromNow(transition.delay)) {
            self._imageView.layer.opacity = (active ? 1.0 : 0.0)
        }
        
        let labelAnimDelayDispatch = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int((transition.delay + labelAnimDelay) * 1000.0))
        DispatchQueue.main.asyncAfter(deadline: labelAnimDelayDispatch) {
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.secondsFromNow(transition.delay + labelAnimDelay)) {
            self._label.layer.opacity = (active ? 1.0 : 0.0)
        }
    }

M Amalgamation/Source/Views/TournamentBracketView.swift => Amalgamation/Source/Views/TournamentBracketView.swift +1 -1
@@ 214,7 214,7 @@ class TournamentBracketView : UIView, UIScrollViewDelegate, Transitionable
        _bracketScrollView.contentSize = maxSize
        _bracketScrollView.contentInset = UIEdgeInsets(
            top: 0.0,
            left: maxSize.width / 4.0,
            left: bounds.size.width / 2.0 - maxSize.width / 2.0,
            bottom: 0.0,
            right: 0.0
        )

M Amalgamation/Source/Views/TournamentStateView.swift => Amalgamation/Source/Views/TournamentStateView.swift +6 -2
@@ 222,7 222,9 @@ internal class TournamentStateIndicator : UIView
    {
        didSet
        {
            _reloadAnimationState()
            if oldValue != animating {
                _reloadAnimationState()
            }
        }
    }
    


@@ 230,7 232,9 @@ internal class TournamentStateIndicator : UIView
    {
        didSet
        {
            _reloadAnimationState()
            if oldValue != tournamentState {
                _reloadAnimationState()
            }
        }
    }
    

M Amalgamation/Supporting Files/Info.plist => Amalgamation/Supporting Files/Info.plist +6 -1
@@ 15,11 15,16 @@
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleShortVersionString</key>
	<string>1.0</string>
	<string>1.1</string>
	<key>CFBundleVersion</key>
	<string>1</string>
	<key>LSRequiresIPhoneOS</key>
	<true/>
	<key>NSAppTransportSecurity</key>
	<dict>
		<key>NSAllowsArbitraryLoads</key>
		<true/>
	</dict>
	<key>UIAppFonts</key>
	<array>
		<string>Karla-Italic.ttf</string>

M Amalgamation/Supporting Files/en.lproj/Localizable.strings => Amalgamation/Supporting Files/en.lproj/Localizable.strings +16 -0
@@ 32,11 32,15 @@
"ROUND_ORDINAL_FORMAT" = "Round %d";
"LOSERS_ROUND_ORDINAL_FORMAT" = "Losers Round %d";

"MATCH_PLAYERS_VERSUS_FORMAT" = "%@ vs. %@";
"MATCH_UNKNOWN_PLAYER_NAME" = "???";

"TOURNAMENT_COMPLETION_LABEL" = "Completion";
"COMPETITIVE_PLAYERS_LABEL" = "Competitive Players";

"MAIN_BRACKET_INTERSTITIAL" = "Main Bracket";
"LOSERS_BRACKET_INTERSTITIAL" = "Loser's Bracket";
"DDR_CARD_DRAW_INTERSTITIAL" = "Current Match";

"TOURNAMENT_ID_PLACEHOLDER" = "Tournament ID";
"TOURNAMENT_ID_HELP_TEXT" = "The tournament identifier is the last part of the Challonge tournament URL. For example, if the URL is \"challonge.com/ddrsummer2017\", then the tournament ID is \"ddrsummer2017\".";


@@ 44,3 48,15 @@
"REMOVE_BUTTON_TEXT" = "Remove";

"TOURNAMENT_LOAD_ERROR_TITLE" = "Load Failed";

"DDR_DIFFICULTY_BEGINNER" = "Beginner";
"DDR_DIFFICULTY_BASIC" = "Basic";
"DDR_DIFFICULTY_DIFFICULT" = "Difficult";
"DDR_DIFFICULTY_EXPERT" = "Expert";
"DDR_DIFFICULTY_CHALLENGE" = "Challenge";

"DDR_DIFFICULTY_SHORT_BEGINNER" = "BEG";
"DDR_DIFFICULTY_SHORT_BASIC" = "BAS";
"DDR_DIFFICULTY_SHORT_DIFFICULTY" = "DIF";
"DDR_DIFFICULTY_SHORT_EXPERT" = "EX";
"DDR_DIFFICULTY_SHORT_CHALLENGE" = "CH";