Runs SwiftLint and adds back OpenGLES

This commit is contained in:
Joe Diragi 2022-04-30 19:34:11 -04:00
parent a6bcd668d5
commit b43abf1548
3 changed files with 562 additions and 567 deletions

View File

@ -16,301 +16,298 @@ let INADDR_BROADCAST = in_addr(s_addr: 0xFFFF_FFFF)
/// An object representing the UDP broadcast connection. Uses a dispatch source to handle the incoming traffic on the UDP socket. /// An object representing the UDP broadcast connection. Uses a dispatch source to handle the incoming traffic on the UDP socket.
open class UDPBroadcastConnection { open class UDPBroadcastConnection {
// MARK: Properties
// MARK: Properties /// The address of the UDP socket.
var address: sockaddr_in
/// The address of the UDP socket. /// Type of a closure that handles incoming UDP packets.
var address: sockaddr_in public typealias ReceiveHandler = (_ ipAddress: String, _ port: Int, _ response: Data) -> Void
/// Closure that handles incoming UDP packets.
var handler: ReceiveHandler?
/// Type of a closure that handles incoming UDP packets. /// Type of a closure that handles errors that were encountered during receiving UDP packets.
public typealias ReceiveHandler = (_ ipAddress: String, _ port: Int, _ response: Data) -> Void public typealias ErrorHandler = (_ error: ConnectionError) -> Void
/// Closure that handles incoming UDP packets. /// Closure that handles errors that were encountered during receiving UDP packets.
var handler: ReceiveHandler? var errorHandler: ErrorHandler?
/// Type of a closure that handles errors that were encountered during receiving UDP packets. /// A dispatch source for reading data from the UDP socket.
public typealias ErrorHandler = (_ error: ConnectionError) -> Void var responseSource: DispatchSourceRead?
/// Closure that handles errors that were encountered during receiving UDP packets.
var errorHandler: ErrorHandler?
/// A dispatch source for reading data from the UDP socket. /// The dispatch queue to run responseSource & reconnection on
var responseSource: DispatchSourceRead? var dispatchQueue = DispatchQueue.main
/// The dispatch queue to run responseSource & reconnection on /// Bind to port to start listening without first sending a message
var dispatchQueue = DispatchQueue.main var shouldBeBound: Bool = false
/// Bind to port to start listening without first sending a message // MARK: Initializers
var shouldBeBound: Bool = false
// MARK: Initializers /// Initializes the UDP connection with the correct port address.
/// Initializes the UDP connection with the correct port address. /// - Note: This doesn't open a socket! The socket is opened transparently as needed when sending broadcast messages. If you want to open a socket immediately, use the `bindIt` parameter. This will also try to reopen the socket if it gets closed.
///
/// - Parameters:
/// - port: Number of the UDP port to use.
/// - bindIt: Opens a port immediately if true, on demand if false. Default is false.
/// - handler: Handler that gets called when data is received.
/// - errorHandler: Handler that gets called when an error occurs.
/// - Throws: Throws a `ConnectionError` if an error occurs.
public init(port: UInt16, bindIt: Bool = false, handler: ReceiveHandler?, errorHandler: ErrorHandler?) throws {
self.address = sockaddr_in(sin_len: __uint8_t(MemoryLayout<sockaddr_in>.size),
sin_family: sa_family_t(AF_INET),
sin_port: UDPBroadcastConnection.htonsPort(port: port),
sin_addr: INADDR_BROADCAST,
sin_zero: (0, 0, 0, 0, 0, 0, 0, 0))
/// - Note: This doesn't open a socket! The socket is opened transparently as needed when sending broadcast messages. If you want to open a socket immediately, use the `bindIt` parameter. This will also try to reopen the socket if it gets closed. self.handler = handler
/// self.errorHandler = errorHandler
/// - Parameters: self.shouldBeBound = bindIt
/// - port: Number of the UDP port to use. if bindIt {
/// - bindIt: Opens a port immediately if true, on demand if false. Default is false. try createSocket()
/// - handler: Handler that gets called when data is received. }
/// - errorHandler: Handler that gets called when an error occurs. }
/// - Throws: Throws a `ConnectionError` if an error occurs.
public init(port: UInt16, bindIt: Bool = false, handler: ReceiveHandler?, errorHandler: ErrorHandler?) throws {
self.address = sockaddr_in(sin_len: __uint8_t(MemoryLayout<sockaddr_in>.size),
sin_family: sa_family_t(AF_INET),
sin_port: UDPBroadcastConnection.htonsPort(port: port),
sin_addr: INADDR_BROADCAST,
sin_zero: (0, 0, 0, 0, 0, 0, 0, 0))
self.handler = handler deinit {
self.errorHandler = errorHandler if responseSource != nil {
self.shouldBeBound = bindIt responseSource!.cancel()
if bindIt { }
try createSocket() }
}
}
deinit { // MARK: Interface
if responseSource != nil {
responseSource!.cancel()
}
}
// MARK: Interface /// Create a UDP socket for broadcasting and set up cancel and event handlers
///
/// - Throws: Throws a `ConnectionError` if an error occurs.
fileprivate func createSocket() throws {
// Create new socket
let newSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
guard newSocket > 0 else { throw ConnectionError.createSocketFailed }
/// Create a UDP socket for broadcasting and set up cancel and event handlers // Enable broadcast on socket
/// var broadcastEnable = Int32(1)
/// - Throws: Throws a `ConnectionError` if an error occurs. let ret = setsockopt(newSocket, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, socklen_t(MemoryLayout<UInt32>.size))
fileprivate func createSocket() throws { if ret == -1 {
debugPrint("Couldn't enable broadcast on socket")
close(newSocket)
throw ConnectionError.enableBroadcastFailed
}
// Create new socket // Bind socket if needed
let newSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) if shouldBeBound {
guard newSocket > 0 else { throw ConnectionError.createSocketFailed } var saddr = sockaddr(sa_len: 0, sa_family: 0,
sa_data: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
address.sin_addr = INADDR_ANY
memcpy(&saddr, &address, MemoryLayout<sockaddr_in>.size)
address.sin_addr = INADDR_BROADCAST
let isBound = bind(newSocket, &saddr, socklen_t(MemoryLayout<sockaddr_in>.size))
if isBound == -1 {
debugPrint("Couldn't bind socket")
close(newSocket)
throw ConnectionError.bindSocketFailed
}
}
// Enable broadcast on socket // Disable global SIGPIPE handler so that the app doesn't crash
var broadcastEnable = Int32(1) setNoSigPipe(socket: newSocket)
let ret = setsockopt(newSocket, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, socklen_t(MemoryLayout<UInt32>.size))
if ret == -1 {
debugPrint("Couldn't enable broadcast on socket")
close(newSocket)
throw ConnectionError.enableBroadcastFailed
}
// Bind socket if needed // Set up a dispatch source
if shouldBeBound { let newResponseSource = DispatchSource.makeReadSource(fileDescriptor: newSocket, queue: dispatchQueue)
var saddr = sockaddr(sa_len: 0, sa_family: 0,
sa_data: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
self.address.sin_addr = INADDR_ANY
memcpy(&saddr, &self.address, MemoryLayout<sockaddr_in>.size)
self.address.sin_addr = INADDR_BROADCAST
let isBound = bind(newSocket, &saddr, socklen_t(MemoryLayout<sockaddr_in>.size))
if isBound == -1 {
debugPrint("Couldn't bind socket")
close(newSocket)
throw ConnectionError.bindSocketFailed
}
}
// Disable global SIGPIPE handler so that the app doesn't crash // Set up cancel handler
setNoSigPipe(socket: newSocket) newResponseSource.setCancelHandler {
// debugPrint("Closing UDP socket")
let UDPSocket = Int32(newResponseSource.handle)
shutdown(UDPSocket, SHUT_RDWR)
close(UDPSocket)
}
// Set up a dispatch source // Set up event handler (gets called when data arrives at the UDP socket)
let newResponseSource = DispatchSource.makeReadSource(fileDescriptor: newSocket, queue: dispatchQueue) newResponseSource.setEventHandler { [unowned self] in
guard let source = self.responseSource else { return }
// Set up cancel handler var socketAddress = sockaddr_storage()
newResponseSource.setCancelHandler { var socketAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
// debugPrint("Closing UDP socket") let response = [UInt8](repeating: 0, count: 4096)
let UDPSocket = Int32(newResponseSource.handle) let UDPSocket = Int32(source.handle)
shutdown(UDPSocket, SHUT_RDWR)
close(UDPSocket)
}
// Set up event handler (gets called when data arrives at the UDP socket)
newResponseSource.setEventHandler { [unowned self] in
guard let source = self.responseSource else { return }
var socketAddress = sockaddr_storage()
var socketAddressLength = socklen_t(MemoryLayout<sockaddr_storage>.size)
let response = [UInt8](repeating: 0, count: 4096)
let UDPSocket = Int32(source.handle)
let pointer = UnsafeMutablePointer<[UInt8]>.allocate(capacity: response.capacity) let pointer = UnsafeMutablePointer<[UInt8]>.allocate(capacity: response.capacity)
pointer.initialize(to: response) pointer.initialize(to: response)
let bytesRead = withUnsafeMutablePointer(to: &socketAddress) { let bytesRead = withUnsafeMutablePointer(to: &socketAddress) {
recvfrom(UDPSocket, pointer, response.count, 0, recvfrom(UDPSocket, pointer, response.count, 0,
UnsafeMutableRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1), &socketAddressLength) UnsafeMutableRawPointer($0).bindMemory(to: sockaddr.self, capacity: 1), &socketAddressLength)
} }
do { do {
guard bytesRead > 0 else { guard bytesRead > 0 else {
self.closeConnection() self.closeConnection()
if bytesRead == 0 { if bytesRead == 0 {
debugPrint("recvfrom returned EOF") debugPrint("recvfrom returned EOF")
throw ConnectionError.receivedEndOfFile throw ConnectionError.receivedEndOfFile
} else { } else {
if let errorString = String(validatingUTF8: strerror(errno)) { if let errorString = String(validatingUTF8: strerror(errno)) {
debugPrint("recvfrom failed: \(errorString)") debugPrint("recvfrom failed: \(errorString)")
} }
throw ConnectionError.receiveFailed(code: errno) throw ConnectionError.receiveFailed(code: errno)
} }
} }
guard let endpoint = withUnsafePointer(to: &socketAddress, guard let endpoint = withUnsafePointer(to: &socketAddress,
{ {
self self
.getEndpointFromSocketAddress(socketAddressPointer: UnsafeRawPointer($0) .getEndpointFromSocketAddress(socketAddressPointer: UnsafeRawPointer($0)
.bindMemory(to: sockaddr.self, capacity: 1)) }) .bindMemory(to: sockaddr.self, capacity: 1)) })
else { else {
// debugPrint("Failed to get the address and port from the socket address received from recvfrom") // debugPrint("Failed to get the address and port from the socket address received from recvfrom")
self.closeConnection() self.closeConnection()
return return
} }
// debugPrint("UDP connection received \(bytesRead) bytes from \(endpoint.host):\(endpoint.port)") // debugPrint("UDP connection received \(bytesRead) bytes from \(endpoint.host):\(endpoint.port)")
let responseBytes = Data(response[0 ..< bytesRead]) let responseBytes = Data(response[0 ..< bytesRead])
// Handle response // Handle response
self.handler?(endpoint.host, endpoint.port, responseBytes) self.handler?(endpoint.host, endpoint.port, responseBytes)
} catch { } catch {
if let error = error as? ConnectionError { if let error = error as? ConnectionError {
self.errorHandler?(error) self.errorHandler?(error)
} else { } else {
self.errorHandler?(ConnectionError.underlying(error: error)) self.errorHandler?(ConnectionError.underlying(error: error))
} }
} }
} }
newResponseSource.resume() newResponseSource.resume()
responseSource = newResponseSource responseSource = newResponseSource
} }
/// Send broadcast message. /// Send broadcast message.
/// ///
/// - Parameter message: Message to send via broadcast. /// - Parameter message: Message to send via broadcast.
/// - Throws: Throws a `ConnectionError` if an error occurs. /// - Throws: Throws a `ConnectionError` if an error occurs.
open func sendBroadcast(_ message: String) throws { open func sendBroadcast(_ message: String) throws {
guard let data = message.data(using: .utf8) else { throw ConnectionError.messageEncodingFailed } guard let data = message.data(using: .utf8) else { throw ConnectionError.messageEncodingFailed }
try sendBroadcast(data) try sendBroadcast(data)
} }
/// Send broadcast data. /// Send broadcast data.
/// ///
/// - Parameter data: Data to send via broadcast. /// - Parameter data: Data to send via broadcast.
/// - Throws: Throws a `ConnectionError` if an error occurs. /// - Throws: Throws a `ConnectionError` if an error occurs.
open func sendBroadcast(_ data: Data) throws { open func sendBroadcast(_ data: Data) throws {
if responseSource == nil { if responseSource == nil {
try createSocket() try createSocket()
} }
guard let source = responseSource else { return } guard let source = responseSource else { return }
let UDPSocket = Int32(source.handle) let UDPSocket = Int32(source.handle)
let socketLength = socklen_t(address.sin_len) let socketLength = socklen_t(address.sin_len)
try data.withUnsafeBytes { broadcastMessage in try data.withUnsafeBytes { broadcastMessage in
let broadcastMessageLength = data.count let broadcastMessageLength = data.count
let sent = withUnsafeMutablePointer(to: &address) { pointer -> Int in let sent = withUnsafeMutablePointer(to: &address) { pointer -> Int in
let memory = UnsafeRawPointer(pointer).bindMemory(to: sockaddr.self, capacity: 1) let memory = UnsafeRawPointer(pointer).bindMemory(to: sockaddr.self, capacity: 1)
return sendto(UDPSocket, broadcastMessage.baseAddress, broadcastMessageLength, 0, memory, socketLength) return sendto(UDPSocket, broadcastMessage.baseAddress, broadcastMessageLength, 0, memory, socketLength)
} }
guard sent > 0 else { guard sent > 0 else {
closeConnection() closeConnection()
throw ConnectionError.sendingMessageFailed(code: errno) throw ConnectionError.sendingMessageFailed(code: errno)
} }
} }
} }
/// Close the connection. /// Close the connection.
/// ///
/// - Parameter reopen: Automatically reopens the connection if true. Defaults to true. /// - Parameter reopen: Automatically reopens the connection if true. Defaults to true.
open func closeConnection(reopen: Bool = true) { open func closeConnection(reopen: Bool = true) {
if let source = responseSource { if let source = responseSource {
source.cancel() source.cancel()
responseSource = nil responseSource = nil
} }
if shouldBeBound && reopen { if shouldBeBound, reopen {
dispatchQueue.async { dispatchQueue.async {
do { do {
try self.createSocket() try self.createSocket()
} catch { } catch {
self.errorHandler?(ConnectionError.reopeningSocketFailed(error: error)) self.errorHandler?(ConnectionError.reopeningSocketFailed(error: error))
} }
} }
} }
} }
// MARK: - Helper // MARK: - Helper
/// Convert a sockaddr structure into an IP address string and port. /// Convert a sockaddr structure into an IP address string and port.
/// ///
/// - Parameter socketAddressPointer: socketAddressPointer: Pointer to a socket address. /// - Parameter socketAddressPointer: socketAddressPointer: Pointer to a socket address.
/// - Returns: Returns a tuple of the host IP address and the port in the socket address given. /// - Returns: Returns a tuple of the host IP address and the port in the socket address given.
func getEndpointFromSocketAddress(socketAddressPointer: UnsafePointer<sockaddr>) -> (host: String, port: Int)? { func getEndpointFromSocketAddress(socketAddressPointer: UnsafePointer<sockaddr>) -> (host: String, port: Int)? {
let socketAddress = UnsafePointer<sockaddr>(socketAddressPointer).pointee let socketAddress = UnsafePointer<sockaddr>(socketAddressPointer).pointee
switch Int32(socketAddress.sa_family) { switch Int32(socketAddress.sa_family) {
case AF_INET: case AF_INET:
var socketAddressInet = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in.self) var socketAddressInet = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in.self)
let length = Int(INET_ADDRSTRLEN) + 2 let length = Int(INET_ADDRSTRLEN) + 2
var buffer = [CChar](repeating: 0, count: length) var buffer = [CChar](repeating: 0, count: length)
let hostCString = inet_ntop(AF_INET, &socketAddressInet.sin_addr, &buffer, socklen_t(length)) let hostCString = inet_ntop(AF_INET, &socketAddressInet.sin_addr, &buffer, socklen_t(length))
let port = Int(UInt16(socketAddressInet.sin_port).byteSwapped) let port = Int(UInt16(socketAddressInet.sin_port).byteSwapped)
return (String(cString: hostCString!), port) return (String(cString: hostCString!), port)
case AF_INET6: case AF_INET6:
var socketAddressInet6 = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in6.self) var socketAddressInet6 = UnsafeRawPointer(socketAddressPointer).load(as: sockaddr_in6.self)
let length = Int(INET6_ADDRSTRLEN) + 2 let length = Int(INET6_ADDRSTRLEN) + 2
var buffer = [CChar](repeating: 0, count: length) var buffer = [CChar](repeating: 0, count: length)
let hostCString = inet_ntop(AF_INET6, &socketAddressInet6.sin6_addr, &buffer, socklen_t(length)) let hostCString = inet_ntop(AF_INET6, &socketAddressInet6.sin6_addr, &buffer, socklen_t(length))
let port = Int(UInt16(socketAddressInet6.sin6_port).byteSwapped) let port = Int(UInt16(socketAddressInet6.sin6_port).byteSwapped)
return (String(cString: hostCString!), port) return (String(cString: hostCString!), port)
default: default:
return nil return nil
} }
} }
// MARK: - Private // MARK: - Private
/// Prevents crashes when blocking calls are pending and the app is paused (via Home button). /// Prevents crashes when blocking calls are pending and the app is paused (via Home button).
/// ///
/// - Parameter socket: The socket for which the signal should be disabled. /// - Parameter socket: The socket for which the signal should be disabled.
fileprivate func setNoSigPipe(socket: CInt) { fileprivate func setNoSigPipe(socket: CInt) {
var no_sig_pipe: Int32 = 1 var no_sig_pipe: Int32 = 1
setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &no_sig_pipe, socklen_t(MemoryLayout<Int32>.size)) setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &no_sig_pipe, socklen_t(MemoryLayout<Int32>.size))
} }
fileprivate class func htonsPort(port: in_port_t) -> in_port_t { fileprivate class func htonsPort(port: in_port_t) -> in_port_t {
let isLittleEndian = Int(OSHostByteOrder()) == OSLittleEndian let isLittleEndian = Int(OSHostByteOrder()) == OSLittleEndian
return isLittleEndian ? _OSSwapInt16(port) : port return isLittleEndian ? _OSSwapInt16(port) : port
} }
fileprivate class func ntohs(value: CUnsignedShort) -> CUnsignedShort { fileprivate class func ntohs(value: CUnsignedShort) -> CUnsignedShort {
(value << 8) + (value >> 8) (value << 8) + (value >> 8)
} }
} }
// Created by Gunter Hager on 25.03.19. // Created by Gunter Hager on 25.03.19.
// Copyright © 2019 Gunter Hager. All rights reserved. // Copyright © 2019 Gunter Hager. All rights reserved.
// //
public extension UDPBroadcastConnection { public extension UDPBroadcastConnection {
enum ConnectionError: Error {
// Creating socket
case createSocketFailed
case enableBroadcastFailed
case bindSocketFailed
enum ConnectionError: Error { // Sending message
// Creating socket case messageEncodingFailed
case createSocketFailed case sendingMessageFailed(code: Int32)
case enableBroadcastFailed
case bindSocketFailed
// Sending message // Receiving data
case messageEncodingFailed case receivedEndOfFile
case sendingMessageFailed(code: Int32) case receiveFailed(code: Int32)
// Receiving data // Closing socket
case receivedEndOfFile case reopeningSocketFailed(error: Error)
case receiveFailed(code: Int32)
// Closing socket // Underlying
case reopeningSocketFailed(error: Error) case underlying(error: Error)
}
// Underlying
case underlying(error: Error)
}
} }

View File

@ -11,356 +11,352 @@ import SwiftUI
// TODO: Needs replacement/reworking // TODO: Needs replacement/reworking
struct SmallMediaStreamSelectionView: View { struct SmallMediaStreamSelectionView: View {
enum Layer: Hashable {
case subtitles
case audio
case playbackSpeed
case chapters
}
enum Layer: Hashable { enum MediaSection: Hashable {
case subtitles case titles
case audio case items
case playbackSpeed }
case chapters
}
enum MediaSection: Hashable { @ObservedObject
case titles var viewModel: VideoPlayerViewModel
case items private let chapterImages: [URL]
}
@ObservedObject @State
var viewModel: VideoPlayerViewModel private var updateFocusedLayer: Layer = .subtitles
private let chapterImages: [URL] @State
private var lastFocusedLayer: Layer = .subtitles
@State @FocusState
private var updateFocusedLayer: Layer = .subtitles private var subtitlesFocused: Bool
@State @FocusState
private var lastFocusedLayer: Layer = .subtitles private var audioFocused: Bool
@FocusState
private var playbackSpeedFocused: Bool
@FocusState
private var chaptersFocused: Bool
@FocusState
private var focusedSection: MediaSection?
@FocusState
private var focusedLayer: Layer? {
willSet {
updateFocusedLayer = newValue!
@FocusState if focusedSection == .titles {
private var subtitlesFocused: Bool lastFocusedLayer = newValue!
@FocusState }
private var audioFocused: Bool }
@FocusState }
private var playbackSpeedFocused: Bool
@FocusState
private var chaptersFocused: Bool
@FocusState
private var focusedSection: MediaSection?
@FocusState
private var focusedLayer: Layer? {
willSet {
updateFocusedLayer = newValue!
if focusedSection == .titles { init(viewModel: VideoPlayerViewModel) {
lastFocusedLayer = newValue! self.viewModel = viewModel
} self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500)
} }
}
init(viewModel: VideoPlayerViewModel) { var body: some View {
self.viewModel = viewModel ZStack(alignment: .bottom) {
self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
} startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.frame(height: 300)
var body: some View { VStack {
ZStack(alignment: .bottom) { Spacer()
LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]),
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.frame(height: 300)
VStack { HStack {
// MARK: Subtitle Header
Spacer() Button {
updateFocusedLayer = .subtitles
focusedLayer = .subtitles
} label: {
if updateFocusedLayer == .subtitles {
HStack(spacing: 15) {
Image(systemName: "captions.bubble")
L10n.subtitles.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "captions.bubble")
L10n.subtitles.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .subtitles)
.focused($subtitlesFocused)
.onChange(of: subtitlesFocused) { isFocused in
if isFocused {
focusedLayer = .subtitles
}
}
HStack { // MARK: Audio Header
// MARK: Subtitle Header Button {
updateFocusedLayer = .audio
focusedLayer = .audio
} label: {
if updateFocusedLayer == .audio {
HStack(spacing: 15) {
Image(systemName: "speaker.wave.3")
L10n.audio.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "speaker.wave.3")
L10n.audio.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .audio)
.focused($audioFocused)
.onChange(of: audioFocused) { isFocused in
if isFocused {
focusedLayer = .audio
}
}
Button { // MARK: Playback Speed Header
updateFocusedLayer = .subtitles
focusedLayer = .subtitles
} label: {
if updateFocusedLayer == .subtitles {
HStack(spacing: 15) {
Image(systemName: "captions.bubble")
L10n.subtitles.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "captions.bubble")
L10n.subtitles.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .subtitles)
.focused($subtitlesFocused)
.onChange(of: subtitlesFocused) { isFocused in
if isFocused {
focusedLayer = .subtitles
}
}
// MARK: Audio Header Button {
updateFocusedLayer = .playbackSpeed
focusedLayer = .playbackSpeed
} label: {
if updateFocusedLayer == .playbackSpeed {
HStack(spacing: 15) {
Image(systemName: "speedometer")
L10n.playbackSpeed.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "speedometer")
L10n.playbackSpeed.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .playbackSpeed)
.focused($playbackSpeedFocused)
.onChange(of: playbackSpeedFocused) { isFocused in
if isFocused {
focusedLayer = .playbackSpeed
}
}
Button { // MARK: Chapters Header
updateFocusedLayer = .audio
focusedLayer = .audio
} label: {
if updateFocusedLayer == .audio {
HStack(spacing: 15) {
Image(systemName: "speaker.wave.3")
L10n.audio.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "speaker.wave.3")
L10n.audio.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .audio)
.focused($audioFocused)
.onChange(of: audioFocused) { isFocused in
if isFocused {
focusedLayer = .audio
}
}
// MARK: Playback Speed Header if !viewModel.chapters.isEmpty {
Button {
updateFocusedLayer = .chapters
focusedLayer = .chapters
} label: {
if updateFocusedLayer == .chapters {
HStack(spacing: 15) {
Image(systemName: "list.dash")
L10n.chapters.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "list.dash")
L10n.chapters.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .chapters)
.focused($chaptersFocused)
.onChange(of: chaptersFocused) { isFocused in
if isFocused {
focusedLayer = .chapters
}
}
}
Button { Spacer()
updateFocusedLayer = .playbackSpeed }
focusedLayer = .playbackSpeed .padding()
} label: { .focusSection()
if updateFocusedLayer == .playbackSpeed { .focused($focusedSection, equals: .titles)
HStack(spacing: 15) { .onChange(of: focusedSection) { _ in
Image(systemName: "speedometer") if focusedSection == .titles {
L10n.playbackSpeed.text if lastFocusedLayer == .subtitles {
} subtitlesFocused = true
.padding() } else if lastFocusedLayer == .audio {
.background(Color.white) audioFocused = true
.foregroundColor(.black) } else if lastFocusedLayer == .playbackSpeed {
} else { playbackSpeedFocused = true
HStack(spacing: 15) { }
Image(systemName: "speedometer") }
L10n.playbackSpeed.text }
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .playbackSpeed)
.focused($playbackSpeedFocused)
.onChange(of: playbackSpeedFocused) { isFocused in
if isFocused {
focusedLayer = .playbackSpeed
}
}
// MARK: Chapters Header if updateFocusedLayer == .subtitles, lastFocusedLayer == .subtitles {
// MARK: Subtitles
if !viewModel.chapters.isEmpty { subtitleMenuView
Button { } else if updateFocusedLayer == .audio, lastFocusedLayer == .audio {
updateFocusedLayer = .chapters // MARK: Audio
focusedLayer = .chapters
} label: {
if updateFocusedLayer == .chapters {
HStack(spacing: 15) {
Image(systemName: "list.dash")
L10n.chapters.text
}
.padding()
.background(Color.white)
.foregroundColor(.black)
} else {
HStack(spacing: 15) {
Image(systemName: "list.dash")
L10n.chapters.text
}
.padding()
}
}
.buttonStyle(PlainButtonStyle())
.background(Color.clear)
.focused($focusedLayer, equals: .chapters)
.focused($chaptersFocused)
.onChange(of: chaptersFocused) { isFocused in
if isFocused {
focusedLayer = .chapters
}
}
}
Spacer() audioMenuView
} } else if updateFocusedLayer == .playbackSpeed, lastFocusedLayer == .playbackSpeed {
.padding() // MARK: Playback Speed
.focusSection()
.focused($focusedSection, equals: .titles)
.onChange(of: focusedSection) { _ in
if focusedSection == .titles {
if lastFocusedLayer == .subtitles {
subtitlesFocused = true
} else if lastFocusedLayer == .audio {
audioFocused = true
} else if lastFocusedLayer == .playbackSpeed {
playbackSpeedFocused = true
}
}
}
if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { playbackSpeedMenuView
// MARK: Subtitles } else if updateFocusedLayer == .chapters, lastFocusedLayer == .chapters {
// MARK: Chapters
subtitleMenuView chaptersMenuView
} else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { }
// MARK: Audio }
}
}
audioMenuView @ViewBuilder
} else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { private var subtitleMenuView: some View {
// MARK: Playback Speed ScrollView(.horizontal) {
HStack {
if viewModel.subtitleStreams.isEmpty {
Button {} label: {
L10n.none.text
}
} else {
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
Button {
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
} label: {
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(subtitleStream.displayTitle ?? L10n.noTitle)
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
playbackSpeedMenuView @ViewBuilder
} else if updateFocusedLayer == .chapters && lastFocusedLayer == .chapters { private var audioMenuView: some View {
// MARK: Chapters ScrollView(.horizontal) {
HStack {
if viewModel.audioStreams.isEmpty {
Button {} label: {
Text("None")
}
} else {
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
Button {
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
} label: {
if audioStream.index == viewModel.selectedAudioStreamIndex {
Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(audioStream.displayTitle ?? L10n.noTitle)
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
chaptersMenuView @ViewBuilder
} private var playbackSpeedMenuView: some View {
} ScrollView(.horizontal) {
} HStack {
} ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in
Button {
viewModel.playbackSpeed = playbackSpeed
} label: {
if playbackSpeed == viewModel.playbackSpeed {
Label(playbackSpeed.displayTitle, systemImage: "checkmark")
} else {
Text(playbackSpeed.displayTitle)
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
@ViewBuilder @ViewBuilder
private var subtitleMenuView: some View { private var chaptersMenuView: some View {
ScrollView(.horizontal) { ScrollView(.horizontal, showsIndicators: false) {
HStack { ScrollViewReader { reader in
if viewModel.subtitleStreams.isEmpty { HStack {
Button {} label: {
L10n.none.text
}
} else {
ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in
Button {
viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1
} label: {
if subtitleStream.index == viewModel.selectedSubtitleStreamIndex {
Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(subtitleStream.displayTitle ?? L10n.noTitle)
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
@ViewBuilder
private var audioMenuView: some View {
ScrollView(.horizontal) {
HStack {
if viewModel.audioStreams.isEmpty {
Button {} label: {
Text("None")
}
} else {
ForEach(viewModel.audioStreams, id: \.self) { audioStream in
Button {
viewModel.selectedAudioStreamIndex = audioStream.index ?? -1
} label: {
if audioStream.index == viewModel.selectedAudioStreamIndex {
Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark")
} else {
Text(audioStream.displayTitle ?? L10n.noTitle)
}
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
@ViewBuilder
private var playbackSpeedMenuView: some View {
ScrollView(.horizontal) {
HStack {
ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in
Button {
viewModel.playbackSpeed = playbackSpeed
} label: {
if playbackSpeed == viewModel.playbackSpeed {
Label(playbackSpeed.displayTitle, systemImage: "checkmark")
} else {
Text(playbackSpeed.displayTitle)
}
}
}
}
.padding(.vertical)
.focusSection()
.focused($focusedSection, equals: .items)
}
}
@ViewBuilder
private var chaptersMenuView: some View {
ScrollView(.horizontal, showsIndicators: false) {
ScrollViewReader { reader in
HStack {
ForEach(0 ..< viewModel.chapters.count, id: \.self) { chapterIndex in ForEach(0 ..< viewModel.chapters.count, id: \.self) { chapterIndex in
VStack(alignment: .leading) { VStack(alignment: .leading) {
Button { Button {
viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex])
} label: { } label: {
ImageView(chapterImages[chapterIndex]) ImageView(chapterImages[chapterIndex])
.cornerRadius(10) .cornerRadius(10)
.frame(width: 350, height: 210) .frame(width: 350, height: 210)
} }
.buttonStyle(CardButtonStyle()) .buttonStyle(CardButtonStyle())
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.white)
Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) Text(viewModel.chapters[chapterIndex].timestampLabel)
.font(.subheadline) .font(.subheadline)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(.white) .foregroundColor(Color(UIColor.systemBlue))
.padding(.vertical, 2)
Text(viewModel.chapters[chapterIndex].timestampLabel) .padding(.horizontal, 4)
.font(.subheadline) .background {
.fontWeight(.semibold) Color(UIColor.darkGray).opacity(0.2).cornerRadius(4)
.foregroundColor(Color(UIColor.systemBlue)) }
.padding(.vertical, 2) }
.padding(.horizontal, 4) }
.background { .id(viewModel.chapters[chapterIndex])
Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) }
} }
} .padding(.top)
} .onAppear {
.id(viewModel.chapters[chapterIndex]) reader.scrollTo(viewModel.currentChapter)
} }
} }
.padding(.top) }
.onAppear { }
reader.scrollTo(viewModel.currentChapter)
}
}
}
}
} }

View File

@ -191,6 +191,7 @@
62666E2127E501E400EC0ECD /* CoreVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2027E501E400EC0ECD /* CoreVideo.framework */; }; 62666E2127E501E400EC0ECD /* CoreVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2027E501E400EC0ECD /* CoreVideo.framework */; };
62666E2327E501EB00EC0ECD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2227E501EB00EC0ECD /* Foundation.framework */; }; 62666E2327E501EB00EC0ECD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2227E501EB00EC0ECD /* Foundation.framework */; };
62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4BC267D40D8000E2F71 /* Foundation.framework */; }; 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5362E4BC267D40D8000E2F71 /* Foundation.framework */; };
62666E2A27E5020A00EC0ECD /* OpenGLES.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2927E5020A00EC0ECD /* OpenGLES.framework */; };
62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2B27E5021000EC0ECD /* QuartzCore.framework */; }; 62666E2C27E5021000EC0ECD /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2B27E5021000EC0ECD /* QuartzCore.framework */; };
62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2D27E5021400EC0ECD /* Security.framework */; }; 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2D27E5021400EC0ECD /* Security.framework */; };
62666E3027E5021800EC0ECD /* VideoToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */; }; 62666E3027E5021800EC0ECD /* VideoToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62666E2F27E5021800EC0ECD /* VideoToolbox.framework */; };
@ -894,6 +895,7 @@
62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */, 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */,
62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */, 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */,
E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */, E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */,
62666E2A27E5020A00EC0ECD /* OpenGLES.framework in Frameworks */,
E1002B6B2793E36600E47059 /* Algorithms in Frameworks */, E1002B6B2793E36600E47059 /* Algorithms in Frameworks */,
62666E1D27E501DB00EC0ECD /* CoreMedia.framework in Frameworks */, 62666E1D27E501DB00EC0ECD /* CoreMedia.framework in Frameworks */,
62666E3027E5021800EC0ECD /* VideoToolbox.framework in Frameworks */, 62666E3027E5021800EC0ECD /* VideoToolbox.framework in Frameworks */,