mirror of
https://github.com/jellyfin/Swiftfin.git
synced 2025-02-24 01:00:54 +00:00
Runs SwiftLint and adds back OpenGLES
This commit is contained in:
parent
a6bcd668d5
commit
b43abf1548
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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 */,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user